├── .gitattributes ├── internal ├── cmd │ ├── preview-test │ │ ├── composable-schema-user.zed │ │ ├── composable-schema-root.zed │ │ └── composable-schema-invalid-root.zed │ ├── validate-test │ │ ├── invalid-schema.zed │ │ ├── composable-schema-user.zed │ │ ├── warnings.zed │ │ ├── missing-relation.zed │ │ ├── only-passes-composable.zed │ │ ├── only-passes-standard.zed │ │ ├── schema-only.zed │ │ ├── external-schema.zed │ │ ├── composable-schema-warning-root.zed │ │ ├── composable-schema-root.zed │ │ ├── missing-schema.yaml │ │ ├── external-schema.yaml │ │ ├── failed-assertions.yaml │ │ ├── external-and-composable.yaml │ │ ├── schema-relation-named-schema.zed │ │ ├── failed-expected-relations.yaml │ │ ├── missing-relation.yaml │ │ ├── schema-with-warnings.zed │ │ ├── standard-validation.yaml │ │ ├── warnings-point-at-right-line.zed │ │ └── warnings-point-at-right-line.yaml │ ├── write-schema-test │ │ └── basic.zed │ ├── import-test │ │ ├── happy-path-validation-schema.zed │ │ └── happy-path-validation-file.yaml │ ├── preview.go │ ├── mcp.go │ ├── helpers_test.go │ ├── import_test.go │ ├── version.go │ ├── version_test.go │ ├── cmd_test.go │ ├── import.go │ ├── context.go │ └── cmd.go ├── commands │ ├── relationship_wasm.go │ ├── relationship_nowasm.go │ ├── util_test.go │ ├── schema.go │ ├── util.go │ ├── completion.go │ └── watch_test.go ├── schemaexamples │ ├── .claude │ │ └── settings.local.json │ ├── schemaexamples_test.go │ ├── schemas │ │ ├── superuser │ │ │ ├── README.md │ │ │ └── schema-and-data.yaml │ │ ├── caveats │ │ │ ├── README.md │ │ │ └── schema-and-data.yaml │ │ ├── basic-rbac │ │ │ ├── schema-and-data.yaml │ │ │ └── README.md │ │ ├── user-defined-roles │ │ │ ├── README.md │ │ │ └── schema-and-data.yaml │ │ └── docs-style-sharing │ │ │ ├── README.md │ │ │ └── schema-and-data.yaml │ └── schemaexamples.go ├── printers │ ├── treeprinter_test.go │ ├── table_test.go │ ├── treeprinter.go │ ├── table.go │ ├── tree.go │ └── debug.go ├── storage │ ├── secrets_test.go │ ├── config_test.go │ └── config.go ├── console │ └── console.go ├── grpcutil │ ├── batch.go │ ├── batch_test.go │ └── grpcutil.go ├── testing │ └── test_helpers.go └── mcp │ └── instructions.go ├── .markdownlint.yaml ├── .gitignore ├── cmd └── zed │ └── main.go ├── NOTICE ├── mage.go ├── .yamllint ├── magefiles ├── build.go ├── test.go ├── lint.go ├── gen.go └── util.go ├── Dockerfile.release ├── .github ├── dependabot.yml └── workflows │ ├── cla.yaml │ ├── release-windows.yml │ ├── build-test.yaml │ ├── docs.yaml │ ├── release.yaml │ └── lint.yaml ├── Dockerfile ├── pkg ├── wasm │ ├── README.md │ ├── example │ │ └── wasm.html │ ├── main_test.go │ └── main.go └── backupformat │ ├── schema.go │ ├── decoder.go │ └── backupformat_test.go ├── DCO ├── .goreleaser.windows.yml ├── .golangci.yaml ├── CODE-OF-CONDUCT.md ├── docs └── getting-started.md ├── CONTRIBUTING.md ├── .goreleaser.docker.yml └── .goreleaser.yml /.gitattributes: -------------------------------------------------------------------------------- 1 | docs/zed.md linguist-generated=true -------------------------------------------------------------------------------- /internal/cmd/preview-test/composable-schema-user.zed: -------------------------------------------------------------------------------- 1 | definition user {} -------------------------------------------------------------------------------- /internal/cmd/validate-test/invalid-schema.zed: -------------------------------------------------------------------------------- 1 | something something {} 2 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/composable-schema-user.zed: -------------------------------------------------------------------------------- 1 | definition user {} 2 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | line-length: false 3 | no-hard-tabs: false 4 | MD041: false 5 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/warnings.zed: -------------------------------------------------------------------------------- 1 | definition test { 2 | permission view = view 3 | } -------------------------------------------------------------------------------- /internal/cmd/validate-test/missing-relation.zed: -------------------------------------------------------------------------------- 1 | definition test { 2 | permission view = write 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | temp/ 3 | docs/merged.md 4 | 5 | # Local-only files 6 | go.work 7 | go.work.sum 8 | coverage.txt -------------------------------------------------------------------------------- /internal/cmd/validate-test/only-passes-composable.zed: -------------------------------------------------------------------------------- 1 | partial foo {} 2 | 3 | definition bar { 4 | ...foo 5 | } 6 | -------------------------------------------------------------------------------- /cmd/zed/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/authzed/zed/internal/cmd" 4 | 5 | func main() { 6 | cmd.Run() 7 | } 8 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/only-passes-standard.zed: -------------------------------------------------------------------------------- 1 | // "and" is a reserved keyword in composable schemas 2 | definition and {} 3 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Zed 2 | Copyright 2022 Authzed, Inc 3 | 4 | This product includes software developed at 5 | Authzed, Inc. (https://www.authzed.com/). 6 | -------------------------------------------------------------------------------- /internal/cmd/write-schema-test/basic.zed: -------------------------------------------------------------------------------- 1 | definition user {} 2 | definition resource { 3 | relation view: user 4 | permission viewer = view 5 | } -------------------------------------------------------------------------------- /internal/commands/relationship_wasm.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import "os" 4 | 5 | var isFileTerminal = func(f *os.File) bool { return true } 6 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/schema-only.zed: -------------------------------------------------------------------------------- 1 | definition user {} 2 | 3 | definition resource { 4 | relation user: user 5 | permission view = user 6 | } 7 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/external-schema.zed: -------------------------------------------------------------------------------- 1 | definition user {} 2 | 3 | definition resource { 4 | relation user: user 5 | permission view = user 6 | } 7 | -------------------------------------------------------------------------------- /internal/cmd/import-test/happy-path-validation-schema.zed: -------------------------------------------------------------------------------- 1 | definition user {} 2 | 3 | definition resource { 4 | relation user: user 5 | permission view = user 6 | } 7 | -------------------------------------------------------------------------------- /internal/cmd/preview-test/composable-schema-root.zed: -------------------------------------------------------------------------------- 1 | import "./composable-schema-user.zed" 2 | 3 | definition resource { 4 | relation user: user 5 | permission view = user 6 | } -------------------------------------------------------------------------------- /internal/cmd/validate-test/composable-schema-warning-root.zed: -------------------------------------------------------------------------------- 1 | partial edit_partial { 2 | permission edit = edit 3 | } 4 | 5 | definition resource { 6 | ...edit_partial 7 | } -------------------------------------------------------------------------------- /internal/cmd/validate-test/composable-schema-root.zed: -------------------------------------------------------------------------------- 1 | import "./composable-schema-user.zed" 2 | 3 | definition resource { 4 | relation user: user 5 | permission view = user 6 | } 7 | -------------------------------------------------------------------------------- /internal/schemaexamples/.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(go test:*)" 5 | ], 6 | "deny": [], 7 | "ask": [] 8 | } 9 | } -------------------------------------------------------------------------------- /mage.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | 8 | "github.com/magefile/mage/mage" 9 | ) 10 | 11 | func main() { os.Exit(mage.Main()) } 12 | -------------------------------------------------------------------------------- /internal/cmd/preview-test/composable-schema-invalid-root.zed: -------------------------------------------------------------------------------- 1 | import "./composable-schema-user.zed" 2 | 3 | definition resource { 4 | relation and: user 5 | permission viewer = and 6 | } -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | # vim: ft=yaml 2 | --- 3 | yaml-files: 4 | - "*.yaml" 5 | - "*.yml" 6 | - ".yamllint" 7 | extends: "default" 8 | rules: 9 | quoted-strings: "enable" 10 | line-length: "disable" 11 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/missing-schema.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | relationships: >- 3 | resource:1#user@user:1 4 | assertions: 5 | assertTrue: 6 | - "resource:1#user@user:1" 7 | assertFalse: 8 | - "resource:1#user@user:2" 9 | -------------------------------------------------------------------------------- /internal/commands/relationship_nowasm.go: -------------------------------------------------------------------------------- 1 | //go:build !wasm 2 | 3 | package commands 4 | 5 | import ( 6 | "os" 7 | 8 | "golang.org/x/term" 9 | ) 10 | 11 | var isFileTerminal = func(f *os.File) bool { return term.IsTerminal(int(f.Fd())) } 12 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/external-schema.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schemaFile: "./external-schema.zed" 3 | relationships: >- 4 | resource:1#user@user:1 5 | assertions: 6 | assertTrue: 7 | - "resource:1#user@user:1" 8 | assertFalse: 9 | - "resource:1#user@user:2" 10 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/failed-assertions.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schema: |- 3 | definition user {} 4 | 5 | definition document { 6 | relation view: user 7 | permission viewer = view 8 | } 9 | assertions: 10 | assertTrue: 11 | - "document:1#viewer@user:maria" 12 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/external-and-composable.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schemaFile: "./composable-schema-root.zed" 3 | relationships: >- 4 | resource:1#user@user:1 5 | assertions: 6 | assertTrue: 7 | - "resource:1#user@user:1" 8 | assertFalse: 9 | - "resource:1#user@user:2" 10 | -------------------------------------------------------------------------------- /internal/cmd/import-test/happy-path-validation-file.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schemaFile: "./happy-path-validation-schema.zed" 3 | relationships: >- 4 | resource:1#user@user:1 5 | assertions: 6 | assertTrue: 7 | - "resource:1#user@user:1" 8 | assertFalse: 9 | - "resource:1#user@user:2" 10 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/schema-relation-named-schema.zed: -------------------------------------------------------------------------------- 1 | definition parent { 2 | relation owner: user 3 | 4 | permission manage = owner 5 | } 6 | 7 | definition child { 8 | relation schema: parent 9 | 10 | permission access = schema->manage 11 | } 12 | 13 | definition user {} 14 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/failed-expected-relations.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schema: |- 3 | definition user {} 4 | 5 | definition document { 6 | relation view: user 7 | permission viewer = view 8 | } 9 | validation: 10 | document:1#viewer: 11 | - "[user:maria] is " 12 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/missing-relation.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | relationships: >- 3 | test:1#viewer@user:1 4 | # notice how schema isn't the first section of the yaml 5 | schema: |- 6 | definition user {} 7 | definition test { 8 | relation viewer: user 9 | permission view = write 10 | } 11 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/schema-with-warnings.zed: -------------------------------------------------------------------------------- 1 | definition user {} 2 | 3 | definition resource { 4 | relation user: user 5 | 6 | // NOTE: This is what will throw the warning - 7 | // we recommend that you don't put the resource name 8 | // in the permission. 9 | permission view_resource = user 10 | } 11 | -------------------------------------------------------------------------------- /magefiles/build.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | "github.com/magefile/mage/mg" 8 | "github.com/magefile/mage/sh" 9 | ) 10 | 11 | type Build mg.Namespace 12 | 13 | // Binary builds the zed binary 14 | func (g Build) Binary() error { 15 | return sh.RunV("go", "build", "-o", "zed", "./cmd/zed") 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile.release: -------------------------------------------------------------------------------- 1 | # vim: syntax=dockerfile 2 | # NOTE: we use chainguard's static image because 3 | # the version of zed that we build for this container 4 | # is statically-linked (i.e. CGO_ENABLED=0) and therefore 5 | # doesn't need a libc. 6 | ARG BASE=cgr.dev/chainguard/static:latest 7 | 8 | FROM $BASE 9 | 10 | COPY zed /usr/local/bin/zed 11 | ENTRYPOINT ["zed"] 12 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/standard-validation.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schema: |- 3 | definition user {} 4 | 5 | definition resource { 6 | relation user: user 7 | permission view = user 8 | } 9 | relationships: >- 10 | resource:1#user@user:1 11 | assertions: 12 | assertTrue: 13 | - "resource:1#user@user:1" 14 | assertFalse: 15 | - "resource:1#user@user:2" 16 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemaexamples_test.go: -------------------------------------------------------------------------------- 1 | package schemaexamples 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestListExampleSchemas(t *testing.T) { 10 | schemas, err := ListExampleSchemas() 11 | require.NoError(t, err) 12 | require.NotEmpty(t, schemas, "Expected at least one schema") 13 | 14 | for i, schema := range schemas { 15 | require.NotEmpty(t, schema, "Schema at index %d should not be empty", i) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | labels: ["area/dependencies"] 9 | groups: 10 | go-mod: 11 | patterns: ["*"] 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "monthly" 17 | labels: ["area/dependencies"] 18 | groups: 19 | github-actions: 20 | patterns: ["*"] 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # NOTE: we use the chainguard image here rather 2 | # than the golang image because the golang image 3 | # uses musl as its libc, and chainguard no longer provides 4 | # a musl-dynamic container. - 2024-12-02 Tanner Stirrat 5 | FROM cgr.dev/chainguard/go:latest AS zed-builder 6 | WORKDIR /go/src/app 7 | COPY . . 8 | RUN go build -v ./cmd/zed/ 9 | 10 | FROM cgr.dev/chainguard/glibc-dynamic:latest 11 | COPY --from=zed-builder /go/src/app/zed /usr/local/bin/zed 12 | ENTRYPOINT ["zed"] 13 | -------------------------------------------------------------------------------- /internal/printers/treeprinter_test.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestTreePrinter(t *testing.T) { 10 | tp := NewTreePrinter() 11 | tp.Child("value") 12 | require.Equal(t, "value\n", tp.String()) 13 | 14 | tp = NewTreePrinter() 15 | tp = tp.Child("parent") 16 | sib := tp.Child("child1") 17 | sib.Child("grandchild") 18 | tp.Child("child2") 19 | require.Equal(t, "parent\n├── child1\n│ └── grandchild\n└── child2\n", tp.String()) 20 | } 21 | -------------------------------------------------------------------------------- /internal/storage/secrets_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestTokenAnyValue(t *testing.T) { 10 | b := false 11 | 12 | require.False(t, Token{}.AnyValue()) 13 | require.False(t, Token{}.AnyValue()) 14 | require.True(t, Token{Endpoint: "foo"}.AnyValue()) 15 | require.True(t, Token{APIToken: "foo"}.AnyValue()) 16 | require.True(t, Token{Insecure: &b}.AnyValue()) 17 | require.True(t, Token{NoVerifyCA: &b}.AnyValue()) 18 | require.True(t, Token{CACert: []byte("a")}.AnyValue()) 19 | } 20 | -------------------------------------------------------------------------------- /magefiles/test.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | "github.com/magefile/mage/mg" 8 | "github.com/magefile/mage/sh" 9 | ) 10 | 11 | type Test mg.Namespace 12 | 13 | // Run runs unit tests 14 | func (g Test) Run() error { 15 | return sh.RunV("go", "test", "-race", "-count=1", "-timeout=10m", "./...") 16 | } 17 | 18 | // RunWithCoverage runs unit tests and measures coverage (useful for CI) 19 | func (g Test) RunWithCoverage() error { 20 | return sh.RunV("go", "test", "-race", "-count=1", "-timeout=10m", "-covermode=atomic", "-coverprofile=coverage.txt", "./...") 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/cla.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CLA" 3 | on: # yamllint disable-line rule:truthy 4 | issue_comment: 5 | types: 6 | - "created" 7 | pull_request_target: 8 | types: 9 | - "opened" 10 | - "closed" 11 | - "synchronize" 12 | merge_group: 13 | types: 14 | - "checks_requested" 15 | jobs: 16 | cla: 17 | name: "Check Signature" 18 | runs-on: "ubuntu-latest" 19 | steps: 20 | - uses: "authzed/actions/cla-check@main" 21 | with: 22 | github_token: "${{ secrets.GITHUB_TOKEN }}" 23 | cla_assistant_token: "${{ secrets.CLA_ASSISTANT_ACCESS_TOKEN }}" 24 | -------------------------------------------------------------------------------- /internal/cmd/preview.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func registerPreviewCmd(rootCmd *cobra.Command, schemaCompileCmd *cobra.Command) { 8 | previewCmd := &cobra.Command{ 9 | Use: "preview ", 10 | Short: "Experimental commands that have been made available for preview", 11 | } 12 | 13 | schemaCmd := &cobra.Command{ 14 | Use: "schema ", 15 | Short: "Manage schema for a permissions system", 16 | Deprecated: "please use `zed schema compile`", 17 | } 18 | 19 | rootCmd.AddCommand(previewCmd) 20 | previewCmd.AddCommand(schemaCmd) 21 | schemaCmd.AddCommand(schemaCompileCmd) 22 | } 23 | -------------------------------------------------------------------------------- /magefiles/lint.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | "github.com/magefile/mage/mg" 8 | "github.com/magefile/mage/sh" 9 | ) 10 | 11 | type Lint mg.Namespace 12 | 13 | // All runs all linters 14 | func (g Lint) All() error { 15 | mg.Deps(g.Go, g.Vulncheck) 16 | return nil 17 | } 18 | 19 | // Go runs golangci-lint 20 | func (g Lint) Go() error { 21 | return sh.RunV("go", "run", "github.com/golangci/golangci-lint/v2/cmd/golangci-lint", "run", "--fix", "-c", ".golangci.yaml", "./...") 22 | } 23 | 24 | // Vulncheck runs vulncheck 25 | func (g Lint) Vulncheck() error { 26 | return sh.RunV("go", "run", "golang.org/x/vuln/cmd/govulncheck", "./...") 27 | } 28 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/superuser/README.md: -------------------------------------------------------------------------------- 1 | # Super-admin / site-wide permissions 2 | 3 | Models providing site-wide (or superuser) permissions for all resources of a specific type 4 | 5 | --- 6 | 7 | ## Schema 8 | 9 | ```zed 10 | definition platform { 11 | relation administrator: user 12 | permission super_admin = administrator 13 | } 14 | 15 | definition organization { 16 | // The platform is generally a singleton pointing to the same 17 | // platform object, on which the superuser is in turn granted 18 | // access. 19 | relation platform: platform 20 | permission admin = platform->super_admin 21 | } 22 | 23 | definition resource { 24 | relation owner: user | organization 25 | permission admin = owner + owner->admin 26 | } 27 | 28 | definition user {} 29 | ``` 30 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/warnings-point-at-right-line.zed: -------------------------------------------------------------------------------- 1 | definition user {} 2 | 3 | definition organization {} 4 | 5 | definition platform {} 6 | 7 | definition resource { 8 | /** platform is the platform to which the resource belongs */ 9 | relation platform: platform 10 | 11 | /** 12 | * organization is the organization to which the resource belongs 13 | */ 14 | relation organization: organization 15 | 16 | /** admin is a user that can administer the resource */ 17 | relation admin: user 18 | 19 | /** viewer is a read-only viewer of the resource */ 20 | relation viewer: user 21 | 22 | /** can_admin allows a user to administer the resource */ 23 | permission can_admin = admin 24 | 25 | /** delete_resource allows a user to delete the resource. */ 26 | permission delete_resource = can_admin 27 | } 28 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/caveats/README.md: -------------------------------------------------------------------------------- 1 | # Caveats for conditional access 2 | 3 | Models the use of caveats, which allows for conditional access based on information provided at _runtime_ to permission checks. 4 | 5 | --- 6 | 7 | ## Schema 8 | 9 | ```zed 10 | definition user {} 11 | 12 | /** 13 | * only allowed on tuesdays. `day_of_week` can be provided either at the time 14 | * the relationship is written, or in the CheckPermission API call. 15 | */ 16 | caveat only_on_tuesday(day_of_week string) { 17 | day_of_week == 'tuesday' 18 | } 19 | 20 | definition document { 21 | /** 22 | * reader indicates that the user is a reader on the document, either 23 | * directly or only on tuesday. 24 | */ 25 | relation reader: user | user with only_on_tuesday 26 | 27 | permission view = reader 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /internal/cmd/validate-test/warnings-point-at-right-line.yaml: -------------------------------------------------------------------------------- 1 | schema: | 2 | definition user {} 3 | 4 | definition organization {} 5 | 6 | definition platform {} 7 | 8 | definition resource { 9 | /** platform is the platform to which the resource belongs */ 10 | relation platform: platform 11 | 12 | /** 13 | * organization is the organization to which the resource belongs 14 | */ 15 | relation organization: organization 16 | 17 | /** admin is a user that can administer the resource */ 18 | relation admin: user 19 | 20 | /** viewer is a read-only viewer of the resource */ 21 | relation viewer: user 22 | 23 | /** can_admin allows a user to administer the resource */ 24 | permission can_admin = admin 25 | 26 | /** delete_resource allows a user to delete the resource. */ 27 | permission delete_resource = can_admin 28 | } 29 | -------------------------------------------------------------------------------- /internal/printers/table_test.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestPrintTable(t *testing.T) { 11 | var buf strings.Builder 12 | headers := []string{"CURRENT NAME", "ENDPOINT", "TOKEN", "TLS CERT"} 13 | rows := [][]string{ 14 | {"my-cluster-1", "local-cluster.dedicated.authzed.dev:443", "sdbpk_", "system"}, 15 | {"my-cluster-2", "localhost:50051", "", "insecure"}, 16 | } 17 | 18 | PrintTable(&buf, headers, rows) 19 | output := buf.String() 20 | 21 | expectedOutput := ` CURRENT NAME ENDPOINT TOKEN TLS CERT 22 | my-cluster-1 local-cluster.dedicated.authzed.dev:443 sdbpk_ system 23 | my-cluster-2 localhost:50051 insecure 24 | ` 25 | 26 | require.Equal(t, expectedOutput, output) 27 | } 28 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/superuser/schema-and-data.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schema: |- 3 | definition platform { 4 | relation administrator: user 5 | permission super_admin = administrator 6 | } 7 | 8 | definition organization { 9 | // The platform is generally a singleton pointing to the same 10 | // platform object, on which the superuser is in turn granted 11 | // access. 12 | relation platform: platform 13 | permission admin = platform->super_admin 14 | } 15 | 16 | definition resource { 17 | relation owner: user | organization 18 | permission admin = owner + owner->admin 19 | } 20 | 21 | definition user {} 22 | relationships: |- 23 | platform:evilempire#administrator@user:drevil 24 | organization:virtucon#platform@platform:evilempire 25 | resource:lasers#owner@organization:virtucon 26 | assertions: 27 | assertTrue: 28 | - "resource:lasers#admin@user:drevil" 29 | assertFalse: null 30 | validation: null 31 | -------------------------------------------------------------------------------- /internal/printers/treeprinter.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/xlab/treeprint" 7 | 8 | "github.com/authzed/zed/internal/console" 9 | ) 10 | 11 | type TreePrinter struct { 12 | tree treeprint.Tree 13 | } 14 | 15 | func NewTreePrinter() *TreePrinter { 16 | return &TreePrinter{} 17 | } 18 | 19 | func (tp *TreePrinter) Child(val string) *TreePrinter { 20 | if tp.tree == nil { 21 | tp.tree = treeprint.NewWithRoot(val) 22 | return tp 23 | } 24 | return &TreePrinter{tree: tp.tree.AddBranch(val)} 25 | } 26 | 27 | func (tp *TreePrinter) Print() { 28 | console.Println(tp.String()) 29 | } 30 | 31 | func (tp *TreePrinter) Indented() string { 32 | var sb strings.Builder 33 | lines := strings.Split(tp.String(), "\n") 34 | for _, line := range lines { 35 | sb.WriteString(" " + line + "\n") 36 | } 37 | 38 | return sb.String() 39 | } 40 | 41 | func (tp *TreePrinter) String() string { 42 | return tp.tree.String() 43 | } 44 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemaexamples.go: -------------------------------------------------------------------------------- 1 | package schemaexamples 2 | 3 | import "embed" 4 | 5 | //go:embed schemas/**/*.yaml 6 | var exampleSchemas embed.FS 7 | 8 | // ListExampleSchemas returns a list of all example schemas embedded in the binary. 9 | func ListExampleSchemas() ([][]byte, error) { 10 | var schemas [][]byte 11 | found, err := exampleSchemas.ReadDir("schemas") 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | for _, entry := range found { 17 | // Read the YAML found in each directory. 18 | if entry.IsDir() { 19 | subEntries, err := exampleSchemas.ReadDir("schemas/" + entry.Name()) 20 | if err != nil { 21 | return nil, err 22 | } 23 | for _, subEntry := range subEntries { 24 | if !subEntry.IsDir() { 25 | schema, err := exampleSchemas.ReadFile("schemas/" + entry.Name() + "/" + subEntry.Name()) 26 | if err != nil { 27 | return nil, err 28 | } 29 | schemas = append(schemas, schema) 30 | } 31 | } 32 | } 33 | } 34 | return schemas, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/wasm/README.md: -------------------------------------------------------------------------------- 1 | # WebAssembly zed Package 2 | 3 | This package provides zed's functionality via a WebAssembly interface, for use with browser-based tooling. 4 | 5 | > **Warning** 6 | > The WebAssembly development interface is **not stable** and subject to change between versions of zed. 7 | 8 | ## Generating WebAssembly 9 | 10 | ```sh 11 | GOOS=js GOARCH=wasm go build -o main.wasm 12 | ``` 13 | 14 | ## Testing 15 | 16 | ```sh 17 | go install github.com/agnivade/wasmbrowsertest@latest 18 | GOOS=js GOARCH=wasm go test ./... -exec $(go env GOPATH)/bin/wasmbrowsertest 19 | ``` 20 | 21 | ## Integrating with the browser 22 | 23 | To see an example of invoking the WebAssembly based interface: 24 | 25 | 1. Build `main.wasm` and copy into the [example](example) directory. 26 | 2. Copy [https://github.com/golang/go/blob/master/misc/wasm/wasm_exec.js](https://github.com/golang/go/blob/master/misc/wasm/wasm_exec.js) into the [example](example) directory 27 | 3. Run an HTTP server over the example directory and visit wasm.html: 28 | 29 | ```sh 30 | python3 -m http.server 31 | ``` 32 | -------------------------------------------------------------------------------- /magefiles/gen.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | 9 | "github.com/jzelinskie/cobrautil/v2/cobrazerolog" 10 | "github.com/magefile/mage/mg" 11 | "github.com/magefile/mage/sh" 12 | 13 | "github.com/authzed/zed/internal/cmd" 14 | ) 15 | 16 | type Gen mg.Namespace 17 | 18 | // All Run all generators in parallel 19 | func (g Gen) All() error { 20 | mg.Deps(g.Docs) 21 | return nil 22 | } 23 | 24 | // Docs Generate documentation in markdown format 25 | func (g Gen) Docs() error { 26 | targetDir := "docs" 27 | 28 | if err := os.MkdirAll(targetDir, os.ModePerm); err != nil { 29 | return err 30 | } 31 | 32 | rootCmd := cmd.InitialiseRootCmd(cobrazerolog.New()) 33 | 34 | return GenCustomMarkdownTree(rootCmd, targetDir) 35 | } 36 | 37 | // DocsForPublish generates a markdown file for publishing in the docs website. 38 | func (g Gen) DocsForPublish() error { 39 | if err := g.Docs(); err != nil { 40 | return err 41 | } 42 | 43 | return sh.RunV("bash", "-c", "cat docs/getting-started.md <(echo -e '\\n') docs/zed.md > docs/merged.md") 44 | } 45 | -------------------------------------------------------------------------------- /internal/printers/table.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/olekukonko/tablewriter/renderer" 8 | "github.com/olekukonko/tablewriter/tw" 9 | ) 10 | 11 | // PrintTable writes an terminal-friendly table of the values to the target. 12 | func PrintTable(target io.Writer, headers []string, rows [][]string) { 13 | table := tablewriter.NewTable(target, 14 | tablewriter.WithRenderer(renderer.NewBlueprint()), 15 | tablewriter.WithRowAutoWrap(tw.WrapNone), 16 | tablewriter.WithHeaderAutoFormat(tw.On), 17 | tablewriter.WithHeaderAlignment(tw.AlignLeft), 18 | tablewriter.WithRowAlignment(tw.AlignLeft), 19 | tablewriter.WithRendition(tw.Rendition{ 20 | Symbols: tw.NewSymbolCustom("custom").WithCenter("").WithColumn("").WithRow(""), 21 | Settings: tw.Settings{ 22 | Lines: tw.LinesNone, 23 | Separators: tw.Separators{ 24 | BetweenColumns: tw.On, 25 | }, 26 | }, 27 | Borders: tw.BorderNone, 28 | }), 29 | tablewriter.WithTrimSpace(tw.Off), 30 | ) 31 | table.Header(headers) 32 | _ = table.Bulk(rows) 33 | _ = table.Render() 34 | } 35 | -------------------------------------------------------------------------------- /internal/commands/util_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestValidationWrapper(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | positionalArgs cobra.PositionalArgs 14 | args []string 15 | wantErr bool 16 | }{ 17 | { 18 | name: "valid args", 19 | positionalArgs: cobra.MaximumNArgs(2), 20 | args: []string{"arg1", "arg2"}, 21 | wantErr: false, 22 | }, 23 | { 24 | name: "invalid args", 25 | positionalArgs: cobra.MaximumNArgs(2), 26 | args: []string{"arg1", "arg2", "arg3"}, 27 | wantErr: true, 28 | }, 29 | } 30 | 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | err := ValidationWrapper(tt.positionalArgs)(nil, tt.args) 34 | if tt.wantErr { 35 | var validationError ValidationError 36 | require.ErrorAs(t, err, &validationError) 37 | require.Error(t, validationError.error) 38 | require.ErrorContains(t, validationError.error, "accepts at most") 39 | } else { 40 | require.NoError(t, err) 41 | } 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/caveats/schema-and-data.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schema: |- 3 | definition user {} 4 | 5 | /** 6 | * only allowed on tuesdays. `day_of_week` can be provided either at the time 7 | * the relationship is written, or in the CheckPermission API call. 8 | */ 9 | caveat only_on_tuesday(day_of_week string) { 10 | day_of_week == 'tuesday' 11 | } 12 | 13 | definition document { 14 | /** 15 | * reader indicates that the user is a reader on the document, either directly 16 | * or only on tuesday. 17 | */ 18 | relation reader: user | user with only_on_tuesday 19 | 20 | permission view = reader 21 | } 22 | relationships: |- 23 | document:firstdoc#reader@user:fred 24 | document:firstdoc#reader@user:tom[only_on_tuesday] 25 | assertions: 26 | assertTrue: 27 | - 'document:firstdoc#view@user:tom with {"day_of_week": "tuesday"}' 28 | - "document:firstdoc#view@user:fred" 29 | assertCaveated: 30 | - "document:firstdoc#view@user:tom" 31 | assertFalse: 32 | - 'document:firstdoc#view@user:tom with {"day_of_week": "wednesday"}' 33 | validation: 34 | document:firstdoc#view: 35 | - "[user:fred] is " 36 | - "[user:tom[...]] is " 37 | document:seconddoc#view: [] 38 | -------------------------------------------------------------------------------- /internal/storage/config_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestTokenWithOverride(t *testing.T) { 11 | bTrue := true 12 | referenceToken := Token{ 13 | Name: "n1", 14 | Endpoint: "e1", 15 | APIToken: "a1", 16 | Insecure: &bTrue, 17 | NoVerifyCA: &bTrue, 18 | CACert: []byte("c1"), 19 | } 20 | 21 | bFalse := false 22 | override := Token{ 23 | Name: "n2", 24 | Endpoint: "e2", 25 | APIToken: "a2", 26 | Insecure: &bFalse, 27 | NoVerifyCA: &bFalse, 28 | CACert: []byte("c2"), 29 | } 30 | 31 | result, err := TokenWithOverride(override, referenceToken) 32 | require.NoError(t, err) 33 | require.Equal(t, "n1", result.Name) 34 | require.Equal(t, "e2", result.Endpoint) 35 | require.Equal(t, "a2", result.APIToken) 36 | require.False(t, *result.Insecure) 37 | require.False(t, *result.NoVerifyCA) 38 | require.Equal(t, 0, bytes.Compare([]byte("c2"), result.CACert)) 39 | 40 | result, err = TokenWithOverride(Token{}, referenceToken) 41 | require.NoError(t, err) 42 | require.Equal(t, "n1", result.Name) 43 | require.Equal(t, "e1", result.Endpoint) 44 | require.Equal(t, "a1", result.APIToken) 45 | require.True(t, *result.Insecure) 46 | require.True(t, *result.NoVerifyCA) 47 | require.Equal(t, 0, bytes.Compare([]byte("c1"), result.CACert)) 48 | } 49 | -------------------------------------------------------------------------------- /DCO: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 1 Letterman Drive 6 | Suite D4700 7 | San Francisco, CA, 94129 8 | 9 | Everyone is permitted to copy and distribute verbatim copies of this 10 | license document, but changing it is not allowed. 11 | 12 | 13 | Developer's Certificate of Origin 1.1 14 | 15 | By making a contribution to this project, I certify that: 16 | 17 | (a) The contribution was created in whole or in part by me and I 18 | have the right to submit it under the open source license 19 | indicated in the file; or 20 | 21 | (b) The contribution is based upon previous work that, to the best 22 | of my knowledge, is covered under an appropriate open source 23 | license and I have the right under that license to submit that 24 | work with modifications, whether created in whole or in part 25 | by me, under the same open source license (unless I am 26 | permitted to submit under a different license), as indicated 27 | in the file; or 28 | 29 | (c) The contribution was provided directly to me by some other 30 | person who certified (a), (b) or (c) and I have not modified 31 | it. 32 | 33 | (d) I understand and agree that this project and the contribution 34 | are public and that a record of the contribution (including all 35 | personal information I submit with it, including my sign-off) is 36 | maintained indefinitely and may be redistributed consistent with 37 | this project or the open source license(s) involved. 38 | -------------------------------------------------------------------------------- /internal/cmd/mcp.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/authzed/zed/internal/commands" 7 | "github.com/authzed/zed/internal/mcp" 8 | ) 9 | 10 | func registerMCPCmd(rootCmd *cobra.Command) { 11 | mcpCmd := &cobra.Command{ 12 | Use: "mcp ", 13 | Short: "MCP (Model Context Protocol) server commands", 14 | Long: `MCP (Model Context Protocol) server commands. 15 | 16 | The MCP server provides tooling and resources for developing and debugging SpiceDB schema and relationships. The server runs an in-memory development instance of SpiceDB and does not connect to a running instance of SpiceDB. 17 | 18 | To use with Claude Code, run ` + "`zed mcp experimental-run`" + ` to start the SpiceDB Dev MCP server and then run ` + "`claude mcp add --transport http spicedb \"http://localhost:9999/mcp\"`" + ` to add the server to your Claude Code integrations.`, 19 | } 20 | 21 | mcpRunCmd := &cobra.Command{ 22 | Use: "experimental-run", 23 | Short: "Run the Experimental MCP server", 24 | Args: commands.ValidationWrapper(cobra.ExactArgs(0)), 25 | ValidArgsFunction: cobra.NoFileCompletions, 26 | RunE: mcpRunCmdFunc, 27 | } 28 | 29 | rootCmd.AddCommand(mcpCmd) 30 | mcpCmd.AddCommand(mcpRunCmd) 31 | mcpRunCmd.Flags().IntP("port", "p", 9999, "port for the HTTP streaming server") 32 | } 33 | 34 | func mcpRunCmdFunc(cmd *cobra.Command, _ []string) error { 35 | port, _ := cmd.Flags().GetInt("port") 36 | 37 | server := mcp.NewSpiceDBMCPServer() 38 | return server.Run(port) 39 | } 40 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/basic-rbac/schema-and-data.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schema: |- 3 | /** 4 | * user represents a user that can be granted role(s) 5 | */ 6 | definition user {} 7 | 8 | /** 9 | * document represents a document protected by Authzed. 10 | */ 11 | definition document { 12 | /** 13 | * writer indicates that the user is a writer on the document. 14 | */ 15 | relation writer: user 16 | 17 | /** 18 | * reader indicates that the user is a reader on the document. 19 | */ 20 | relation reader: user 21 | 22 | /** 23 | * edit indicates that the user has permission to edit the document. 24 | */ 25 | permission edit = writer 26 | 27 | /** 28 | * view indicates that the user has permission to view the document, if they 29 | * are a `reader` *or* have `edit` permission. 30 | */ 31 | permission view = reader + edit 32 | } 33 | 34 | relationships: |- 35 | document:firstdoc#writer@user:tom 36 | document:firstdoc#reader@user:fred 37 | document:seconddoc#reader@user:tom 38 | 39 | assertions: 40 | assertTrue: 41 | - "document:firstdoc#view@user:tom" 42 | - "document:firstdoc#view@user:fred" 43 | - "document:seconddoc#view@user:tom" 44 | assertFalse: 45 | - "document:seconddoc#view@user:fred" 46 | 47 | validation: 48 | document:firstdoc#view: 49 | - "[user:tom] is " 50 | - "[user:fred] is " 51 | document:seconddoc#view: 52 | - "[user:tom] is " 53 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/user-defined-roles/README.md: -------------------------------------------------------------------------------- 1 | # User Defined Roles 2 | 3 | Models user defined custom roles. Blog post: 4 | 5 | --- 6 | 7 | ## Schema 8 | 9 | ```zed 10 | definition user {} 11 | 12 | definition project { 13 | relation issue_creator: role#member 14 | relation issue_assigner: role#member 15 | relation any_issue_resolver: role#member 16 | relation assigned_issue_resolver: role#member 17 | relation comment_creator: role#member 18 | relation comment_deleter: role#member 19 | relation role_manager: role#member 20 | 21 | permission create_issue = issue_creator 22 | permission create_role = role_manager 23 | } 24 | 25 | definition role { 26 | relation project: project 27 | relation member: user 28 | relation built_in_role: project 29 | 30 | permission delete = project->role_manager - built_in_role->role_manager 31 | permission add_user = project->role_manager 32 | permission add_permission = project->role_manager - built_in_role->role_manager 33 | permission remove_permission = project->role_manager - built_in_role->role_manager 34 | } 35 | 36 | definition issue { 37 | relation project: project 38 | relation assigned: user 39 | 40 | permission assign = project->issue_assigner 41 | permission resolve = (project->assigned_issue_resolver & assigned) + project->any_issue_resolver 42 | permission create_comment = project->comment_creator 43 | 44 | // synthetic relation 45 | permission project_comment_deleter = project->comment_deleter 46 | } 47 | 48 | definition comment { 49 | relation issue: issue 50 | permission delete = issue->project_comment_deleter 51 | } 52 | ``` 53 | -------------------------------------------------------------------------------- /internal/cmd/helpers_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "testing" 7 | 8 | "github.com/samber/lo" 9 | "github.com/spf13/cobra" 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/authzed/spicedb/pkg/tuple" 13 | 14 | "github.com/authzed/zed/pkg/backupformat" 15 | ) 16 | 17 | func mapRelationshipTuplesToCLIOutput(t *testing.T, input []string) []string { 18 | t.Helper() 19 | 20 | return lo.Map(input, func(item string, _ int) string { 21 | return replaceRelString(item) 22 | }) 23 | } 24 | 25 | func readLines(t *testing.T, fileName string) []string { 26 | t.Helper() 27 | 28 | f, err := os.Open(fileName) 29 | require.NoError(t, err) 30 | defer func() { 31 | _ = f.Close() 32 | }() 33 | 34 | var lines []string 35 | scanner := bufio.NewScanner(f) 36 | for scanner.Scan() { 37 | lines = append(lines, scanner.Text()) 38 | } 39 | 40 | return lines 41 | } 42 | 43 | // createTestBackup creates a test backup file with the given schema and relationships. 44 | // It returns the file name of the created backup. 45 | // When the test is done, the file is closed and removed. 46 | func createTestBackup(t *testing.T, cmd *cobra.Command, schema string, relationships []string) string { 47 | t.Helper() 48 | 49 | f, err := os.CreateTemp(t.TempDir(), "test-backup") 50 | require.NoError(t, err) 51 | t.Cleanup(func() { 52 | _ = f.Close() 53 | _ = os.Remove(f.Name()) 54 | }) 55 | 56 | avroWriter := backupformat.NewOcfEncoder(f) 57 | encoder := &backupformat.RewriteEncoder{Rewriter: backupformat.RewriterFromFlags(cmd), Encoder: avroWriter} 58 | defer func() { 59 | require.NoError(t, avroWriter.Close()) 60 | }() 61 | require.NoError(t, encoder.WriteSchema(schema, "test")) 62 | 63 | for _, rel := range relationships { 64 | r := tuple.MustParseV1Rel(rel) 65 | require.NoError(t, encoder.Append(r, "")) 66 | } 67 | 68 | return f.Name() 69 | } 70 | -------------------------------------------------------------------------------- /.goreleaser.windows.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | git: 4 | tag_sort: "-version:creatordate" 5 | prerelease_suffix: "-" 6 | builds: 7 | - main: "./cmd/zed" 8 | env: 9 | - "CGO_ENABLED=0" 10 | goos: 11 | - "windows" 12 | goarch: 13 | - "amd64" 14 | mod_timestamp: "{{ .CommitTimestamp }}" 15 | ldflags: 16 | - "-s -w" 17 | - "-X github.com/jzelinskie/cobrautil/v2.Version=v{{ .Version }}" 18 | archives: 19 | - files: 20 | - "README.md" 21 | - "LICENSE" 22 | format_overrides: 23 | - goos: "windows" 24 | formats: ["zip"] 25 | 26 | chocolateys: 27 | - name: "zed" 28 | package_source_url: "https://github.com/authzed/zed/releases" 29 | owners: "AuthZed, Inc" 30 | title: "Zed" 31 | project_url: "https://github.com/authzed/zed" 32 | url_template: "https://github.com/authzed/zed/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 33 | icon_url: "https://cdn.jsdelivr.net/gh/authzed/docs@refs/heads/main/public/images/favicon.svg" 34 | copyright: "2025 AuthZed Inc." 35 | authors: "Zed Contributors" 36 | license_url: "https://github.com/authzed/zed/blob/main/LICENSE" 37 | project_source_url: "https://github.com/authzed/zed" 38 | docs_url: "https://authzed.com/docs" 39 | bug_tracker_url: "https://github.com/authzed/zed/issues" 40 | tags: "spicedb zanzibar authz rebac rbac abac fga cli command-line-tool" 41 | summary: "Official command-line tool for managing SpiceDB" 42 | description: | 43 | Open Source command-line client for managing SpiceDB clusters, built by AuthZed. 44 | release_notes: "https://github.com/authzed/zed/releases/tag/v{{ .Version }}" 45 | api_key: "{{ .Env.CHOCOLATEY_API_KEY }}" 46 | source_repo: "https://push.chocolatey.org/" 47 | checksum: 48 | name_template: "windows_checksums.txt" 49 | snapshot: 50 | version_template: "{{ incpatch .Version }}-next" 51 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "2" 3 | linters: 4 | enable: 5 | - "bidichk" 6 | - "bodyclose" 7 | - "depguard" 8 | - "errcheck" 9 | - "errname" 10 | - "errorlint" 11 | - "gocritic" 12 | - "goprintffuncname" 13 | - "gosec" 14 | - "govet" 15 | - "importas" 16 | - "ineffassign" 17 | - "makezero" 18 | - "perfsprint" 19 | - "prealloc" 20 | - "predeclared" 21 | - "promlinter" 22 | - "revive" 23 | - "rowserrcheck" 24 | - "spancheck" 25 | - "staticcheck" 26 | - "tagalign" 27 | - "testifylint" 28 | - "tparallel" 29 | - "unconvert" 30 | - "usetesting" 31 | - "wastedassign" 32 | - "whitespace" 33 | - "unused" 34 | exclusions: 35 | generated: "lax" 36 | presets: 37 | - "comments" 38 | - "common-false-positives" 39 | - "legacy" 40 | - "std-error-handling" 41 | paths: 42 | - "third_party$" 43 | - "builtin$" 44 | - "examples$" 45 | settings: 46 | depguard: 47 | rules: 48 | main: 49 | deny: 50 | - pkg: "k8s.io/utils/strings/slices$" 51 | desc: "use github.com/samber/lo" 52 | staticcheck: 53 | checks: 54 | - "all" 55 | formatters: 56 | enable: 57 | - "gci" 58 | - "gofmt" 59 | - "gofumpt" 60 | - "goimports" 61 | settings: 62 | gofmt: 63 | rewrite-rules: 64 | - pattern: "interface{}" 65 | replacement: "any" 66 | - pattern: "a[b:len(a)]" 67 | replacement: "a[b:]" 68 | gci: 69 | sections: 70 | - "standard" 71 | - "default" 72 | - "prefix(github.com/authzed)" 73 | - "localmodule" 74 | goimports: 75 | local-prefixes: 76 | - "github.com/authzed/zed" 77 | exclusions: 78 | generated: "lax" 79 | paths: 80 | - "third_party$" 81 | - "builtin$" 82 | - "examples$" 83 | -------------------------------------------------------------------------------- /internal/console/console.go: -------------------------------------------------------------------------------- 1 | package console 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/mattn/go-isatty" 9 | "github.com/schollz/progressbar/v3" 10 | ) 11 | 12 | // Printf defines an (overridable) function for printing to the console via stdout. 13 | var Printf = func(format string, a ...any) { 14 | fmt.Printf(format, a...) 15 | } 16 | 17 | var Print = func(a ...any) { 18 | fmt.Print(a...) 19 | } 20 | 21 | // Errorf defines an (overridable) function for printing to the console via stderr. 22 | var Errorf = func(format string, a ...any) { 23 | _, err := fmt.Fprintf(os.Stderr, format, a...) 24 | if err != nil { 25 | panic(err) 26 | } 27 | } 28 | 29 | // Println prints a line with optional values to the console. 30 | var Println = func(values ...any) { 31 | for _, value := range values { 32 | Printf("%v\n", value) 33 | } 34 | } 35 | 36 | // CreateProgressBar creates a new progress bar with the given description and defaults adjusted to zed's UX experience 37 | func CreateProgressBar(description string) *progressbar.ProgressBar { 38 | bar := progressbar.NewOptions(-1, 39 | progressbar.OptionSetWidth(10), 40 | progressbar.OptionSetRenderBlankState(true), 41 | progressbar.OptionSetVisibility(false), 42 | ) 43 | if isatty.IsTerminal(os.Stderr.Fd()) { 44 | bar = progressbar.NewOptions64(-1, 45 | progressbar.OptionSetDescription(description), 46 | progressbar.OptionSetWriter(os.Stderr), 47 | progressbar.OptionSetWidth(10), 48 | progressbar.OptionThrottle(65*time.Millisecond), 49 | progressbar.OptionShowCount(), 50 | progressbar.OptionShowIts(), 51 | progressbar.OptionSetItsString("relationship"), 52 | progressbar.OptionOnCompletion(func() { _, _ = fmt.Fprint(os.Stderr, "\n") }), 53 | progressbar.OptionSpinnerType(14), 54 | progressbar.OptionFullWidth(), 55 | progressbar.OptionSetRenderBlankState(true), 56 | ) 57 | } 58 | 59 | return bar 60 | } 61 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | - The use of sexualized language or imagery 10 | - Personal attacks 11 | - Trolling or insulting/derogatory comments 12 | - Public or private harassment 13 | - Publishing other’s private information, such as physical or electronic addresses, without explicit permission 14 | - Other unethical or unprofessional conduct 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. 17 | By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. 18 | Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 19 | 20 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 21 | 22 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 23 | 24 | This Code of Conduct is adapted from the Contributor Covenant, version 1.2.0, available [here](https://www.contributor-covenant.org/version/1/2/0/code-of-conduct.html) 25 | -------------------------------------------------------------------------------- /internal/printers/tree.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jzelinskie/stringz" 7 | 8 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 9 | ) 10 | 11 | func prettySubject(subj *v1.SubjectReference) string { 12 | if subj.OptionalRelation == "" { 13 | return fmt.Sprintf( 14 | "%s:%s", 15 | stringz.TrimPrefixIndex(subj.Object.ObjectType, "/"), 16 | subj.Object.ObjectId, 17 | ) 18 | } 19 | return fmt.Sprintf( 20 | "%s:%s->%s", 21 | stringz.TrimPrefixIndex(subj.Object.ObjectType, "/"), 22 | subj.Object.ObjectId, 23 | subj.OptionalRelation, 24 | ) 25 | } 26 | 27 | // TreeNodeTree walks an Authzed Tree Node and creates corresponding nodes 28 | // for a treeprinter. 29 | func TreeNodeTree(tp *TreePrinter, treeNode *v1.PermissionRelationshipTree) { 30 | if treeNode.ExpandedObject != nil { 31 | tp = tp.Child(fmt.Sprintf( 32 | "%s:%s->%s", 33 | stringz.TrimPrefixIndex(treeNode.ExpandedObject.ObjectType, "/"), 34 | treeNode.ExpandedObject.ObjectId, 35 | treeNode.ExpandedRelation, 36 | )) 37 | } 38 | switch typed := treeNode.TreeType.(type) { 39 | case *v1.PermissionRelationshipTree_Intermediate: 40 | switch typed.Intermediate.Operation { 41 | case v1.AlgebraicSubjectSet_OPERATION_UNION: 42 | union := tp.Child("union") 43 | for _, child := range typed.Intermediate.Children { 44 | TreeNodeTree(union, child) 45 | } 46 | case v1.AlgebraicSubjectSet_OPERATION_INTERSECTION: 47 | intersection := tp.Child("intersection") 48 | for _, child := range typed.Intermediate.Children { 49 | TreeNodeTree(intersection, child) 50 | } 51 | case v1.AlgebraicSubjectSet_OPERATION_EXCLUSION: 52 | exclusion := tp.Child("exclusion") 53 | for _, child := range typed.Intermediate.Children { 54 | TreeNodeTree(exclusion, child) 55 | } 56 | default: 57 | panic("unknown expand operation") 58 | } 59 | case *v1.PermissionRelationshipTree_Leaf: 60 | for _, subject := range typed.Leaf.Subjects { 61 | tp.Child(prettySubject(subject)) 62 | } 63 | default: 64 | panic("unknown TreeNode type") 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/release-windows.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Release for Windows" # TODO why is this separate from release.yaml? 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" 7 | permissions: 8 | contents: "write" 9 | packages: "write" 10 | jobs: 11 | release-windows: 12 | runs-on: "windows-latest" 13 | steps: 14 | - uses: "actions/checkout@v5" 15 | with: 16 | fetch-depth: 0 17 | - uses: "authzed/actions/setup-go@main" 18 | - uses: "nowsprinting/check-version-format-action@v4" 19 | id: "version" 20 | with: 21 | prefix: "v" 22 | - uses: "authzed/actions/docker-login@main" 23 | with: 24 | quayio_token: "${{ secrets.QUAYIO_PASSWORD }}" 25 | github_token: "${{ secrets.GITHUB_TOKEN }}" 26 | dockerhub_token: "${{ secrets.DOCKERHUB_ACCESS_TOKEN }}" 27 | - uses: "goreleaser/goreleaser-action@v6" 28 | with: 29 | distribution: "goreleaser-pro" 30 | # NOTE: keep in sync with goreleaser version in other job. 31 | # github actions don't allow yaml anchors. 32 | version: "v2.12.5" 33 | args: "release --clean --config=.goreleaser.windows.yml" 34 | env: 35 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 36 | GORELEASER_KEY: "${{ secrets.GORELEASER_KEY }}" 37 | CHOCOLATEY_API_KEY: "${{ secrets.CHOCOLATEY_API_KEY }}" 38 | - name: "Notify in Slack if failure" 39 | if: "${{ failure() }}" 40 | uses: "slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a" # v2.1.1 41 | with: 42 | webhook: "${{ secrets.SLACK_BUILDS_WEBHOOK_URL }}" 43 | webhook-type: "incoming-webhook" 44 | payload: | 45 | text: "Release failure." 46 | blocks: 47 | - type: "section" 48 | text: 49 | type: "mrkdwn" 50 | text: | 51 | :x: @eng-oss Release failure. Please take a look. 52 | *Repository:* <${{ github.server_url }}/${{ github.repository }}|${{ github.repository }}> 53 | -------------------------------------------------------------------------------- /internal/grpcutil/batch.go: -------------------------------------------------------------------------------- 1 | package grpcutil 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "runtime" 7 | 8 | "golang.org/x/sync/errgroup" 9 | "golang.org/x/sync/semaphore" 10 | ) 11 | 12 | func minimum(a int, b int) int { 13 | if a <= b { 14 | return a 15 | } 16 | return b 17 | } 18 | 19 | // EachFunc is a callback function that is called for each batch. no is the 20 | // batch number, start is the starting index of this batch in the slice, and 21 | // end is the ending index of this batch in the slice. 22 | type EachFunc func(ctx context.Context, no int, start int, end int) error 23 | 24 | // ConcurrentBatch will calculate the minimum number of batches to required to batch n items 25 | // with batchSize batches. For each batch, it will execute the each function. 26 | // These functions will be processed in parallel using maxWorkers number of 27 | // goroutines. If maxWorkers is 1, then batching will happen synchronously. If 28 | // maxWorkers is 0, then GOMAXPROCS number of workers will be used. 29 | // 30 | // If an error occurs during a batch, all the worker's contexts are cancelled 31 | // and the original error is returned. 32 | func ConcurrentBatch(ctx context.Context, n int, batchSize int, maxWorkers int, each EachFunc) error { 33 | if n < 0 { 34 | return errors.New("cannot batch items of length < 0") 35 | } else if n == 0 { 36 | // Batching zero items is a noop. 37 | return nil 38 | } 39 | 40 | if batchSize < 1 { 41 | return errors.New("cannot batch items with batch size < 1") 42 | } 43 | 44 | if maxWorkers < 0 { 45 | return errors.New("cannot batch items with workers < 0") 46 | } else if maxWorkers == 0 { 47 | maxWorkers = runtime.GOMAXPROCS(0) 48 | } 49 | 50 | sem := semaphore.NewWeighted(int64(maxWorkers)) 51 | g, ctx := errgroup.WithContext(ctx) 52 | numBatches := (n + batchSize - 1) / batchSize 53 | for i := 0; i < numBatches; i++ { 54 | if err := sem.Acquire(ctx, 1); err != nil { 55 | break 56 | } 57 | 58 | batchNum := i 59 | g.Go(func() error { 60 | defer sem.Release(1) 61 | start := batchNum * batchSize 62 | end := minimum(start+batchSize, n) 63 | return each(ctx, batchNum, start, end) 64 | }) 65 | } 66 | return g.Wait() 67 | } 68 | -------------------------------------------------------------------------------- /internal/cmd/import_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | 10 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 11 | 12 | "github.com/authzed/zed/internal/client" 13 | zedtesting "github.com/authzed/zed/internal/testing" 14 | ) 15 | 16 | var fullyConsistent = &v1.Consistency{Requirement: &v1.Consistency_FullyConsistent{FullyConsistent: true}} 17 | 18 | func TestImportCmdHappyPath(t *testing.T) { 19 | require := require.New(t) 20 | cmd := zedtesting.CreateTestCobraCommandWithFlagValue(t, 21 | zedtesting.StringFlag{FlagName: "schema-definition-prefix"}, 22 | zedtesting.BoolFlag{FlagName: "schema", FlagValue: true}, 23 | zedtesting.BoolFlag{FlagName: "relationships", FlagValue: true}, 24 | zedtesting.IntFlag{FlagName: "batch-size", FlagValue: 100}, 25 | zedtesting.IntFlag{FlagName: "workers", FlagValue: 1}, 26 | ) 27 | f := filepath.Join("import-test", "happy-path-validation-file.yaml") 28 | 29 | // Set up client 30 | ctx := t.Context() 31 | srv := zedtesting.NewTestServer(ctx, t) 32 | go func() { 33 | assert.NoError(t, srv.Run(ctx)) 34 | }() 35 | conn, err := srv.GRPCDialContext(ctx) 36 | require.NoError(err) 37 | 38 | originalClient := client.NewClient 39 | defer func() { 40 | client.NewClient = originalClient 41 | }() 42 | 43 | client.NewClient = zedtesting.ClientFromConn(conn) 44 | 45 | c, err := zedtesting.ClientFromConn(conn)(cmd) 46 | require.NoError(err) 47 | 48 | // Run the import and assert we don't have errors 49 | err = importCmdFunc(cmd, []string{f}) 50 | require.NoError(err) 51 | 52 | // Run a check with full consistency to see whether the relationships 53 | // and schema are written 54 | resp, err := c.CheckPermission(ctx, &v1.CheckPermissionRequest{ 55 | Consistency: fullyConsistent, 56 | Subject: &v1.SubjectReference{Object: &v1.ObjectReference{ObjectType: "user", ObjectId: "1"}}, 57 | Permission: "view", 58 | Resource: &v1.ObjectReference{ObjectType: "resource", ObjectId: "1"}, 59 | }) 60 | require.NoError(err) 61 | require.Equal(v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION, resp.Permissionship) 62 | } 63 | -------------------------------------------------------------------------------- /internal/commands/schema.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jzelinskie/cobrautil/v2" 7 | "github.com/jzelinskie/stringz" 8 | "github.com/rs/zerolog/log" 9 | "github.com/spf13/cobra" 10 | "google.golang.org/grpc/codes" 11 | "google.golang.org/grpc/status" 12 | 13 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 14 | 15 | "github.com/authzed/zed/internal/client" 16 | "github.com/authzed/zed/internal/console" 17 | ) 18 | 19 | func RegisterSchemaCmd(rootCmd *cobra.Command) *cobra.Command { 20 | schemaCmd := &cobra.Command{ 21 | Use: "schema ", 22 | Short: "Manage schema for a permissions system", 23 | } 24 | 25 | schemaReadCmd := &cobra.Command{ 26 | Use: "read", 27 | Short: "Read the schema of a permissions system", 28 | Args: ValidationWrapper(cobra.ExactArgs(0)), 29 | ValidArgsFunction: cobra.NoFileCompletions, 30 | RunE: schemaReadCmdFunc, 31 | } 32 | 33 | rootCmd.AddCommand(schemaCmd) 34 | schemaCmd.AddCommand(schemaReadCmd) 35 | schemaReadCmd.Flags().Bool("json", false, "output as JSON") 36 | 37 | return schemaCmd 38 | } 39 | 40 | func schemaReadCmdFunc(cmd *cobra.Command, _ []string) error { 41 | client, err := client.NewClient(cmd) 42 | if err != nil { 43 | return err 44 | } 45 | request := &v1.ReadSchemaRequest{} 46 | log.Trace().Interface("request", request).Msg("requesting schema read") 47 | 48 | resp, err := client.ReadSchema(cmd.Context(), request) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if cobrautil.MustGetBool(cmd, "json") { 54 | prettyProto, err := PrettyProto(resp) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | console.Println(string(prettyProto)) 60 | return nil 61 | } 62 | 63 | console.Println(stringz.Join("\n\n", resp.SchemaText)) 64 | return nil 65 | } 66 | 67 | // ReadSchema calls read schema for the client and returns the schema found. 68 | func ReadSchema(ctx context.Context, client v1.SchemaServiceClient) (string, error) { 69 | request := &v1.ReadSchemaRequest{} 70 | log.Trace().Interface("request", request).Msg("requesting schema read") 71 | 72 | resp, err := client.ReadSchema(ctx, request) 73 | if err != nil { 74 | errStatus, ok := status.FromError(err) 75 | if !ok || errStatus.Code() != codes.NotFound { 76 | return "", err 77 | } 78 | 79 | log.Debug().Msg("no schema defined") 80 | return "", nil 81 | } 82 | 83 | return resp.SchemaText, nil 84 | } 85 | -------------------------------------------------------------------------------- /pkg/wasm/example/wasm.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 26 | 61 | 62 | 63 | 64 | 65 | 66 |
67 | 68 | 69 | -------------------------------------------------------------------------------- /pkg/wasm/main_test.go: -------------------------------------------------------------------------------- 1 | //go:build wasm 2 | // +build wasm 3 | 4 | // To Run: 5 | // 1) Install wasmbrowsertest: `go install github.com/agnivade/wasmbrowsertest@latest` 6 | // 2) Run: `GOOS=js GOARCH=wasm go test -exec wasmbrowsertest` 7 | 8 | package main 9 | 10 | import ( 11 | "strings" 12 | "testing" 13 | 14 | "github.com/stretchr/testify/require" 15 | "google.golang.org/protobuf/encoding/protojson" 16 | 17 | v1 "github.com/authzed/spicedb/pkg/proto/core/v1" 18 | devinterface "github.com/authzed/spicedb/pkg/proto/developer/v1" 19 | "github.com/authzed/spicedb/pkg/tuple" 20 | ) 21 | 22 | func TestZedCommand(t *testing.T) { 23 | requestCtx := &devinterface.RequestContext{ 24 | Schema: `definition user {} 25 | 26 | caveat somecaveat(somecondition int) { 27 | somecondition == 42 28 | } 29 | 30 | definition document { 31 | relation viewer: user | user with somecaveat 32 | permission view = viewer 33 | }`, 34 | 35 | Relationships: []*v1.RelationTuple{ 36 | tuple.MustParse(`document:first#viewer@user:fred[somecaveat:{"somecondition": 42}]`).ToCoreTuple(), 37 | tuple.MustParse("document:first#viewer@user:tom").ToCoreTuple(), 38 | }, 39 | } 40 | 41 | encodedContext, err := protojson.Marshal(requestCtx) 42 | require.NoError(t, err) 43 | 44 | rootCmd := buildRootCmd() 45 | 46 | // Run with --help 47 | result := runZedCommand(rootCmd, string(encodedContext), []string{"permission", "check", "--help"}) 48 | require.Contains(t, result.Output, "Usage:", "failed to run 'permission check --help' command: %v", result) 49 | 50 | // Run the actual command. 51 | result = runZedCommand(rootCmd, string(encodedContext), []string{"permission", "check", "document:first", "view", "user:fred"}) 52 | require.True(t, strings.HasSuffix(strings.TrimSpace(result.Output), "true"), "expected true at end of: %s", result.Output) 53 | 54 | updatedContext := &devinterface.RequestContext{} 55 | err = protojson.Unmarshal([]byte(result.UpdatedContext), updatedContext) 56 | require.NoError(t, err) 57 | 58 | require.Contains(t, updatedContext.Schema, "definition document") 59 | require.Equal(t, `document:first#viewer@user:fred[somecaveat:{"somecondition":42}]`, tuple.MustCoreRelationToString(updatedContext.Relationships[0])) 60 | require.Equal(t, "document:first#viewer@user:tom", tuple.MustCoreRelationToString(updatedContext.Relationships[1])) 61 | require.Len(t, updatedContext.Relationships, 2) 62 | 63 | // Run the actual command. 64 | result = runZedCommand(rootCmd, string(encodedContext), []string{"relationship", "create", "document:1", "viewer", "user:1"}) 65 | require.Empty(t, result.Error, "failed to run relationship create: %s", result.Error) 66 | 67 | updatedContext = &devinterface.RequestContext{} 68 | err = protojson.Unmarshal([]byte(result.UpdatedContext), updatedContext) 69 | require.NoError(t, err) 70 | require.Len(t, updatedContext.Relationships, 3) 71 | } 72 | -------------------------------------------------------------------------------- /internal/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/gookit/color" 8 | "github.com/jzelinskie/cobrautil/v2" 9 | "github.com/mattn/go-isatty" 10 | "github.com/spf13/cobra" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/metadata" 13 | 14 | "github.com/authzed/authzed-go/pkg/responsemeta" 15 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 16 | 17 | "github.com/authzed/zed/internal/client" 18 | "github.com/authzed/zed/internal/console" 19 | ) 20 | 21 | func getClientVersion(cmd *cobra.Command) string { 22 | includeDeps := cobrautil.MustGetBool(cmd, "include-deps") 23 | return cobrautil.UsageVersion("zed", includeDeps) 24 | } 25 | 26 | func getServerVersion(cmd *cobra.Command, spiceClient v1.SchemaServiceClient) (string, error) { 27 | var headerMD, trailerMD metadata.MD 28 | // NOTE: we ignore the error here, as it may be due to a schema not existing, or 29 | // the client being unable to connect, etc. We just treat all such cases as an unknown 30 | // version. 31 | // NOTE: the client already has the request header set by a middleware. 32 | _, _ = spiceClient.ReadSchema(cmd.Context(), &v1.ReadSchemaRequest{}, grpc.Header(&headerMD), grpc.Trailer(&trailerMD)) 33 | versionFromHeader := headerMD.Get(string(responsemeta.ServerVersion)) 34 | versionFromTrailer := trailerMD.Get(string(responsemeta.ServerVersion)) 35 | if len(versionFromHeader) == 1 && len(versionFromTrailer) == 1 && versionFromHeader[0] != versionFromTrailer[0] { 36 | return "", fmt.Errorf("mismatched server versions in header (%s) and trailer (%s)", versionFromHeader[0], versionFromTrailer[0]) 37 | } 38 | 39 | if len(versionFromHeader) == 1 && versionFromHeader[0] != "" { 40 | return versionFromHeader[0], nil 41 | } 42 | if len(versionFromTrailer) == 1 && versionFromTrailer[0] != "" { 43 | return versionFromTrailer[0], nil 44 | } 45 | return "(unknown)", nil 46 | } 47 | 48 | func versionCmdFunc(cmd *cobra.Command, _ []string) error { 49 | if !isatty.IsTerminal(os.Stdout.Fd()) { 50 | color.Disable() 51 | } 52 | 53 | includeRemoteVersion := cobrautil.MustGetBool(cmd, "include-remote-version") 54 | if includeRemoteVersion { 55 | green := color.FgGreen.Render 56 | fmt.Print(green("client: ")) 57 | } 58 | 59 | console.Println(getClientVersion(cmd)) 60 | 61 | if includeRemoteVersion { 62 | configStore, secretStore := client.DefaultStorage() 63 | _, err := client.GetCurrentTokenWithCLIOverride(cmd, configStore, secretStore) 64 | 65 | if err == nil { 66 | spiceClient, err := client.NewClient(cmd) 67 | if err != nil { 68 | return err 69 | } 70 | serverVersion, err := getServerVersion(cmd, spiceClient) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | blue := color.FgLightBlue.Render 76 | fmt.Print(blue("service: ")) 77 | console.Println(serverVersion) 78 | } 79 | } 80 | 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/basic-rbac/README.md: -------------------------------------------------------------------------------- 1 | # Simple Role Based Access Control 2 | 3 | Models Role Based Access Control (RBAC), where access is granted to users based on the role(s) in which they are a member. 4 | 5 | --- 6 | 7 | ## Schema 8 | 9 | ```zed 10 | /** 11 | * user represents a user that can be granted role(s) 12 | */ 13 | definition user {} 14 | 15 | /** 16 | * document represents a document protected by Authzed. 17 | */ 18 | definition document { 19 | /** 20 | * writer indicates that the user is a writer on the document. 21 | */ 22 | relation writer: user 23 | 24 | /** 25 | * reader indicates that the user is a reader on the document. 26 | */ 27 | relation reader: user 28 | 29 | /** 30 | * edit indicates that the user has permission to edit the document. 31 | */ 32 | permission edit = writer 33 | 34 | /** 35 | * view indicates that the user has permission to view the document, if they 36 | * are a `reader` *or* have `edit` permission. 37 | */ 38 | permission view = reader + edit 39 | } 40 | ``` 41 | 42 | The RBAC example defines two kinds of objects: `user` to be used as references to users and `document`, representing a resource being protected (in this case, a document). 43 | 44 | ### user 45 | 46 | `user` is an example of a "user" definition, which is used to represent users. The definition itself is empty, as it is only used for referencing purposes. 47 | 48 | ```zed 49 | definition user {} 50 | ``` 51 | 52 | ### document 53 | 54 | `document` is an example of a "resource" definition, which is used to define the relations and permissions for a specific kind of resource. Here, that resource is a document. 55 | 56 | ```zed 57 | definition document { 58 | /** 59 | * writer indicates that the user is a writer on the document. 60 | */ 61 | relation writer: user 62 | 63 | /** 64 | * reader indicates that the user is a reader on the document. 65 | */ 66 | relation reader: user 67 | 68 | /** 69 | * edit indicates that the user has permission to edit the document. 70 | */ 71 | permission edit = writer 72 | 73 | /** 74 | * view indicates that the user has permission to view the document, if they 75 | * are a `reader` *or* have `edit` permission. 76 | */ 77 | permission view = reader + edit 78 | } 79 | ``` 80 | 81 | Within the `document` definition, there are defined two relations `reader` and `writer`, which are used to represent roles for users, and two permissions `edit` and `view`, which represent the permissions that can be checked on a document. 82 | 83 | #### writer 84 | 85 | The `writer` relation defines a role of "writer" for users. 86 | 87 | #### reader 88 | 89 | The `reader` relation defines a role of "reader" for users. 90 | 91 | #### edit 92 | 93 | The `edit` permission defines an edit permission on the document. 94 | 95 | #### view 96 | 97 | The `view` permission defines a view permission on the document. 98 | 99 | Note that `view` includes the `edit` permission. This means that if a user is granted the role of `writer` and thus, has `edit` permission, they will _also_ be implicitly granted the permission of `view`. 100 | -------------------------------------------------------------------------------- /pkg/backupformat/schema.go: -------------------------------------------------------------------------------- 1 | package backupformat 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | 8 | "github.com/hamba/avro/v2" 9 | ) 10 | 11 | func init() { 12 | avro.DefaultConfig.Register(spiceDBBackupNamespace+"."+schemaV1SchemaName, SchemaV1{}) 13 | avro.DefaultConfig.Register(spiceDBBackupNamespace+"."+relationshipV1SchemaName, RelationshipV1{}) 14 | } 15 | 16 | type RelationshipV1 struct { 17 | ObjectType string `avro:"object_type"` 18 | ObjectID string `avro:"object_id"` 19 | Relation string `avro:"relation"` 20 | SubjectObjectType string `avro:"subject_object_type"` 21 | SubjectObjectID string `avro:"subject_object_id"` 22 | SubjectRelation string `avro:"subject_relation"` 23 | CaveatName string `avro:"caveat_name"` 24 | CaveatContext []byte `avro:"caveat_context"` 25 | } 26 | 27 | type SchemaV1 struct { 28 | SchemaText string `avro:"schema_text"` 29 | } 30 | 31 | const ( 32 | spiceDBBackupNamespace = "com.authzed.spicedb.backup" 33 | 34 | relationshipV1SchemaName = "relationship_v1" 35 | schemaV1SchemaName = "schema_v1" 36 | 37 | metadataKeyZT = "com.authzed.spicedb.zedtoken.v1" 38 | ) 39 | 40 | func avroSchemaV1() (string, error) { 41 | relationshipSchema, err := recordSchemaFromAvroStruct( 42 | relationshipV1SchemaName, 43 | spiceDBBackupNamespace, 44 | RelationshipV1{}, 45 | ) 46 | if err != nil { 47 | return "", fmt.Errorf("unable to create schema: %w", err) 48 | } 49 | 50 | schemaSchema, err := recordSchemaFromAvroStruct( 51 | schemaV1SchemaName, 52 | spiceDBBackupNamespace, 53 | SchemaV1{}, 54 | ) 55 | if err != nil { 56 | return "", fmt.Errorf("unable to create avro SpiceDB schema schema: %w", err) 57 | } 58 | 59 | unionSchema, err := avro.NewUnionSchema([]avro.Schema{relationshipSchema, schemaSchema}) 60 | if err != nil { 61 | return "", fmt.Errorf("unable to create avro union schema: %w", err) 62 | } 63 | 64 | serialized, err := unionSchema.MarshalJSON() 65 | return string(serialized), err 66 | } 67 | 68 | func recordSchemaFromAvroStruct(name, namespace string, avroStruct any) (*avro.RecordSchema, error) { 69 | v := reflect.TypeOf(avroStruct) 70 | schemaFields := make([]*avro.Field, 0, v.NumField()) 71 | for i := 0; i < v.NumField(); i++ { 72 | f := v.Field(i) 73 | fieldName := f.Tag.Get("avro") 74 | if fieldName == "" { 75 | return nil, fmt.Errorf("field `%s` missing avro struct tag", f.Name) 76 | } 77 | fieldGoType := f.Type 78 | 79 | var fieldType avro.Type 80 | switch fieldGoType.Kind() { 81 | case reflect.String: 82 | fieldType = avro.String 83 | case reflect.Slice: 84 | if fieldGoType.Elem().Kind() != reflect.Uint8 { 85 | return nil, errors.New("unable to build schema for slice, only byte slices are supported") 86 | } 87 | fieldType = avro.Bytes 88 | default: 89 | return nil, fmt.Errorf("unsupported struct kind: %s", fieldGoType) 90 | } 91 | 92 | schemaField, err := avro.NewField(fieldName, avro.NewPrimitiveSchema(fieldType, nil)) 93 | if err != nil { 94 | return nil, fmt.Errorf("unable to create avro schema field: %w", err) 95 | } 96 | 97 | schemaFields = append(schemaFields, schemaField) 98 | } 99 | 100 | return avro.NewRecordSchema(name, namespace, schemaFields) 101 | } 102 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Build & Test" 3 | on: # yamllint disable-line rule:truthy 4 | pull_request: 5 | branches: 6 | - "*" 7 | merge_group: 8 | types: 9 | - "checks_requested" 10 | jobs: 11 | build: 12 | name: "Build Binary" 13 | runs-on: "ubuntu-latest" 14 | steps: 15 | - uses: "actions/checkout@v5" 16 | - uses: "authzed/actions/setup-go@main" 17 | - uses: "authzed/actions/go-build@main" 18 | 19 | image-build: 20 | name: "Build Container Image" 21 | runs-on: "ubuntu-latest" 22 | steps: 23 | - uses: "actions/checkout@v5" 24 | - uses: "authzed/actions/docker-build@main" 25 | 26 | unit: 27 | name: "Run Unit Tests" 28 | runs-on: "${{ matrix.os }}" 29 | strategy: 30 | matrix: 31 | os: ["ubuntu-latest"] # TODO(miparnisari): add "windows-latest" after fixing the tests 32 | 33 | steps: 34 | - uses: "actions/checkout@v5" 35 | - uses: "authzed/actions/setup-go@main" 36 | - name: "Unit tests with coverage" 37 | run: "go run mage.go test:runWithCoverage" 38 | - name: "Upload coverage to Codecov" 39 | uses: "codecov/codecov-action@v5.5.1" 40 | with: 41 | files: "./coverage.txt" 42 | verbose: true 43 | token: "${{ secrets.CODECOV_TOKEN }}" 44 | fail_ci_if_error: false 45 | 46 | development: 47 | name: "WASM Tests" 48 | runs-on: "depot-ubuntu-24.04-4" 49 | steps: 50 | - uses: "actions/checkout@v5" 51 | - uses: "authzed/actions/setup-go@main" 52 | with: 53 | # NOTE: This needs to match the toolchain version, or else 54 | # go env gopath won't point at the right install location for the 55 | # wasm tool. 56 | go-version: "1.23.2" 57 | - name: "Disable AppArmor" 58 | if: 59 | "runner.os == 'Linux'" 60 | # Disable AppArmor for Ubuntu 23.10+. 61 | # https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md 62 | run: "echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns" 63 | - name: "Install wasmbrowsertest" 64 | run: "go install github.com/agnivade/wasmbrowsertest@latest" 65 | # cleanenv is a util provided by the wasmbrowsertest package that removes 66 | # environment variables from the environment handed to wasmbrowsertest. 67 | # this works around https://github.com/agnivade/wasmbrowsertest/issues/40, 68 | # which we were experiencing on depot. 69 | - name: "Install cleanenv" 70 | run: "go install github.com/agnivade/wasmbrowsertest/cmd/cleanenv@latest" 71 | - name: "Run WASM Tests" 72 | # There's a whole bunch of vars in the environment that aren't needed for running this test, so we clear them out. 73 | # NOTE: if you need to do this in the future, I recommend bashing into the container and running `env | sort | less` 74 | run: |- 75 | GOOS=js \ 76 | GOARCH=wasm \ 77 | cleanenv \ 78 | -remove-prefix GITHUB_ \ 79 | -remove-prefix ANDROID_ \ 80 | -remove-prefix JAVA_ \ 81 | -remove-prefix DOTNET_ \ 82 | -remove-prefix RUNNER_ \ 83 | -remove-prefix HOMEBREW_ \ 84 | -remove-prefix runner_ \ 85 | -- \ 86 | go test ./pkg/wasm/... -exec $(go env GOPATH)/bin/wasmbrowsertest 87 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Sync Generated Docs" 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | branches: 6 | - "main" 7 | workflow_dispatch: 8 | env: 9 | DOCS_REPO: "authzed/docs" 10 | TARGET_DOCS_FILE: "app/spicedb/getting-started/installing-zed/page.mdx" 11 | CHANGES_DETECTED: false 12 | 13 | permissions: 14 | contents: "write" 15 | pull-requests: "write" 16 | actions: "write" 17 | repository-projects: "write" 18 | 19 | jobs: 20 | sync-docs: 21 | name: "Generate & Sync Documentation" 22 | runs-on: "ubuntu-latest" 23 | steps: 24 | - uses: "actions/checkout@v5" 25 | - uses: "authzed/actions/setup-go@main" 26 | - uses: "authzed/actions/setup-mage@main" 27 | 28 | - name: "Generate Documentation" 29 | run: "mage gen:docsForPublish" 30 | 31 | - name: "Checkout docs repository" 32 | uses: "actions/checkout@v5" 33 | with: 34 | token: "${{ secrets.GITHUB_TOKEN }}" 35 | repository: "${{ env.DOCS_REPO }}" 36 | path: "docs-repo" 37 | ref: "main" 38 | 39 | - name: "Sync documentation changes" 40 | id: "check-changes" 41 | run: | 42 | cp -v docs/merged.md docs-repo/$TARGET_DOCS_FILE 43 | if [[ -n "$(git status --porcelain ./docs-repo)" ]]; then 44 | echo "docs_changed=true" >> $GITHUB_OUTPUT 45 | else 46 | echo "no changes were made" 47 | echo "docs_changed=false" >> $GITHUB_OUTPUT 48 | fi 49 | - name: "Create commit & pull request" 50 | if: "steps.check-changes.outputs.docs_changed == 'true'" 51 | id: "cpr" 52 | uses: "peter-evans/create-pull-request@v7" 53 | with: 54 | token: "${{ secrets.PAT_TO_PUSH_TO_DOCS }}" 55 | path: "docs-repo" 56 | title: "Auto-generated PR: Update zed docs" 57 | body: "This PR was auto-generated by GitHub Actions." 58 | branch: "docs-zed-update" 59 | branch-suffix: "random" 60 | - name: "Approve Pull Request in target" 61 | uses: "juliangruber/approve-pull-request-action@b71c44ff142895ba07fad34389f1938a4e8ee7b0" # v2.0.6 62 | if: "steps.check-changes.outputs.docs_changed == 'true'" 63 | with: 64 | repo: "authzed/docs" 65 | github-token: "${{ secrets.AUTHZEDAPPROVER_REPO_SCOPED_TOKEN }}" 66 | number: "${{ steps.cpr.outputs.pull-request-number }}" 67 | - name: "Enable Pull Request Automerge in target" 68 | if: "steps.check-changes.outputs.docs_changed == 'true'" 69 | run: "gh pr merge ${{ steps.cpr.outputs.pull-request-number }} --merge --auto -R ${{ env.DOCS_REPO }}" 70 | env: 71 | GH_TOKEN: "${{ secrets.AUTHZEDAPPROVER_REPO_SCOPED_TOKEN }}" 72 | - name: "Notify in Slack if failure" 73 | if: "${{ failure() }}" 74 | uses: "slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a" # v2.1.1 75 | with: 76 | webhook: "${{ secrets.SLACK_BUILDS_WEBHOOK_URL }}" 77 | webhook-type: "incoming-webhook" 78 | payload: | 79 | text: ":x: @eng-oss Could not sync docs from zed repo to the docs repo" 80 | blocks: 81 | - type: "section" 82 | text: 83 | type: "mrkdwn" 84 | text: | 85 | :x: @eng-oss Could not sync docs from zed repo to the docs repo. Please take a look. 86 | *Repository:* <${{ github.server_url }}/${{ github.repository }}|${{ github.repository }}> 87 | *Job Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}> 88 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Release for Docker and MacOS" 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | github: 10 | runs-on: "macos-latest" 11 | steps: 12 | - uses: "actions/checkout@v5" 13 | with: 14 | fetch-depth: 0 15 | - name: "Install cross-compilers" 16 | run: | 17 | brew tap messense/macos-cross-toolchains 18 | brew install x86_64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-gnu aarch64-unknown-linux-musl mingw-w64 19 | echo "SDKROOT=$(xcrun --sdk macosx --show-sdk-path)" >> $GITHUB_ENV 20 | - uses: "authzed/actions/setup-go@main" 21 | - uses: "goreleaser/goreleaser-action@v6" 22 | with: 23 | distribution: "goreleaser-pro" 24 | # NOTE: keep in sync with goreleaser version in other job. 25 | # github actions don't allow yaml anchors. 26 | version: "v2.12.5" 27 | args: "release --clean" 28 | env: 29 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 30 | HOMEBREW_TAP_GITHUB_TOKEN: "${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}" 31 | GORELEASER_KEY: "${{ secrets.GORELEASER_KEY }}" 32 | GEMFURY_PUSH_TOKEN: "${{ secrets.GEMFURY_PUSH_TOKEN }}" 33 | - name: "Notify in Slack if failure" 34 | if: "${{ failure() }}" 35 | uses: "slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a" # v2.1.1 36 | with: 37 | webhook: "${{ secrets.SLACK_BUILDS_WEBHOOK_URL }}" 38 | webhook-type: "incoming-webhook" 39 | payload: | 40 | text: "Release failure." 41 | blocks: 42 | - type: "section" 43 | text: 44 | type: "mrkdwn" 45 | text: | 46 | :x: @eng-oss Release failure. Please take a look. 47 | *Repository:* <${{ github.server_url }}/${{ github.repository }}|${{ github.repository }}> 48 | 49 | docker: 50 | runs-on: "ubuntu-latest" 51 | steps: 52 | - uses: "actions/checkout@v5" 53 | with: 54 | fetch-depth: 0 55 | - uses: "authzed/actions/docker-login@main" 56 | with: 57 | quayio_token: "${{ secrets.QUAYIO_PASSWORD }}" 58 | github_token: "${{ secrets.GITHUB_TOKEN }}" 59 | dockerhub_token: "${{ secrets.DOCKERHUB_ACCESS_TOKEN }}" 60 | - name: "Install linux cross-compilers" 61 | run: "sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu gcc-mingw-w64-x86-64" 62 | - uses: "authzed/actions/setup-go@main" 63 | - uses: "goreleaser/goreleaser-action@v6" 64 | with: 65 | distribution: "goreleaser-pro" 66 | # NOTE: keep in sync with goreleaser version in other job. 67 | # github actions don't allow yaml anchors. 68 | version: "v2.12.5" 69 | args: "release --config=.goreleaser.docker.yml --clean" 70 | env: 71 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 72 | GORELEASER_KEY: "${{ secrets.GORELEASER_KEY }}" 73 | - name: "Notify in Slack if failure" 74 | if: "${{ failure() }}" 75 | uses: "slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a" # v2.1.1 76 | with: 77 | webhook: "${{ secrets.SLACK_BUILDS_WEBHOOK_URL }}" 78 | webhook-type: "incoming-webhook" 79 | payload: | 80 | text: "Release failure." 81 | blocks: 82 | - type: "section" 83 | text: 84 | type: "mrkdwn" 85 | text: | 86 | :x: @eng-oss Release failure. Please take a look. 87 | *Repository:* <${{ github.server_url }}/${{ github.repository }}|${{ github.repository }}> 88 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | import { Callout } from 'nextra/components' 2 | 3 | # Installing Zed 4 | 5 | [Zed](https://github.com/authzed/zed) is the CLI used to interact with SpiceDB. 6 | 7 | It is built as a standalone executable file which simplifies installation, but one should prefer one of the recommended installation methods detailed below. 8 | 9 | ## Debian packages 10 | 11 | [Debian-based Linux] users can install SpiceDB packages by adding an additional apt source. 12 | 13 | First, download the public signing key for the repository: 14 | 15 | ```sh 16 | # In releases older than Debian 12 and Ubuntu 22.04, the folder `/etc/apt/keyrings` does not exist by default, and it should be created before the curl command. 17 | # sudo mkdir -p -m 755 /etc/apt/keyrings 18 | 19 | curl -sS https://pkg.authzed.com/apt/gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/authzed.gpg 20 | ``` 21 | 22 | Then add the list file for the repository: 23 | 24 | ```sh 25 | echo "deb [signed-by=/etc/apt/keyrings/authzed.gpg] https://pkg.authzed.com/apt/ * *" | sudo tee /etc/apt/sources.list.d/authzed.list 26 | sudo chmod 644 /etc/apt/sources.list.d/authzed.list # helps tools such as command-not-found to work correctly 27 | ``` 28 | 29 | Alternatively, if you want to use the new `deb822`-style `authzed.sources` format, put the following in `/etc/apt/sources.list.d/authzed.sources`: 30 | 31 | ```sh 32 | Types: deb 33 | URIs: https://pkg.authzed.com/apt/ 34 | Suites: * 35 | Components: * 36 | Signed-By: /etc/apt/keyrings/authzed.gpg 37 | ``` 38 | 39 | Once you've defined the sources and updated your apt cache, it can be installed just like any other package: 40 | 41 | ```sh 42 | sudo apt update 43 | sudo apt install -y zed 44 | ``` 45 | 46 | [Debian-based Linux]: https://en.wikipedia.org/wiki/List_of_Linux_distributions#Debian-based 47 | 48 | ## RPM packages 49 | 50 | [RPM-based Linux] users can install packages by adding a new yum repository: 51 | 52 | ```sh 53 | sudo cat << EOF >> /etc/yum.repos.d/Authzed-Fury.repo 54 | [authzed-fury] 55 | name=AuthZed Fury Repository 56 | baseurl=https://pkg.authzed.com/yum/ 57 | enabled=1 58 | gpgcheck=0 59 | EOF 60 | ``` 61 | 62 | Install as usual: 63 | 64 | ```sh 65 | sudo dnf install -y zed 66 | ``` 67 | 68 | [RPM-based Linux]: https://en.wikipedia.org/wiki/List_of_Linux_distributions#RPM-based 69 | 70 | ## Homebrew (macOS) 71 | 72 | macOS users can install packages by adding a [Homebrew tap]: 73 | 74 | ```sh 75 | brew install authzed/tap/zed 76 | ``` 77 | 78 | [Homebrew tap]: https://docs.brew.sh/Taps 79 | 80 | ### Other methods 81 | 82 | #### Docker 83 | 84 | Container images are available for AMD64 and ARM64 architectures on the following registries: 85 | 86 | - [authzed/zed](https://hub.docker.com/r/authzed/zed) 87 | - [ghcr.io/authzed/zed](https://github.com/authzed/zed/pkgs/container/zed) 88 | - [quay.io/authzed/zed](https://quay.io/authzed/zed) 89 | 90 | You can pull down the latest stable release: 91 | 92 | ```sh 93 | docker pull authzed/zed 94 | ``` 95 | 96 | Afterwards, you can run it with `docker run`: 97 | 98 | ```sh 99 | docker run --rm authzed/zed version 100 | ``` 101 | 102 | #### Downloading the binary 103 | 104 | Visit the GitHub release page for the [latest release](https://github.com/authzed/zed/releases/latest). 105 | Scroll down to the `Assets` section and download the appropriate artifact. 106 | 107 | #### Source 108 | 109 | Clone the GitHub repository: 110 | 111 | ```sh 112 | git clone git@github.com:authzed/zed.git 113 | ``` 114 | 115 | Enter the directory and build the binary using mage: 116 | 117 | ```sh 118 | cd zed 119 | go build ./cmd/zed 120 | ``` 121 | 122 | You can find more commands for tasks such as testing, linting in the repository's [CONTRIBUTING.md]. 123 | 124 | [CONTRIBUTING.md]: https://github.com/authzed/zed/blob/main/CONTRIBUTING.md 125 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Communication 4 | 5 | - Issues: [GitHub](https://github.com/authzed/zed/issues) 6 | - Email: [Google Groups](https://groups.google.com/g/authzed-oss) 7 | - Discord: [Zanzibar Discord](https://discord.gg/jTysUaxXzM) 8 | 9 | All communication must follow our [Code of Conduct]. 10 | 11 | [Code of Conduct]: CODE-OF-CONDUCT.md 12 | 13 | ## Creating issues 14 | 15 | If any part of the project has a bug or documentation mistakes, please let us know by opening an issue. 16 | All bugs and mistakes are considered very seriously, regardless of complexity. 17 | 18 | Before creating an issue, please check that an issue reporting the same problem does not already exist. 19 | To make the issue accurate and easy to understand, please try to create issues that are: 20 | 21 | - Unique -- do not duplicate existing bug report. 22 | Deuplicate bug reports will be closed. 23 | - Specific -- include as much details as possible: which version, what environment, what configuration, etc. 24 | - Reproducible -- include the steps to reproduce the problem. 25 | Some issues might be hard to reproduce, so please do your best to include the steps that might lead to the problem. 26 | - Isolated -- try to isolate and reproduce the bug with minimum dependencies. 27 | It would significantly slow down the speed to fix a bug if too many dependencies are involved in a bug report. 28 | Debugging external systems that rely on this project is out of scope, but guidance or help using the project itself is fine. 29 | - Scoped -- one bug per report. 30 | Do not follow up with another bug inside one report. 31 | 32 | It may be worthwhile to read [Elika Etemad’s article on filing good bug reports][filing-good-bugs] before creating a bug report. 33 | 34 | Maintainers might ask for further information to resolve an issue. 35 | 36 | [filing-good-bugs]: http://fantasai.inkedblade.net/style/talks/filing-good-bugs/ 37 | 38 | ## Contribution flow 39 | 40 | This is a rough outline of what a contributor's workflow looks like: 41 | 42 | - Create an issue 43 | - Fork the project 44 | - Create a branch from where to base the contribution -- this is almost always `main` 45 | - Push changes into a branch of your fork 46 | - Submit a pull request 47 | - Respond to feedback from project maintainers 48 | 49 | Creating new issues is one of the best ways to contribute. 50 | You have no obligation to offer a solution or code to fix an issue that you open. 51 | If you do decide to try and contribute something, please submit an issue first so that a discussion can occur to avoid any wasted efforts. 52 | 53 | ## Legal requirements 54 | 55 | In order to protect both you and ourselves, all commits will require an explicit sign-off that acknowledges the [DCO]. 56 | 57 | Sign-off commits end with the following line: 58 | 59 | ```git 60 | Signed-off-by: Random J Developer 61 | ``` 62 | 63 | This can be done by using the `--signoff` (or `-s` for short) git flag to append this automatically to your commit message. 64 | If you have already authored a commit that is missing the signed-off, you can amend or rebase your commits and force push them to GitHub. 65 | 66 | [DCO]: /DCO 67 | 68 | ## Common tasks 69 | 70 | ### Testing & building a binary 71 | 72 | In order to build and test the project, the [latest stable version of Go] and knowledge of a [working Go environment] are required. 73 | 74 | [latest stable version of Go]: https://golang.org/dl 75 | [working Go environment]: https://golang.org/doc/code.html 76 | 77 | ```sh 78 | go test -v ./... 79 | go build ./cmd/zed 80 | ``` 81 | 82 | ### Adding dependencies 83 | 84 | This project does not use anything other than the standard [Go modules] toolchain for managing dependencies. 85 | 86 | [Go modules]: https://golang.org/ref/mod 87 | 88 | ```sh 89 | go get github.com/org/newdependency@version 90 | ``` 91 | 92 | Continuous integration enforces that `go mod tidy` has been run. 93 | -------------------------------------------------------------------------------- /internal/grpcutil/batch_test.go: -------------------------------------------------------------------------------- 1 | package grpcutil 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync/atomic" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type batch struct { 14 | no int 15 | start int 16 | end int 17 | } 18 | 19 | func generateItems(n int) []string { 20 | items := make([]string, n) 21 | for i := 0; i < n; i++ { 22 | items[i] = fmt.Sprintf("item %d", i) 23 | } 24 | return items 25 | } 26 | 27 | func TestConcurrentBatchOrdering(t *testing.T) { 28 | const batchSize = 3 29 | const workers = 1 // Set to one to keep everything synchronous. 30 | 31 | tests := []struct { 32 | name string 33 | items []string 34 | want []batch 35 | }{ 36 | { 37 | name: "1 item", 38 | items: generateItems(1), 39 | want: []batch{ 40 | {0, 0, 1}, 41 | }, 42 | }, 43 | { 44 | name: "3 items", 45 | items: generateItems(3), 46 | want: []batch{ 47 | {0, 0, 3}, 48 | }, 49 | }, 50 | { 51 | name: "5 items", 52 | items: generateItems(5), 53 | want: []batch{ 54 | {0, 0, 3}, 55 | {1, 3, 5}, 56 | }, 57 | }, 58 | } 59 | 60 | for _, tt := range tests { 61 | tt := tt 62 | t.Run(tt.name, func(t *testing.T) { 63 | require := require.New(t) 64 | 65 | gotCh := make(chan batch, len(tt.items)) 66 | fn := func(_ context.Context, no, start, end int) error { 67 | gotCh <- batch{no, start, end} 68 | return nil 69 | } 70 | 71 | err := ConcurrentBatch(t.Context(), len(tt.items), batchSize, workers, fn) 72 | require.NoError(err) 73 | 74 | got := make([]batch, len(gotCh)) 75 | i := 0 76 | for span := range gotCh { 77 | got[i] = span 78 | i++ 79 | 80 | if i == len(got) { 81 | break 82 | } 83 | } 84 | require.Equal(tt.want, got) 85 | }) 86 | } 87 | } 88 | 89 | func TestConcurrentBatch(t *testing.T) { 90 | tests := []struct { 91 | name string 92 | items []string 93 | batchSize int 94 | workers int 95 | wantCalls int 96 | }{ 97 | { 98 | name: "5 batches", 99 | items: generateItems(50), 100 | batchSize: 10, 101 | workers: 3, 102 | wantCalls: 5, 103 | }, 104 | { 105 | name: "0 batches", 106 | items: []string{}, 107 | batchSize: 10, 108 | workers: 3, 109 | wantCalls: 0, 110 | }, 111 | { 112 | name: "1 batch", 113 | items: generateItems(10), 114 | batchSize: 10, 115 | workers: 3, 116 | wantCalls: 1, 117 | }, 118 | { 119 | name: "1 full batch, 1 partial batch", 120 | items: generateItems(15), 121 | batchSize: 10, 122 | workers: 3, 123 | wantCalls: 2, 124 | }, 125 | } 126 | 127 | for _, tt := range tests { 128 | tt := tt 129 | t.Run(tt.name, func(t *testing.T) { 130 | require := require.New(t) 131 | 132 | var calls int64 133 | fn := func(_ context.Context, _, _, _ int) error { 134 | atomic.AddInt64(&calls, 1) 135 | return nil 136 | } 137 | err := ConcurrentBatch(t.Context(), len(tt.items), tt.batchSize, tt.workers, fn) 138 | 139 | require.NoError(err) 140 | require.Equal(tt.wantCalls, int(calls)) 141 | }) 142 | } 143 | } 144 | 145 | func TestConcurrentBatchWhenOneBatchFailsAndWorkersIsOne(t *testing.T) { 146 | items := generateItems(20) 147 | batchSize := 10 148 | workers := 1 // effectively no parallelization... 149 | var callsToEachFn int64 150 | batchFn := func(_ context.Context, no int, _ int, _ int) error { 151 | atomic.AddInt64(&callsToEachFn, 1) 152 | if no == 0 { 153 | return errors.New("one batch failed") 154 | } 155 | return nil 156 | } 157 | err := ConcurrentBatch(t.Context(), len(items), batchSize, workers, batchFn) 158 | 159 | require.ErrorContains(t, err, "one batch failed") 160 | 161 | // one call is made, the other one is never queued 162 | require.Equal(t, 1, int(callsToEachFn)) 163 | } 164 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/docs-style-sharing/README.md: -------------------------------------------------------------------------------- 1 | # Google Docs-style Sharing 2 | 3 | Models a Google Docs-style sharing permission system where users can be granted direct access to a resource, or access via organizations and nested groups. 4 | 5 | --- 6 | 7 | ## Schema 8 | 9 | ```zed 10 | definition user {} 11 | 12 | definition resource { 13 | relation manager: user | usergroup#member | usergroup#manager 14 | relation viewer: user | usergroup#member | usergroup#manager 15 | 16 | permission manage = manager 17 | permission view = viewer + manager 18 | } 19 | 20 | definition usergroup { 21 | relation manager: user | usergroup#member | usergroup#manager 22 | relation direct_member: user | usergroup#member | usergroup#manager 23 | 24 | permission member = direct_member + manager 25 | } 26 | 27 | definition organization { 28 | relation group: usergroup 29 | relation administrator: user | usergroup#member | usergroup#manager 30 | relation direct_member: user 31 | 32 | relation resource: resource 33 | 34 | permission admin = administrator 35 | permission member = direct_member + administrator + group->member 36 | } 37 | ``` 38 | 39 | ### user 40 | 41 | `user` is an example of a "user" type, which is used to represent users. The definition itself is empty, as it is only used for referencing purposes. 42 | 43 | ```zed 44 | definition user {} 45 | ``` 46 | 47 | ### resource 48 | 49 | `resource` is the definition used to represent the resource being shared 50 | 51 | ```zed 52 | definition resource { 53 | relation manager: user | usergroup#member | usergroup#manager 54 | relation viewer: user | usergroup#member | usergroup#manager 55 | 56 | permission manage = manager 57 | permission view = viewer + manager 58 | } 59 | ``` 60 | 61 | Within the definition, there are defined two relations: `viewer` and `manager`, which are used to represent roles for users _or members/managers of groups_ for the resource, as well as the `view` and `manage` permissions for viewing and managing the resource, respectively. 62 | 63 | ### usergroup 64 | 65 | `usergroup` is the definition used to represent groups, which can contain either users or other groups. Groups support a distinction between member and manager. 66 | 67 | ```zed 68 | definition usergroup { 69 | relation manager: user | usergroup#member | usergroup#manager 70 | relation direct_member: user | usergroup#member | usergroup#manager 71 | 72 | permission member = direct_member + manager 73 | } 74 | ``` 75 | 76 | ### organization 77 | 78 | `organization` is the definition used to represent the overall organization. 79 | 80 | ```zed 81 | definition organization { 82 | relation group: usergroup 83 | relation administrator: user | usergroup#member | usergroup#manager 84 | relation direct_member: user 85 | 86 | relation resource: resource 87 | 88 | permission admin = administrator 89 | permission member = direct_member + administrator + group->member 90 | } 91 | ``` 92 | 93 | Organizations contain four relations (`group`, `resource`, `member`, `administrator`) which are used to reference the groups, resources, direct members and administrator users for the organization. 94 | 95 | #### member permission 96 | 97 | The `member` permission under organization computes the transitive closure of _all_ member users/groups of an organization by combining data from three sources: 98 | 99 | 1. `direct_member`: users directly added to the organization as a member 100 | 2. `administrator` is used to add any users found as an `administrator` of the organization 101 | 3. `group->member` is used to walk from the organization to any of its groups, and then from the `group` to any of its members. This ensure that if a user is available under `member` under any group in the organization, they are treated as a member of the organization as well. 102 | -------------------------------------------------------------------------------- /magefiles/util.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "strings" 13 | 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | type byName []*cobra.Command 18 | 19 | type CommandContent struct { 20 | Name string 21 | Content string 22 | } 23 | 24 | func (s byName) Len() int { return len(s) } 25 | func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 26 | func (s byName) Less(i, j int) bool { return s[i].Name() < s[j].Name() } 27 | 28 | func GenCustomMarkdownTree(cmd *cobra.Command, dir string) error { 29 | basename := strings.ReplaceAll(cmd.CommandPath(), " ", "_") + ".md" 30 | filename := filepath.Join(dir, basename) 31 | 32 | f, err := os.Create(filename) 33 | if err != nil { 34 | return err 35 | } 36 | defer f.Close() 37 | 38 | return genMarkdownTreeCustom(cmd, f) 39 | } 40 | 41 | func genMarkdownTreeCustom(cmd *cobra.Command, f *os.File) error { 42 | var commandContents []CommandContent 43 | 44 | collectCommandContent(cmd, &commandContents) 45 | 46 | // for sorting commands and their content 47 | sort.Slice(commandContents, func(i, j int) bool { 48 | return commandContents[i].Name < commandContents[j].Name 49 | }) 50 | 51 | for _, cc := range commandContents { 52 | _, err := f.WriteString(cc.Content) 53 | if err != nil { 54 | return err 55 | } 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func collectCommandContent(cmd *cobra.Command, commandContents *[]CommandContent) { 62 | buf := new(bytes.Buffer) 63 | 64 | name := cmd.CommandPath() 65 | 66 | buf.WriteString("## Reference: `" + name + "`\n\n") 67 | if len(cmd.Short) > 0 && len(cmd.Long) == 0 { 68 | buf.WriteString(cmd.Short + "\n\n") 69 | } else if len(cmd.Short) > 0 { 70 | buf.WriteString(cmd.Long + "\n\n") 71 | } 72 | 73 | if cmd.Runnable() { 74 | buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.UseLine())) 75 | } 76 | 77 | if len(cmd.Example) > 0 { 78 | buf.WriteString("### Examples\n\n") 79 | buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.Example)) 80 | } 81 | 82 | if err := printOptions(buf, cmd); err != nil { 83 | fmt.Println("Error printing options:", err) 84 | } 85 | 86 | children := cmd.Commands() 87 | sort.Sort(byName(children)) 88 | 89 | if len(children) > 0 { 90 | buf.WriteString("### Children commands\n\n") 91 | } 92 | for _, child := range children { 93 | if !child.IsAvailableCommand() || child.IsAdditionalHelpTopicCommand() { 94 | continue 95 | } 96 | cname := name + " " + child.Name() 97 | link := "reference-" + strings.ReplaceAll(strings.ReplaceAll(cname, "_", "-"), " ", "-") 98 | buf.WriteString(fmt.Sprintf("- [%s](#%s)\t - %s\n", cname, link, child.Short)) 99 | } 100 | buf.WriteString("\n\n") 101 | 102 | *commandContents = append(*commandContents, CommandContent{ 103 | Name: name, 104 | Content: buf.String(), 105 | }) 106 | 107 | for _, c := range cmd.Commands() { 108 | if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() { 109 | continue 110 | } 111 | collectCommandContent(c, commandContents) 112 | } 113 | } 114 | 115 | func hasSeeAlso(cmd *cobra.Command) bool { 116 | if cmd.HasParent() { 117 | return true 118 | } 119 | for _, c := range cmd.Commands() { 120 | if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() { 121 | continue 122 | } 123 | return true 124 | } 125 | return false 126 | } 127 | 128 | func printOptions(buf *bytes.Buffer, cmd *cobra.Command) error { 129 | flags := cmd.NonInheritedFlags() 130 | flags.SetOutput(buf) 131 | 132 | if flags.HasAvailableFlags() { 133 | buf.WriteString("### Options\n\n```\n") 134 | flags.PrintDefaults() 135 | buf.WriteString("```\n\n") 136 | } 137 | 138 | parentFlags := cmd.InheritedFlags() 139 | parentFlags.SetOutput(buf) 140 | 141 | if parentFlags.HasAvailableFlags() { 142 | buf.WriteString("### Options Inherited From Parent Flags\n\n```\n") 143 | parentFlags.PrintDefaults() 144 | buf.WriteString("```\n\n") 145 | } 146 | 147 | return nil 148 | } 149 | -------------------------------------------------------------------------------- /internal/commands/util.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/TylerBrock/colorjson" 10 | "github.com/jzelinskie/cobrautil/v2" 11 | "github.com/jzelinskie/stringz" 12 | "github.com/spf13/cobra" 13 | "google.golang.org/protobuf/encoding/protojson" 14 | "google.golang.org/protobuf/proto" 15 | "google.golang.org/protobuf/types/known/structpb" 16 | 17 | "github.com/authzed/authzed-go/pkg/requestmeta" 18 | ) 19 | 20 | // ParseSubject parses the given subject string into its namespace, object ID 21 | // and relation, if valid. 22 | func ParseSubject(s string) (namespace, id, relation string, err error) { 23 | err = stringz.SplitExact(s, ":", &namespace, &id) 24 | if err != nil { 25 | return namespace, id, relation, err 26 | } 27 | err = stringz.SplitExact(id, "#", &id, &relation) 28 | if err != nil { 29 | relation = "" 30 | err = nil 31 | } 32 | return namespace, id, relation, err 33 | } 34 | 35 | // ParseType parses a type reference of the form `namespace#relaion`. 36 | func ParseType(s string) (namespace, relation string) { 37 | namespace, relation, _ = strings.Cut(s, "#") 38 | return namespace, relation 39 | } 40 | 41 | // GetCaveatContext returns the entered caveat caveat, if any. 42 | func GetCaveatContext(cmd *cobra.Command) (*structpb.Struct, error) { 43 | contextString := cobrautil.MustGetString(cmd, "caveat-context") 44 | if len(contextString) == 0 { 45 | return nil, nil 46 | } 47 | 48 | return ParseCaveatContext(contextString) 49 | } 50 | 51 | // ParseCaveatContext parses the given context JSON string into caveat context, 52 | // if valid. 53 | func ParseCaveatContext(contextString string) (*structpb.Struct, error) { 54 | contextMap := map[string]any{} 55 | err := json.Unmarshal([]byte(contextString), &contextMap) 56 | if err != nil { 57 | return nil, fmt.Errorf("invalid caveat context JSON: %w", err) 58 | } 59 | 60 | context, err := structpb.NewStruct(contextMap) 61 | if err != nil { 62 | return nil, fmt.Errorf("could not construct caveat context: %w", err) 63 | } 64 | return context, err 65 | } 66 | 67 | // PrettyProto returns the given protocol buffer formatted into pretty text. 68 | func PrettyProto(m proto.Message) ([]byte, error) { 69 | encoded, err := protojson.Marshal(m) 70 | if err != nil { 71 | return nil, err 72 | } 73 | var obj any 74 | err = json.Unmarshal(encoded, &obj) 75 | if err != nil { 76 | panic("protojson decode failed: " + err.Error()) 77 | } 78 | 79 | f := colorjson.NewFormatter() 80 | f.Indent = 2 81 | pretty, err := f.Marshal(obj) 82 | if err != nil { 83 | panic("colorjson encode failed: " + err.Error()) 84 | } 85 | 86 | return pretty, nil 87 | } 88 | 89 | // InjectRequestID adds the value of the --request-id flag to the 90 | // context of the given command. 91 | func InjectRequestID(cmd *cobra.Command, _ []string) error { 92 | ctx := cmd.Context() 93 | requestID := cobrautil.MustGetString(cmd, "request-id") 94 | if ctx != nil && requestID != "" { 95 | cmd.SetContext(requestmeta.WithRequestID(ctx, requestID)) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | // ValidationError is used to wrap errors that are cobra validation errors. It should be used to 102 | // wrap the Command.PositionalArgs function in order to be able to determine if the error is a validation error. 103 | // This is used to determine if an error should print the usage string. Unfortunately Cobra parameter parsing 104 | // and parameter validation are handled differently, and the latter does not trigger calling Command.FlagErrorFunc 105 | type ValidationError struct { 106 | error 107 | } 108 | 109 | func (ve ValidationError) Is(err error) bool { 110 | var validationError ValidationError 111 | return errors.As(err, &validationError) 112 | } 113 | 114 | // ValidationWrapper is used to be able to determine if an error is a validation error. 115 | func ValidationWrapper(f cobra.PositionalArgs) cobra.PositionalArgs { 116 | return func(cmd *cobra.Command, args []string) error { 117 | if err := f(cmd, args); err != nil { 118 | return ValidationError{error: err} 119 | } 120 | 121 | return nil 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /pkg/backupformat/decoder.go: -------------------------------------------------------------------------------- 1 | package backupformat 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/hamba/avro/v2" 9 | "github.com/hamba/avro/v2/ocf" 10 | "google.golang.org/protobuf/proto" 11 | "google.golang.org/protobuf/types/known/structpb" 12 | 13 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 14 | ) 15 | 16 | func init() { 17 | // This defaults to a 1MiB limit, but large schemas can exceed this size. 18 | avro.DefaultConfig = avro.Config{ 19 | MaxByteSliceSize: 1024 * 1024 * 100, // 100 MiB 20 | }.Freeze() 21 | } 22 | 23 | func NewDecoder(r io.Reader) (*OcfDecoder, error) { 24 | dec, err := ocf.NewDecoder(r) 25 | if err != nil { 26 | return nil, fmt.Errorf("unable to create ocf decoder: %w", err) 27 | } 28 | 29 | md := dec.Metadata() 30 | var zedToken *v1.ZedToken 31 | 32 | if token, ok := md[metadataKeyZT]; ok { 33 | zedToken = &v1.ZedToken{ 34 | Token: string(token), 35 | } 36 | } 37 | 38 | var schemaText string 39 | if dec.HasNext() { 40 | var decodedSchema any 41 | if err := dec.Decode(&decodedSchema); err != nil { 42 | return nil, fmt.Errorf("unable to decode schema object: %w", err) 43 | } 44 | 45 | schema, ok := decodedSchema.(SchemaV1) 46 | if !ok { 47 | return nil, fmt.Errorf("received schema object of wrong type: %T", decodedSchema) 48 | } 49 | schemaText = schema.SchemaText 50 | } else { 51 | return nil, errors.New("avro stream contains no schema object") 52 | } 53 | 54 | return &OcfDecoder{ 55 | dec, 56 | schemaText, 57 | zedToken, 58 | }, nil 59 | } 60 | 61 | type Decoder interface { 62 | Schema() (string, error) 63 | ZedToken() (*v1.ZedToken, error) 64 | Next() (*v1.Relationship, error) 65 | } 66 | 67 | var ( 68 | _ Decoder = (*OcfDecoder)(nil) 69 | _ Decoder = (*RewriteDecoder)(nil) 70 | ) 71 | 72 | type RewriteDecoder struct { 73 | Rewriter 74 | Decoder 75 | } 76 | 77 | func (d *RewriteDecoder) Schema() (string, error) { 78 | schema, err := d.Decoder.Schema() 79 | if err != nil { 80 | return "", err 81 | } 82 | return d.RewriteSchema(schema) 83 | } 84 | 85 | func (d *RewriteDecoder) Next() (*v1.Relationship, error) { 86 | r, err := d.Decoder.Next() 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | for { 92 | r, err = d.RewriteRelationship(r) 93 | if err != nil { 94 | return nil, err 95 | } else if r == nil { 96 | // The rewriter filtered this relationship, decode another. 97 | r, err = d.Decoder.Next() 98 | if err != nil { 99 | return nil, err 100 | } 101 | continue 102 | } 103 | return r, nil 104 | } 105 | } 106 | 107 | type OcfDecoder struct { 108 | dec *ocf.Decoder 109 | schema string 110 | zedToken *v1.ZedToken 111 | } 112 | 113 | func (d *OcfDecoder) Schema() (string, error) { return d.schema, nil } 114 | func (d *OcfDecoder) ZedToken() (*v1.ZedToken, error) { return d.zedToken, nil } 115 | func (d *OcfDecoder) Close() error { return nil } 116 | 117 | func (d *OcfDecoder) Next() (*v1.Relationship, error) { 118 | if !d.dec.HasNext() { 119 | return nil, io.EOF 120 | } 121 | 122 | var nextRelIFace any 123 | if err := d.dec.Decode(&nextRelIFace); err != nil { 124 | return nil, fmt.Errorf("unable to decode relationship from avro stream: %w", err) 125 | } 126 | 127 | flat := nextRelIFace.(RelationshipV1) 128 | 129 | rel := &v1.Relationship{ 130 | Resource: &v1.ObjectReference{ 131 | ObjectType: flat.ObjectType, 132 | ObjectId: flat.ObjectID, 133 | }, 134 | Relation: flat.Relation, 135 | Subject: &v1.SubjectReference{ 136 | Object: &v1.ObjectReference{ 137 | ObjectType: flat.SubjectObjectType, 138 | ObjectId: flat.SubjectObjectID, 139 | }, 140 | OptionalRelation: flat.SubjectRelation, 141 | }, 142 | } 143 | 144 | if flat.CaveatName != "" { 145 | var deserializedCtxt structpb.Struct 146 | 147 | if err := proto.Unmarshal(flat.CaveatContext, &deserializedCtxt); err != nil { 148 | return nil, fmt.Errorf("unable to deserialize caveat context: %w", err) 149 | } 150 | 151 | rel.OptionalCaveat = &v1.ContextualizedCaveat{ 152 | CaveatName: flat.CaveatName, 153 | Context: &deserializedCtxt, 154 | } 155 | } 156 | 157 | return rel, nil 158 | } 159 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Lint" 3 | on: # yamllint disable-line rule:truthy 4 | push: 5 | branches: 6 | - "!dependabot/*" 7 | - "main" 8 | pull_request: 9 | branches: ["*"] 10 | merge_group: 11 | types: 12 | - "checks_requested" 13 | jobs: 14 | go-lint: 15 | name: "Lint Go" 16 | runs-on: "ubuntu-latest" 17 | steps: 18 | - uses: "actions/checkout@v5" 19 | - uses: "authzed/actions/setup-go@main" 20 | - uses: "authzed/actions/gofumpt@main" 21 | - uses: "authzed/actions/go-mod-tidy@main" 22 | - uses: "authzed/actions/go-generate@main" 23 | - uses: "authzed/actions/golangci-lint@main" 24 | 25 | extra-lint: 26 | name: "Lint YAML & Markdown" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - uses: "actions/checkout@v5" 30 | - uses: "authzed/actions/yaml-lint@main" 31 | - uses: "authzed/actions/markdown-lint@main" 32 | with: 33 | args: "--ignore docs/zed.md" # auto-generated 34 | 35 | codeql: 36 | name: "Analyze with CodeQL" 37 | runs-on: "ubuntu-latest" 38 | permissions: 39 | actions: "read" 40 | contents: "read" 41 | security-events: "write" 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | language: ["go"] 46 | steps: 47 | - uses: "actions/checkout@v5" 48 | - uses: "authzed/actions/setup-go@main" 49 | - uses: "authzed/actions/codeql@main" 50 | 51 | trivy-fs: 52 | name: "Analyze FS with Trivy" 53 | runs-on: "ubuntu-latest" 54 | steps: 55 | - uses: "actions/checkout@v5" 56 | - uses: "aquasecurity/trivy-action@master" 57 | with: 58 | scan-type: "fs" 59 | ignore-unfixed: true 60 | format: "table" 61 | exit-code: "1" 62 | severity: "CRITICAL,HIGH,MEDIUM" 63 | env: 64 | TRIVY_DB_REPOSITORY: "public.ecr.aws/aquasecurity/trivy-db" 65 | TRIVY_JAVA_DB_REPOSITORY: "public.ecr.aws/aquasecurity/trivy-java-db" 66 | 67 | trivy-image: 68 | name: "Analyze Release Image with Trivy" 69 | runs-on: "ubuntu-latest" 70 | steps: 71 | - uses: "actions/checkout@v5" 72 | - uses: "authzed/actions/setup-go@main" 73 | # Workaround until goreleaser release supports --single-target 74 | # makes the build faster by not building everything 75 | - name: "modify goreleaser config to skip building all targets" 76 | run: | 77 | echo "partial: 78 | by: target" >> .goreleaser.docker.yml 79 | - uses: "goreleaser/goreleaser-action@v6" 80 | id: "goreleaser" 81 | with: 82 | distribution: "goreleaser-pro" 83 | # NOTE: keep in sync with goreleaser version in other job. 84 | # github actions don't allow yaml anchors. 85 | version: "v2.12.5" 86 | args: "release -f .goreleaser.docker.yml --clean --split --snapshot" 87 | env: 88 | GORELEASER_KEY: "${{ secrets.GORELEASER_KEY }}" 89 | - name: "Obtain container image to scan" 90 | run: | 91 | IMAGE_VERSION=$(jq .version dist/linux_amd64/metadata.json --raw-output) 92 | if [ -z "$IMAGE_VERSION" ]; then 93 | echo "Failed to extract version from metadata.json" 94 | exit 1 95 | fi 96 | echo "IMAGE_VERSION=$IMAGE_VERSION" >> $GITHUB_ENV 97 | - name: "run trivy on release image" 98 | run: "docker run -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image --format table --exit-code 1 --ignore-unfixed --vuln-type os,library --no-progress --severity CRITICAL,HIGH,MEDIUM authzed/zed:v${{ env.IMAGE_VERSION }}-amd64 --db-repository public.ecr.aws/aquasecurity/trivy-db --java-db-repository public.ecr.aws/aquasecurity/trivy-java-db" 99 | 100 | conventional-commits: 101 | name: "Lint Commit Messages" 102 | runs-on: "depot-ubuntu-24.04-small" 103 | if: "github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || github.event.action == 'edited')" 104 | steps: 105 | - uses: "actions/checkout@v5" 106 | - uses: "webiny/action-conventional-commits@v1.3.0" 107 | -------------------------------------------------------------------------------- /internal/cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | "google.golang.org/grpc" 9 | "google.golang.org/grpc/metadata" 10 | 11 | "github.com/authzed/authzed-go/pkg/responsemeta" 12 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 13 | 14 | zedtesting "github.com/authzed/zed/internal/testing" 15 | ) 16 | 17 | func TestGetClientVersion(t *testing.T) { 18 | t.Parallel() 19 | 20 | tests := []struct { 21 | name string 22 | includeDeps bool 23 | expectStr string 24 | }{ 25 | { 26 | name: "without dependencies", 27 | includeDeps: false, 28 | expectStr: "zed (devel)", 29 | }, 30 | { 31 | name: "with dependencies", 32 | includeDeps: true, 33 | expectStr: "github.com/authzed/zed/internal/cmd.test (devel)", 34 | }, 35 | } 36 | 37 | for _, tt := range tests { 38 | t.Run(tt.name, func(t *testing.T) { 39 | t.Parallel() 40 | cmd := zedtesting.CreateTestCobraCommandWithFlagValue(t, 41 | zedtesting.BoolFlag{FlagName: "include-deps", FlagValue: tt.includeDeps}) 42 | 43 | result := getClientVersion(cmd) 44 | t.Log(result) 45 | require.Contains(t, result, tt.expectStr) 46 | }) 47 | } 48 | } 49 | 50 | func TestGetServerVersion(t *testing.T) { 51 | t.Parallel() 52 | tests := []struct { 53 | name string 54 | serverVersionInHeader string 55 | serverVersionInTrailer string 56 | expectVersion string 57 | expectErr bool 58 | }{ 59 | { 60 | name: "valid server version in header", 61 | serverVersionInHeader: "v1.2.3", 62 | expectVersion: "v1.2.3", 63 | expectErr: false, 64 | }, 65 | { 66 | name: "valid server version in trailer", 67 | serverVersionInTrailer: "v1.2.3", 68 | expectVersion: "v1.2.3", 69 | expectErr: false, 70 | }, 71 | { 72 | name: "empty server version in header and trailer", 73 | expectVersion: "(unknown)", 74 | expectErr: false, 75 | }, 76 | { 77 | name: "inconsistent server versions in header and trailer", 78 | serverVersionInHeader: "v1.0.0", 79 | serverVersionInTrailer: "v1.0.1", 80 | expectErr: true, 81 | }, 82 | } 83 | 84 | for _, tt := range tests { 85 | t.Run(tt.name, func(t *testing.T) { 86 | t.Parallel() 87 | cmd := zedtesting.CreateTestCobraCommandWithFlagValue(t) 88 | 89 | mockClient := &mockSchemaServiceClient{ 90 | serverVersionInHeader: tt.serverVersionInHeader, 91 | serverVersionInTrailer: tt.serverVersionInTrailer, 92 | } 93 | 94 | result, err := getServerVersion(cmd, mockClient) 95 | 96 | if tt.expectErr { 97 | require.Error(t, err) 98 | } else { 99 | require.NoError(t, err) 100 | require.Equal(t, tt.expectVersion, result) 101 | } 102 | }) 103 | } 104 | } 105 | 106 | type mockSchemaServiceClient struct { 107 | v1.SchemaServiceClient 108 | serverVersionInHeader string 109 | serverVersionInTrailer string 110 | } 111 | 112 | var _ v1.SchemaServiceClient = (*mockSchemaServiceClient)(nil) 113 | 114 | func (m *mockSchemaServiceClient) ReadSchema(_ context.Context, _ *v1.ReadSchemaRequest, opts ...grpc.CallOption) (*v1.ReadSchemaResponse, error) { 115 | for _, opt := range opts { 116 | if headerOpt, ok := opt.(grpc.HeaderCallOption); ok { 117 | if m.serverVersionInHeader != "" { 118 | *headerOpt.HeaderAddr = metadata.MD{ 119 | string(responsemeta.ServerVersion): []string{m.serverVersionInHeader}, 120 | } 121 | } else { 122 | *headerOpt.HeaderAddr = metadata.MD{} 123 | } 124 | } 125 | if trailerOpt, ok := opt.(grpc.TrailerCallOption); ok { 126 | if m.serverVersionInTrailer != "" { 127 | *trailerOpt.TrailerAddr = metadata.MD{ 128 | string(responsemeta.ServerVersion): []string{m.serverVersionInTrailer}, 129 | } 130 | } else { 131 | *trailerOpt.TrailerAddr = metadata.MD{} 132 | } 133 | } 134 | } 135 | return &v1.ReadSchemaResponse{}, nil 136 | } 137 | 138 | func (m *mockSchemaServiceClient) WriteSchema(_ context.Context, _ *v1.WriteSchemaRequest, _ ...grpc.CallOption) (*v1.WriteSchemaResponse, error) { 139 | return nil, nil 140 | } 141 | -------------------------------------------------------------------------------- /internal/testing/test_helpers.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/stretchr/testify/require" 10 | "google.golang.org/grpc" 11 | 12 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 13 | "github.com/authzed/authzed-go/v1" 14 | "github.com/authzed/spicedb/pkg/cmd/datastore" 15 | "github.com/authzed/spicedb/pkg/cmd/server" 16 | "github.com/authzed/spicedb/pkg/cmd/util" 17 | 18 | "github.com/authzed/zed/internal/client" 19 | ) 20 | 21 | func ClientFromConn(conn *grpc.ClientConn) func(_ *cobra.Command) (client.Client, error) { 22 | return func(_ *cobra.Command) (client.Client, error) { 23 | return &authzed.ClientWithExperimental{ 24 | Client: authzed.Client{ 25 | SchemaServiceClient: v1.NewSchemaServiceClient(conn), 26 | PermissionsServiceClient: v1.NewPermissionsServiceClient(conn), 27 | WatchServiceClient: v1.NewWatchServiceClient(conn), 28 | }, 29 | ExperimentalServiceClient: v1.NewExperimentalServiceClient(conn), 30 | }, nil 31 | } 32 | } 33 | 34 | func NewTestServer(ctx context.Context, t *testing.T) server.RunnableServer { 35 | t.Helper() 36 | 37 | ds, err := datastore.NewDatastore(ctx, 38 | datastore.DefaultDatastoreConfig().ToOption(), 39 | datastore.WithRequestHedgingEnabled(false), 40 | ) 41 | require.NoError(t, err, "unable to start memdb datastore") 42 | 43 | configOpts := []server.ConfigOption{ 44 | server.WithGRPCServer(util.GRPCServerConfig{ 45 | Network: util.BufferedNetwork, 46 | Enabled: true, 47 | }), 48 | server.WithGRPCAuthFunc(func(ctx context.Context) (context.Context, error) { 49 | return ctx, nil 50 | }), 51 | server.WithHTTPGateway(util.HTTPServerConfig{HTTPEnabled: false}), 52 | server.WithMetricsAPI(util.HTTPServerConfig{HTTPEnabled: false}), 53 | server.WithDispatchCacheConfig(server.CacheConfig{Enabled: false, Metrics: false}), 54 | server.WithNamespaceCacheConfig(server.CacheConfig{Enabled: false, Metrics: false}), 55 | server.WithClusterDispatchCacheConfig(server.CacheConfig{Enabled: false, Metrics: false}), 56 | server.WithDatastore(ds), 57 | } 58 | 59 | srv, err := server.NewConfigWithOptionsAndDefaults(configOpts...).Complete(ctx) 60 | require.NoError(t, err) 61 | 62 | return srv 63 | } 64 | 65 | type StringToStringFlag struct { 66 | FlagName string 67 | FlagValue map[string]string 68 | Changed bool 69 | } 70 | 71 | type StringFlag struct { 72 | FlagName string 73 | FlagValue string 74 | Changed bool 75 | } 76 | 77 | type BoolFlag struct { 78 | FlagName string 79 | FlagValue bool 80 | Changed bool 81 | } 82 | 83 | type IntFlag struct { 84 | FlagName string 85 | FlagValue int 86 | Changed bool 87 | } 88 | 89 | type UintFlag struct { 90 | FlagName string 91 | FlagValue uint 92 | Changed bool 93 | } 94 | 95 | type UintFlag32 struct { 96 | FlagName string 97 | FlagValue uint32 98 | Changed bool 99 | } 100 | 101 | type DurationFlag struct { 102 | FlagName string 103 | FlagValue time.Duration 104 | Changed bool 105 | } 106 | 107 | func CreateTestCobraCommandWithFlagValue(t *testing.T, flagAndValues ...any) *cobra.Command { 108 | t.Helper() 109 | 110 | c := cobra.Command{} 111 | for _, flagAndValue := range flagAndValues { 112 | switch f := flagAndValue.(type) { 113 | case StringToStringFlag: 114 | c.Flags().StringToString(f.FlagName, f.FlagValue, "") 115 | c.Flag(f.FlagName).Changed = f.Changed 116 | case StringFlag: 117 | c.Flags().String(f.FlagName, f.FlagValue, "") 118 | c.Flag(f.FlagName).Changed = f.Changed 119 | case BoolFlag: 120 | c.Flags().Bool(f.FlagName, f.FlagValue, "") 121 | c.Flag(f.FlagName).Changed = f.Changed 122 | case IntFlag: 123 | c.Flags().Int(f.FlagName, f.FlagValue, "") 124 | c.Flag(f.FlagName).Changed = f.Changed 125 | case UintFlag: 126 | c.Flags().Uint(f.FlagName, f.FlagValue, "") 127 | c.Flag(f.FlagName).Changed = f.Changed 128 | case UintFlag32: 129 | c.Flags().Uint32(f.FlagName, f.FlagValue, "") 130 | c.Flag(f.FlagName).Changed = f.Changed 131 | case DurationFlag: 132 | c.Flags().Duration(f.FlagName, f.FlagValue, "") 133 | c.Flag(f.FlagName).Changed = f.Changed 134 | default: 135 | t.Fatalf("unknown flag type: %T", f) 136 | } 137 | } 138 | 139 | c.SetContext(t.Context()) 140 | return &c 141 | } 142 | -------------------------------------------------------------------------------- /.goreleaser.docker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | builds: 4 | - id: "linux-amd64" 5 | goos: ["linux"] 6 | goarch: ["amd64"] 7 | env: ["CC=gcc", "CGO_ENABLED=0"] 8 | main: &main "./cmd/zed" 9 | binary: &binary "zed" 10 | mod_timestamp: &mod_timestamp "{{ .CommitTimestamp }}" 11 | flags: &flags ["-trimpath"] 12 | asmflags: &asmflags ["all=-trimpath={{ .Env.GITHUB_WORKSPACE }}"] 13 | gcflags: &gcflags ["all=-trimpath={{ .Env.GITHUB_WORKSPACE }}"] 14 | ldflags: &ldflags 15 | - "-s -w" 16 | - "-X github.com/jzelinskie/cobrautil/v2.Version=v{{ .Version }}" 17 | - id: "linux-arm64" 18 | goos: ["linux"] 19 | goarch: ["arm64"] 20 | env: ["CC=aarch64-linux-gnu-gcc", "CGO_ENABLED=0"] 21 | main: *main 22 | binary: *binary 23 | mod_timestamp: *mod_timestamp 24 | flags: *flags 25 | asmflags: *asmflags 26 | gcflags: *gcflags 27 | ldflags: *ldflags 28 | 29 | dockers: 30 | # AMD64 31 | - image_templates: 32 | - &amd_image_quay "quay.io/authzed/zed:v{{ .Version }}-amd64" 33 | - &amd_image_gh "ghcr.io/authzed/zed:v{{ .Version }}-amd64" 34 | - &amd_image_dh "authzed/zed:v{{ .Version }}-amd64" 35 | ids: ["linux-amd64"] 36 | dockerfile: &dockerfile "Dockerfile.release" 37 | goos: "linux" 38 | goarch: "amd64" 39 | use: "buildx" 40 | build_flag_templates: 41 | - "--platform=linux/amd64" 42 | # AMD64 (debug) 43 | - image_templates: 44 | - &amd_debug_image_quay "quay.io/authzed/zed:v{{ .Version }}-amd64-debug" 45 | - &amd_debug_image_gh "ghcr.io/authzed/zed:v{{ .Version }}-amd64-debug" 46 | - &amd_debug_image_dh "authzed/zed:v{{ .Version }}-amd64-debug" 47 | ids: ["linux-amd64"] 48 | dockerfile: "Dockerfile.release" 49 | goos: "linux" 50 | goarch: "amd64" 51 | use: "buildx" 52 | build_flag_templates: 53 | - "--platform=linux/amd64" 54 | - "--build-arg=BASE=cgr.dev/chainguard/busybox:latest" 55 | # ARM64 56 | - image_templates: 57 | - &arm_image_quay "quay.io/authzed/zed:v{{ .Version }}-arm64" 58 | - &arm_image_gh "ghcr.io/authzed/zed:v{{ .Version }}-arm64" 59 | - &arm_image_dh "authzed/zed:v{{ .Version }}-arm64" 60 | ids: ["linux-arm64"] 61 | dockerfile: *dockerfile 62 | goos: "linux" 63 | goarch: "arm64" 64 | use: "buildx" 65 | build_flag_templates: 66 | - "--platform=linux/arm64" 67 | # ARM64 (debug) 68 | - image_templates: 69 | - &arm_debug_image_quay "quay.io/authzed/zed:v{{ .Version }}-arm64-debug" 70 | - &arm_debug_image_gh "ghcr.io/authzed/zed:v{{ .Version }}-arm64-debug" 71 | - &arm_debug_image_dh "authzed/zed:v{{ .Version }}-arm64-debug" 72 | ids: ["linux-arm64"] 73 | dockerfile: *dockerfile 74 | goos: "linux" 75 | goarch: "arm64" 76 | use: "buildx" 77 | build_flag_templates: 78 | - "--platform=linux/arm64" 79 | - "--build-arg=BASE=cgr.dev/chainguard/busybox:latest" 80 | 81 | docker_manifests: 82 | # Quay 83 | - name_template: "quay.io/authzed/zed:v{{ .Version }}" 84 | image_templates: [*amd_image_quay, *arm_image_quay] 85 | - name_template: "quay.io/authzed/zed:latest" 86 | image_templates: [*amd_image_quay, *arm_image_quay] 87 | # GitHub Registry 88 | - name_template: "ghcr.io/authzed/zed:v{{ .Version }}" 89 | image_templates: [*amd_image_gh, *arm_image_gh] 90 | - name_template: "ghcr.io/authzed/zed:latest" 91 | image_templates: [*amd_image_gh, *arm_image_gh] 92 | # Docker Hub 93 | - name_template: "authzed/zed:v{{ .Version }}" 94 | image_templates: [*amd_image_dh, *arm_image_dh] 95 | - name_template: "authzed/zed:latest" 96 | image_templates: [*amd_image_dh, *arm_image_dh] 97 | # Debug Images: 98 | # Quay (debug) 99 | - name_template: "quay.io/authzed/zed:v{{ .Version }}-debug" 100 | image_templates: [*amd_debug_image_quay, *arm_debug_image_quay] 101 | - name_template: "quay.io/authzed/zed:latest-debug" 102 | image_templates: [*amd_debug_image_quay, *arm_debug_image_quay] 103 | # GitHub Registry 104 | - name_template: "ghcr.io/authzed/zed:v{{ .Version }}-debug" 105 | image_templates: [*amd_debug_image_gh, *arm_debug_image_gh] 106 | - name_template: "ghcr.io/authzed/zed:latest-debug" 107 | image_templates: [*amd_debug_image_gh, *arm_debug_image_gh] 108 | # Docker Hub 109 | - name_template: "authzed/zed:v{{ .Version }}-debug" 110 | image_templates: [*amd_debug_image_dh, *arm_debug_image_dh] 111 | - name_template: "authzed/zed:latest-debug" 112 | image_templates: [*amd_debug_image_dh, *arm_debug_image_dh] 113 | release: 114 | disable: true 115 | -------------------------------------------------------------------------------- /internal/cmd/cmd_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/google/uuid" 9 | "github.com/jzelinskie/cobrautil/v2/cobrazerolog" 10 | "github.com/rs/zerolog" 11 | "github.com/spf13/cobra" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | // note: these tests mess with global variables, so do not run in parallel with other tests. 16 | func TestCommandOutput(t *testing.T) { 17 | cases := []struct { 18 | name string 19 | flagErrorContains string 20 | expectUsageContains string 21 | expectFlagErrorCalled bool 22 | expectStdErrorMsg string 23 | command []string 24 | }{ 25 | { 26 | name: "prints usage on invalid command error", 27 | command: []string{"zed", "madeupcommand"}, 28 | expectFlagErrorCalled: true, 29 | flagErrorContains: "unknown command", 30 | expectUsageContains: "zed [command]", 31 | }, 32 | { 33 | name: "prints usage on invalid flag error", 34 | command: []string{"zed", "version", "--madeupflag"}, 35 | expectFlagErrorCalled: true, 36 | flagErrorContains: "unknown flag: --madeupflag", 37 | expectUsageContains: "zed version [flags]", 38 | }, 39 | { 40 | name: "prints usage on parameter validation error", 41 | command: []string{"zed", "validate"}, 42 | expectFlagErrorCalled: true, 43 | flagErrorContains: "requires at least 1 arg(s), only received 0", 44 | expectUsageContains: "zed validate [flags]", 45 | }, 46 | { 47 | name: "prints correct usage", 48 | command: []string{"zed", "perm", "check"}, 49 | expectFlagErrorCalled: true, 50 | flagErrorContains: "accepts 3 arg(s), received 0", 51 | expectUsageContains: "zed permission check ", 52 | }, 53 | { 54 | name: "does not print usage on command error", 55 | command: []string{"zed", "validate", uuid.NewString()}, 56 | expectFlagErrorCalled: false, 57 | expectStdErrorMsg: "terminated with errors", 58 | }, 59 | } 60 | 61 | for _, tt := range cases { 62 | t.Run(tt.name, func(t *testing.T) { 63 | zl := cobrazerolog.New(cobrazerolog.WithPreRunLevel(zerolog.DebugLevel)) 64 | rootCmd := InitialiseRootCmd(zl) 65 | 66 | var flagErrorCalled bool 67 | testFlagError := func(cmd *cobra.Command, err error) error { 68 | require.ErrorContains(t, err, tt.flagErrorContains) 69 | require.Contains(t, cmd.UsageString(), tt.expectUsageContains) 70 | flagErrorCalled = true 71 | return errParsing 72 | } 73 | stderrFile := setupOutputForTest(t, testFlagError, tt.command...) 74 | 75 | err := handleError(rootCmd, rootCmd.ExecuteContext(t.Context())) 76 | require.Error(t, err) 77 | stdErrBytes, err := os.ReadFile(stderrFile) 78 | require.NoError(t, err) 79 | if tt.expectStdErrorMsg != "" { 80 | require.Contains(t, string(stdErrBytes), tt.expectStdErrorMsg) 81 | } else { 82 | require.Empty(t, stdErrBytes) 83 | } 84 | require.Equal(t, tt.expectFlagErrorCalled, flagErrorCalled) 85 | }) 86 | } 87 | } 88 | 89 | // TestMultipleInitialiseRootCmd is a regression test to ensure that calling 90 | // InitialiseRootCmd multiple times doesn't panic due to flag redefinition. 91 | // This fixes issue #556. 92 | func TestMultipleInitialiseRootCmd(t *testing.T) { 93 | zl := cobrazerolog.New(cobrazerolog.WithPreRunLevel(zerolog.DebugLevel)) 94 | 95 | // Call InitialiseRootCmd multiple times to simulate what happens 96 | // when tests run with -count=10 97 | for range 10 { 98 | rootCmd := InitialiseRootCmd(zl) 99 | require.NotNil(t, rootCmd) 100 | 101 | // Execute the command with invalid args to trigger the command tree 102 | // This ensures all commands and flags are properly initialized 103 | os.Args = []string{"zed", "version", "--invalid-flag"} 104 | err := rootCmd.ExecuteContext(t.Context()) 105 | require.Error(t, err) // We expect an error due to the invalid flag 106 | } 107 | } 108 | 109 | func setupOutputForTest(t *testing.T, testFlagError func(cmd *cobra.Command, err error) error, args ...string) string { 110 | t.Helper() 111 | 112 | originalLevel := zerolog.GlobalLevel() 113 | originalFlagError := flagError 114 | originalArgs := os.Args 115 | originalStderr := os.Stderr 116 | t.Cleanup(func() { 117 | zerolog.SetGlobalLevel(originalLevel) 118 | flagError = originalFlagError 119 | os.Args = originalArgs 120 | os.Stderr = originalStderr 121 | }) 122 | 123 | if len(args) > 0 { 124 | os.Args = args 125 | } 126 | flagError = testFlagError 127 | zerolog.SetGlobalLevel(zerolog.TraceLevel) 128 | tempStdErrFileName := filepath.Join(t.TempDir(), uuid.NewString()) 129 | tempStdErr, err := os.Create(tempStdErrFileName) 130 | require.NoError(t, err) 131 | t.Cleanup(func() { 132 | _ = tempStdErr.Close() 133 | _ = os.Remove(tempStdErrFileName) 134 | }) 135 | 136 | os.Stderr = tempStdErr 137 | return tempStdErrFileName 138 | } 139 | -------------------------------------------------------------------------------- /pkg/backupformat/backupformat_test.go: -------------------------------------------------------------------------------- 1 | package backupformat 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "testing" 7 | 8 | "github.com/brianvoe/gofakeit/v6" 9 | "github.com/stretchr/testify/require" 10 | "google.golang.org/protobuf/types/known/structpb" 11 | 12 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 13 | ) 14 | 15 | func TestWriteAndRead(t *testing.T) { 16 | simpleRel := &v1.Relationship{ 17 | Resource: &v1.ObjectReference{ 18 | ObjectType: gofakeit.Noun(), 19 | ObjectId: gofakeit.UUID(), 20 | }, 21 | Relation: gofakeit.Noun(), 22 | Subject: &v1.SubjectReference{ 23 | Object: &v1.ObjectReference{ 24 | ObjectType: gofakeit.Noun(), 25 | ObjectId: gofakeit.FirstName(), 26 | }, 27 | }, 28 | } 29 | 30 | relWithCaveatName := simpleRel.CloneVT() 31 | relWithCaveatName.OptionalCaveat = &v1.ContextualizedCaveat{ 32 | CaveatName: gofakeit.Noun(), 33 | } 34 | 35 | relWithSimpleContext := relWithCaveatName.CloneVT() 36 | flatContext, err := structpb.NewStruct(map[string]any{ 37 | "nullVal": nil, 38 | "intVal": 123, 39 | "floatVal": 123.45, 40 | "boolVal": true, 41 | }) 42 | require.NoError(t, err) 43 | relWithSimpleContext.OptionalCaveat.Context = flatContext 44 | 45 | relWithNestedContext := relWithCaveatName.CloneVT() 46 | nestedContext, err := structpb.NewStruct(map[string]any{ 47 | "obj1": map[string]any{ 48 | "obj2": map[string]any{ 49 | "obj3": gofakeit.Noun(), 50 | }, 51 | }, 52 | }) 53 | require.NoError(t, err) 54 | relWithNestedContext.OptionalCaveat.Context = nestedContext 55 | 56 | testCases := []struct { 57 | name string 58 | schemaSize int 59 | numRandomRelationships int 60 | extraRelationships []*v1.Relationship 61 | }{ 62 | {"base", 1, 1, nil}, 63 | {"big", 50, 1000, nil}, 64 | {"caveats", 1, 0, []*v1.Relationship{ 65 | relWithCaveatName, 66 | relWithSimpleContext, 67 | relWithNestedContext, 68 | }}, 69 | } 70 | 71 | for _, tc := range testCases { 72 | t.Run(tc.name, func(t *testing.T) { 73 | require := require.New(t) 74 | 75 | fields := make([]gofakeit.Field, tc.schemaSize) 76 | for i := range fields { 77 | fields[i].Name = gofakeit.Noun() 78 | fields[i].Function = "noun" 79 | } 80 | schemaBytes, err := gofakeit.JSON(&gofakeit.JSONOptions{ 81 | Type: "object", 82 | Fields: fields, 83 | }) 84 | require.NoError(err) 85 | 86 | expectedSchema := string(schemaBytes) 87 | expectedZedtoken := base64.StdEncoding.EncodeToString(gofakeit.ImageJpeg(10, 10)) 88 | 89 | expectedRels := make([]*v1.Relationship, 0, tc.numRandomRelationships+len(tc.extraRelationships)) 90 | expectedRels = append(expectedRels, tc.extraRelationships...) 91 | 92 | for i := 0; i < tc.numRandomRelationships; i++ { 93 | expectedRels = append(expectedRels, &v1.Relationship{ 94 | Resource: &v1.ObjectReference{ 95 | ObjectType: gofakeit.Noun(), 96 | ObjectId: gofakeit.UUID(), 97 | }, 98 | Relation: gofakeit.Noun(), 99 | Subject: &v1.SubjectReference{ 100 | Object: &v1.ObjectReference{ 101 | ObjectType: gofakeit.Noun(), 102 | ObjectId: gofakeit.FirstName(), 103 | }, 104 | }, 105 | }) 106 | } 107 | 108 | buf := bytes.Buffer{} 109 | enc := &OcfEncoder{w: &buf} 110 | err = enc.WriteSchema(expectedSchema, expectedZedtoken) 111 | require.NoError(err) 112 | 113 | for _, rel := range expectedRels { 114 | require.NoError(enc.Append(rel, "")) 115 | } 116 | require.NoError(enc.Close()) 117 | require.NotEmpty(buf.Bytes()) 118 | 119 | dec, err := NewDecoder(bytes.NewReader(buf.Bytes())) 120 | require.NoError(err) 121 | 122 | schema, err := dec.Schema() 123 | require.NoError(err) 124 | require.Equal(expectedSchema, schema) 125 | zedtoken, err := dec.ZedToken() 126 | require.NoError(err) 127 | require.Equal(expectedZedtoken, zedtoken.Token) 128 | 129 | for _, expected := range expectedRels { 130 | rel, err := dec.Next() 131 | require.NoError(err) 132 | requireRelationshipEqual(require, expected, rel) 133 | } 134 | 135 | require.NoError(dec.Close()) 136 | }) 137 | } 138 | } 139 | 140 | func requireRelationshipEqual(require *require.Assertions, expected, received *v1.Relationship) { 141 | require.Equal(expected.Resource.ObjectType, received.Resource.ObjectType) 142 | require.Equal(expected.Resource.ObjectId, received.Resource.ObjectId) 143 | require.Equal(expected.Relation, received.Relation) 144 | require.Equal(expected.Subject.Object.ObjectType, received.Subject.Object.ObjectType) 145 | require.Equal(expected.Subject.Object.ObjectId, received.Subject.Object.ObjectId) 146 | require.Equal(expected.Subject.OptionalRelation, received.Subject.OptionalRelation) 147 | 148 | if expected.OptionalCaveat == nil { 149 | require.Nil(received.OptionalCaveat) 150 | } else { 151 | require.Equal(expected.OptionalCaveat.CaveatName, received.OptionalCaveat.CaveatName) 152 | require.Equal(expected.OptionalCaveat.Context.AsMap(), received.OptionalCaveat.Context.AsMap()) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /internal/commands/completion.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 10 | "github.com/authzed/spicedb/pkg/schemadsl/compiler" 11 | 12 | "github.com/authzed/zed/internal/client" 13 | ) 14 | 15 | type CompletionArgumentType int 16 | 17 | const ( 18 | ResourceType CompletionArgumentType = iota 19 | ResourceID 20 | Permission 21 | SubjectType 22 | SubjectID 23 | SubjectTypeWithOptionalRelation 24 | ) 25 | 26 | func FileExtensionCompletions(extension ...string) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 27 | return func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 28 | return extension, cobra.ShellCompDirectiveFilterFileExt 29 | } 30 | } 31 | 32 | func GetArgs(fields ...CompletionArgumentType) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 33 | return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { 34 | // Read the current schema, if any. 35 | schema, err := readSchema(cmd) 36 | if err != nil { 37 | return nil, cobra.ShellCompDirectiveError 38 | } 39 | 40 | // Find the specified resource type, if any. 41 | var resourceType string 42 | loop: 43 | for index, arg := range args { 44 | field := fields[index] 45 | switch field { 46 | case ResourceType: 47 | resourceType = arg 48 | break loop 49 | 50 | case ResourceID: 51 | pieces := strings.Split(arg, ":") 52 | if len(pieces) >= 1 { 53 | resourceType = pieces[0] 54 | break loop 55 | } 56 | } 57 | } 58 | 59 | // Handle : on resource and subject IDs. 60 | if strings.HasSuffix(toComplete, ":") && (fields[len(args)] == ResourceID || fields[len(args)] == SubjectID) { 61 | comps := []string{} 62 | comps = cobra.AppendActiveHelp(comps, "Please enter an object ID") 63 | return comps, cobra.ShellCompDirectiveNoFileComp 64 | } 65 | 66 | // Handle # on subject types. If the toComplete contains a valid subject, 67 | // then we should return the relation names. Note that we cannot do this 68 | // on the # character because shell autocompletion won't send it to us. 69 | if len(args) == len(fields)-1 && toComplete != "" && fields[len(args)] == SubjectTypeWithOptionalRelation { 70 | for _, objDef := range schema.ObjectDefinitions { 71 | subjectType := toComplete 72 | if objDef.Name == subjectType { 73 | relationNames := make([]string, 0) 74 | relationNames = append(relationNames, subjectType) 75 | for _, relation := range objDef.Relation { 76 | relationNames = append(relationNames, subjectType+"#"+relation.Name) 77 | } 78 | return relationNames, cobra.ShellCompDirectiveNoFileComp 79 | } 80 | } 81 | } 82 | 83 | if len(args) >= len(fields) { 84 | // If we have all the arguments, return no completions. 85 | return nil, cobra.ShellCompDirectiveNoFileComp 86 | } 87 | 88 | // Return the completions. 89 | currentFieldType := fields[len(args)] 90 | switch currentFieldType { 91 | case ResourceType: 92 | fallthrough 93 | 94 | case SubjectType: 95 | fallthrough 96 | 97 | case SubjectID: 98 | fallthrough 99 | 100 | case SubjectTypeWithOptionalRelation: 101 | fallthrough 102 | 103 | case ResourceID: 104 | resourceTypeNames := make([]string, 0, len(schema.ObjectDefinitions)) 105 | for _, objDef := range schema.ObjectDefinitions { 106 | resourceTypeNames = append(resourceTypeNames, objDef.Name) 107 | } 108 | 109 | flags := cobra.ShellCompDirectiveNoFileComp 110 | if currentFieldType == ResourceID || currentFieldType == SubjectID || currentFieldType == SubjectTypeWithOptionalRelation { 111 | flags |= cobra.ShellCompDirectiveNoSpace 112 | } 113 | 114 | return resourceTypeNames, flags 115 | 116 | case Permission: 117 | if resourceType == "" { 118 | return nil, cobra.ShellCompDirectiveNoFileComp 119 | } 120 | 121 | relationNames := make([]string, 0) 122 | for _, objDef := range schema.ObjectDefinitions { 123 | if objDef.Name == resourceType { 124 | for _, relation := range objDef.Relation { 125 | relationNames = append(relationNames, relation.Name) 126 | } 127 | } 128 | } 129 | return relationNames, cobra.ShellCompDirectiveNoFileComp 130 | } 131 | 132 | return nil, cobra.ShellCompDirectiveDefault 133 | } 134 | } 135 | 136 | func readSchema(cmd *cobra.Command) (*compiler.CompiledSchema, error) { 137 | // TODO: we should find a way to cache this 138 | client, err := client.NewClient(cmd) 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | request := &v1.ReadSchemaRequest{} 144 | 145 | resp, err := client.ReadSchema(cmd.Context(), request) 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | schemaText := resp.SchemaText 151 | if len(schemaText) == 0 { 152 | return nil, errors.New("no schema defined") 153 | } 154 | 155 | compiledSchema, err := compiler.Compile( 156 | compiler.InputSchema{Source: "schema", SchemaString: schemaText}, 157 | compiler.AllowUnprefixedObjectType(), 158 | compiler.SkipValidation(), 159 | ) 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | return compiledSchema, nil 165 | } 166 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/docs-style-sharing/schema-and-data.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schema: |- 3 | definition user {} 4 | 5 | definition resource { 6 | relation manager: user | usergroup#member | usergroup#manager 7 | relation viewer: user | usergroup#member | usergroup#manager 8 | 9 | permission manage = manager 10 | permission view = viewer + manager 11 | } 12 | 13 | definition usergroup { 14 | relation manager: user | usergroup#member | usergroup#manager 15 | relation direct_member: user | usergroup#member | usergroup#manager 16 | 17 | permission member = direct_member + manager 18 | } 19 | 20 | definition organization { 21 | relation group: usergroup 22 | relation administrator: user | usergroup#member | usergroup#manager 23 | relation direct_member: user 24 | 25 | relation resource: resource 26 | 27 | permission admin = administrator 28 | permission member = direct_member + administrator + group->member 29 | } 30 | 31 | relationships: |- 32 | // Add users to various groups 33 | usergroup:productname#manager@user:an_eng_manager 34 | usergroup:productname#direct_member@user:an_engineer 35 | usergroup:applications#manager@user:an_eng_director 36 | usergroup:engineering#manager@user:cto 37 | usergroup:csuite#manager@user:ceo 38 | usergroup:csuite#direct_member@user:cto 39 | 40 | // Add groups to some other groups. 41 | usergroup:engineering#direct_member@usergroup:applications#member 42 | usergroup:applications#direct_member@usergroup:productname#member 43 | usergroup:engineering#direct_member@usergroup:csuite#member 44 | 45 | // Add the groups under the organization. 46 | organization:org1#group@usergroup:csuite 47 | organization:org1#group@usergroup:productname 48 | organization:org1#group@usergroup:applications 49 | organization:org1#group@usergroup:engineering 50 | 51 | // Add some resources under the organization. 52 | organization:org1#resource@resource:promserver 53 | organization:org1#resource@resource:jira 54 | 55 | // Set a group's members and a user as the administrators of the organization. 56 | organization:org1#administrator@usergroup:csuite#member 57 | organization:org1#administrator@user:it_admin 58 | 59 | // Set the permissions on some resources. 60 | resource:promserver#manager@usergroup:productname#member 61 | resource:promserver#viewer@usergroup:engineering#member 62 | resource:jira#viewer@usergroup:engineering#member 63 | resource:jira#manager@usergroup:engineering#manager 64 | resource:promserver#viewer@user:an_external_user 65 | 66 | usergroup:blackhats#manager@user:a_villain 67 | 68 | validation: 69 | organization:org1#admin: 70 | - "[user:ceo] is " 71 | - "[user:cto] is " 72 | - "[user:it_admin] is " 73 | - "[usergroup:csuite#member] is " 74 | organization:org1#member: 75 | - "[user:an_eng_director] is " 76 | - "[user:an_eng_manager] is " 77 | - "[user:an_engineer] is " 78 | - "[user:ceo] is " 79 | - "[user:cto] is /" 80 | - "[user:it_admin] is " 81 | - "[usergroup:applications#member] is " 82 | - "[usergroup:csuite#member] is /" 83 | - "[usergroup:productname#member] is " 84 | resource:jira#manage: 85 | - "[user:cto] is " 86 | - "[usergroup:engineering#manager] is " 87 | resource:jira#view: 88 | - "[user:an_eng_director] is " 89 | - "[user:an_eng_manager] is " 90 | - "[user:an_engineer] is " 91 | - "[user:ceo] is " 92 | - "[user:cto] is /" 93 | - "[usergroup:applications#member] is " 94 | - "[usergroup:csuite#member] is " 95 | - "[usergroup:engineering#manager] is " 96 | - "[usergroup:engineering#member] is " 97 | - "[usergroup:productname#member] is " 98 | resource:promserver#manage: 99 | - "[user:an_eng_manager] is " 100 | - "[user:an_engineer] is " 101 | - "[usergroup:productname#member] is " 102 | resource:promserver#view: 103 | - "[user:an_eng_director] is " 104 | - "[user:an_eng_manager] is " 105 | - "[user:an_engineer] is " 106 | - "[user:an_external_user] is " 107 | - "[user:ceo] is " 108 | - "[user:cto] is /" 109 | - "[usergroup:applications#member] is " 110 | - "[usergroup:csuite#member] is " 111 | - "[usergroup:engineering#member] is " 112 | - "[usergroup:productname#member] is /" 113 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | builds: 4 | - id: "linux-amd64-gnu" 5 | goos: ["linux"] 6 | goarch: ["amd64"] 7 | env: ["CC=x86_64-unknown-linux-gnu-gcc", "CGO_ENABLED=1"] 8 | main: &main "./cmd/zed" 9 | binary: &binary "zed" 10 | mod_timestamp: &mod_timestamp "{{ .CommitTimestamp }}" 11 | flags: &flags ["-trimpath"] 12 | asmflags: &asmflags ["all=-trimpath={{ .Env.GITHUB_WORKSPACE }}"] 13 | gcflags: &gcflags ["all=-trimpath={{ .Env.GITHUB_WORKSPACE }}"] 14 | ldflags: &ldflags 15 | - "-s -w" 16 | - "-X github.com/jzelinskie/cobrautil/v2.Version=v{{ .Version }}" 17 | - id: "linux-amd64-musl" 18 | goos: ["linux"] 19 | goarch: ["amd64"] 20 | env: ["CC=x86_64-unknown-linux-musl-gcc", "CGO_ENABLED=1"] 21 | main: *main 22 | binary: *binary 23 | mod_timestamp: *mod_timestamp 24 | flags: *flags 25 | asmflags: *asmflags 26 | gcflags: *gcflags 27 | ldflags: *ldflags 28 | - id: "linux-arm64-gnu" 29 | goos: ["linux"] 30 | goarch: ["arm64"] 31 | env: ["CC=aarch64-unknown-linux-gnu-gcc", "CGO_ENABLED=1"] 32 | main: *main 33 | binary: *binary 34 | mod_timestamp: *mod_timestamp 35 | flags: *flags 36 | asmflags: *asmflags 37 | gcflags: *gcflags 38 | ldflags: *ldflags 39 | - id: "linux-arm64-musl" 40 | goos: ["linux"] 41 | goarch: ["arm64"] 42 | env: ["CC=aarch64-unknown-linux-musl-gcc", "CGO_ENABLED=1"] 43 | main: *main 44 | binary: *binary 45 | mod_timestamp: *mod_timestamp 46 | flags: *flags 47 | asmflags: *asmflags 48 | gcflags: *gcflags 49 | ldflags: *ldflags 50 | - id: "windows-amd64" 51 | goos: ["windows"] 52 | goarch: ["amd64"] 53 | env: ["CC=x86_64-w64-mingw32-gcc", "CGO_ENABLED=1"] 54 | main: *main 55 | binary: *binary 56 | mod_timestamp: *mod_timestamp 57 | flags: *flags 58 | asmflags: *asmflags 59 | gcflags: *gcflags 60 | ldflags: *ldflags 61 | - id: "darwin-amd64" 62 | goos: ["darwin"] 63 | goarch: ["amd64"] 64 | env: ["CGO_ENABLED=1"] 65 | main: *main 66 | binary: *binary 67 | mod_timestamp: *mod_timestamp 68 | flags: *flags 69 | asmflags: *asmflags 70 | gcflags: *gcflags 71 | ldflags: *ldflags 72 | - id: "darwin-arm64" 73 | goos: ["darwin"] 74 | goarch: ["arm64"] 75 | env: ["CGO_ENABLED=1"] 76 | main: *main 77 | binary: *binary 78 | mod_timestamp: *mod_timestamp 79 | flags: *flags 80 | asmflags: *asmflags 81 | gcflags: *gcflags 82 | ldflags: *ldflags 83 | 84 | archives: 85 | - id: "gnu" 86 | ids: 87 | - "linux-amd64-gnu" 88 | - "linux-arm64-gnu" 89 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}_gnu" 90 | - id: "musl" 91 | ids: 92 | - "linux-amd64-musl" 93 | - "linux-arm64-musl" 94 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}_musl" 95 | - id: "other" 96 | ids: 97 | - "darwin-amd64" 98 | - "darwin-arm64" 99 | - "windows-amd64" 100 | 101 | nfpms: 102 | - id: "gnu" 103 | vendor: &vendor "authzed inc." 104 | homepage: &homepage "https://authzed.com/" 105 | maintainer: &maintainer "authzed " 106 | description: &description "manage Authzed from your command line." 107 | license: &license "Apache 2.0" 108 | epoch: &epoch "0" 109 | ids: ["linux-amd64-gnu", "linux-arm64-gnu"] 110 | formats: ["deb", "rpm"] 111 | - id: "musl" 112 | vendor: *vendor 113 | homepage: *homepage 114 | maintainer: *maintainer 115 | description: *description 116 | license: *license 117 | epoch: *epoch 118 | ids: ["linux-amd64-musl", "linux-arm64-musl"] 119 | formats: ["apk"] 120 | 121 | furies: 122 | - account: "authzed" 123 | secret_name: "GEMFURY_PUSH_TOKEN" 124 | 125 | brews: 126 | - description: "command-line client for managing SpiceDB" 127 | homepage: "https://github.com/authzed/zed" 128 | license: "Apache-2.0" 129 | dependencies: 130 | - name: "go" 131 | type: "build" 132 | custom_block: | 133 | head "https://github.com/authzed/zed.git", :branch => "main" 134 | url_template: "https://github.com/authzed/zed/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 135 | install: | 136 | if build.head? 137 | versionVar = "github.com/jzelinskie/cobrautil/v2.Version" 138 | versionCmd = "$(git describe --always --abbrev=7 --dirty --tags)" 139 | system "go build --ldflags '-s -w -X #{versionVar}=#{versionCmd}' ./cmd/zed" 140 | end 141 | bin.install "zed" 142 | generate_completions_from_executable(bin/"zed", "completion", shells: [:bash, :zsh, :fish]) 143 | test: | 144 | system "#{bin}/zed version" 145 | repository: 146 | owner: "authzed" 147 | name: "homebrew-tap" 148 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 149 | ids: ["gnu", "other"] 150 | commit_author: 151 | name: "authzedbot" 152 | email: "infrastructure@authzed.com" 153 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 154 | directory: "Formula" 155 | skip_upload: "auto" 156 | 157 | checksum: 158 | name_template: "checksums.txt" 159 | 160 | snapshot: 161 | version_template: "{{ incpatch .Version }}-next" 162 | 163 | changelog: 164 | use: "github-native" 165 | sort: "asc" 166 | 167 | release: 168 | prerelease: "auto" 169 | footer: | 170 | ## Docker Images 171 | This release is available at `authzed/zed:v{{ .Version }}`, `quay.io/authzed/zed:v{{ .Version }}`, `ghcr.io/authzed/zed:v{{ .Version }}` 172 | -------------------------------------------------------------------------------- /internal/storage/config.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | 11 | "github.com/jzelinskie/stringz" 12 | ) 13 | 14 | const configFileName = "config.json" 15 | 16 | // ErrConfigNotFound is returned if there is no Config in a ConfigStore. 17 | var ErrConfigNotFound = errors.New("config did not exist") 18 | 19 | // ErrTokenNotFound is returned if there is no Token in a ConfigStore. 20 | var ErrTokenNotFound = errors.New("token does not exist") 21 | 22 | // Config represents the contents of a zed configuration file. 23 | type Config struct { 24 | Version string 25 | CurrentToken string 26 | } 27 | 28 | // ConfigStore is anything that can persistently store a Config. 29 | type ConfigStore interface { 30 | Get() (Config, error) 31 | Put(Config) error 32 | Exists() (bool, error) 33 | } 34 | 35 | // TokenWithOverride returns a Token that retrieves its values from the reference Token, and has its values overridden 36 | // any of the non-empty/non-nil values of the overrideToken. 37 | func TokenWithOverride(overrideToken Token, referenceToken Token) (Token, error) { 38 | insecure := referenceToken.Insecure 39 | if overrideToken.Insecure != nil { 40 | insecure = overrideToken.Insecure 41 | } 42 | 43 | // done so that logging messages don't show nil for the resulting context 44 | if insecure == nil { 45 | bFalse := false 46 | insecure = &bFalse 47 | } 48 | 49 | noVerifyCA := referenceToken.NoVerifyCA 50 | if overrideToken.NoVerifyCA != nil { 51 | noVerifyCA = overrideToken.NoVerifyCA 52 | } 53 | 54 | // done so that logging messages don't show nil for the resulting context 55 | if noVerifyCA == nil { 56 | bFalse := false 57 | noVerifyCA = &bFalse 58 | } 59 | 60 | caCert := referenceToken.CACert 61 | if overrideToken.CACert != nil { 62 | caCert = overrideToken.CACert 63 | } 64 | 65 | return Token{ 66 | Name: referenceToken.Name, 67 | Endpoint: stringz.DefaultEmpty(overrideToken.Endpoint, referenceToken.Endpoint), 68 | APIToken: stringz.DefaultEmpty(overrideToken.APIToken, referenceToken.APIToken), 69 | Insecure: insecure, 70 | NoVerifyCA: noVerifyCA, 71 | CACert: caCert, 72 | }, nil 73 | } 74 | 75 | // CurrentToken is a convenient way to obtain the CurrentToken field from the 76 | // current Config. 77 | func CurrentToken(cs ConfigStore, ss SecretStore) (token Token, err error) { 78 | cfg, err := cs.Get() 79 | if err != nil { 80 | return Token{}, err 81 | } 82 | 83 | return GetTokenIfExists(cfg.CurrentToken, ss) 84 | } 85 | 86 | // SetCurrentToken is a convenient way to set the CurrentToken field in a 87 | // the current config. 88 | func SetCurrentToken(name string, cs ConfigStore, ss SecretStore) error { 89 | // Ensure the token exists 90 | exists, err := TokenExists(name, ss) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | if !exists { 96 | return ErrTokenNotFound 97 | } 98 | 99 | cfg, err := cs.Get() 100 | if err != nil { 101 | if errors.Is(err, ErrConfigNotFound) { 102 | cfg = Config{Version: "v1"} 103 | } else { 104 | return err 105 | } 106 | } 107 | 108 | cfg.CurrentToken = name 109 | return cs.Put(cfg) 110 | } 111 | 112 | // JSONConfigStore implements a ConfigStore that stores its Config in a JSON file at the provided ConfigPath. 113 | type JSONConfigStore struct { 114 | ConfigPath string 115 | } 116 | 117 | // Enforce that our implementation satisfies the interface. 118 | var _ ConfigStore = JSONConfigStore{} 119 | 120 | // Get parses a Config from the filesystem. 121 | func (s JSONConfigStore) Get() (Config, error) { 122 | cfgBytes, err := os.ReadFile(filepath.Join(s.ConfigPath, configFileName)) 123 | if errors.Is(err, fs.ErrNotExist) { 124 | return Config{}, ErrConfigNotFound 125 | } else if err != nil { 126 | return Config{}, err 127 | } 128 | 129 | var cfg Config 130 | if err := json.Unmarshal(cfgBytes, &cfg); err != nil { 131 | return Config{}, err 132 | } 133 | 134 | return cfg, nil 135 | } 136 | 137 | // Put overwrites a Config on the filesystem. 138 | func (s JSONConfigStore) Put(cfg Config) error { 139 | if err := os.MkdirAll(s.ConfigPath, 0o774); err != nil { 140 | return err 141 | } 142 | 143 | cfgBytes, err := json.Marshal(cfg) 144 | if err != nil { 145 | return err 146 | } 147 | 148 | return atomicWriteFile(filepath.Join(s.ConfigPath, configFileName), cfgBytes, 0o774) 149 | } 150 | 151 | func (s JSONConfigStore) Exists() (bool, error) { 152 | if _, err := os.Stat(filepath.Join(s.ConfigPath, configFileName)); errors.Is(err, fs.ErrNotExist) { 153 | return false, nil 154 | } else if err != nil { 155 | return false, err 156 | } 157 | return true, nil 158 | } 159 | 160 | // atomicWriteFile writes data to filename+some suffix, then renames it into 161 | // filename. 162 | // 163 | // Copyright (c) 2019 Tailscale Inc & AUTHORS All rights reserved. 164 | // Use of this source code is governed by a BSD-style license that can be found 165 | // at the following URL: 166 | // https://github.com/tailscale/tailscale/blob/main/LICENSE 167 | func atomicWriteFile(filename string, data []byte, perm os.FileMode) (err error) { 168 | f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp") 169 | if err != nil { 170 | return err 171 | } 172 | tmpName := f.Name() 173 | defer func() { 174 | if err != nil { 175 | f.Close() 176 | os.Remove(tmpName) 177 | } 178 | }() 179 | if _, err := f.Write(data); err != nil { 180 | return err 181 | } 182 | if runtime.GOOS != "windows" { 183 | if err := f.Chmod(perm); err != nil { 184 | return err 185 | } 186 | } 187 | if err := f.Sync(); err != nil { 188 | return err 189 | } 190 | if err := f.Close(); err != nil { 191 | return err 192 | } 193 | return os.Rename(tmpName, filename) 194 | } 195 | -------------------------------------------------------------------------------- /internal/grpcutil/grpcutil.go: -------------------------------------------------------------------------------- 1 | package grpcutil 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "sync" 8 | "time" 9 | 10 | "github.com/rs/zerolog/log" 11 | "golang.org/x/mod/semver" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/metadata" 14 | 15 | "github.com/authzed/authzed-go/pkg/requestmeta" 16 | "github.com/authzed/authzed-go/pkg/responsemeta" 17 | "github.com/authzed/spicedb/pkg/releases" 18 | ) 19 | 20 | // Compile-time assertion that LogDispatchTrailers and CheckServerVersion implement the 21 | // grpc.UnaryClientInterceptor interface. 22 | var ( 23 | _ grpc.UnaryClientInterceptor = grpc.UnaryClientInterceptor(LogDispatchTrailers) 24 | _ grpc.UnaryClientInterceptor = grpc.UnaryClientInterceptor(CheckServerVersion) 25 | ) 26 | 27 | var once sync.Once 28 | 29 | // CheckServerVersion implements a gRPC unary interceptor that requests the server version 30 | // from SpiceDB and, if found, compares it to the current released version. 31 | func CheckServerVersion( 32 | ctx context.Context, 33 | method string, 34 | req, reply any, 35 | cc *grpc.ClientConn, 36 | invoker grpc.UnaryInvoker, 37 | callOpts ...grpc.CallOption, 38 | ) error { 39 | var headerMD, trailerMD metadata.MD 40 | ctx = requestmeta.AddRequestHeaders(ctx, requestmeta.RequestServerVersion) 41 | err := invoker(ctx, method, req, reply, cc, append(callOpts, grpc.Header(&headerMD), grpc.Trailer(&trailerMD))...) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | once.Do(func() { 47 | versionFromHeader := headerMD.Get(string(responsemeta.ServerVersion)) 48 | versionFromTrailer := trailerMD.Get(string(responsemeta.ServerVersion)) 49 | currentVersion := "" 50 | switch { 51 | case len(versionFromHeader) == 0 && len(versionFromTrailer) == 0: 52 | log.Debug().Msg("error reading server version response header and trailer; it may be disabled on the server") 53 | case len(versionFromHeader) == 1: 54 | currentVersion = versionFromHeader[0] 55 | case len(versionFromTrailer) == 1: 56 | currentVersion = versionFromTrailer[0] 57 | } 58 | 59 | // If there is a build on the version, then do not compare. 60 | if semver.Build(currentVersion) != "" { 61 | log.Debug().Str("this-version", currentVersion).Msg("received build version of SpiceDB") 62 | return 63 | } 64 | 65 | rctx, cancel := context.WithTimeout(ctx, time.Second*2) 66 | defer cancel() 67 | 68 | state, _, release, cerr := releases.CheckIsLatestVersion(rctx, func() (string, error) { 69 | return currentVersion, nil 70 | }, releases.GetLatestRelease) 71 | if cerr != nil { 72 | log.Debug().Err(cerr).Msg("error looking up currently released version") 73 | } else { 74 | switch state { 75 | case releases.UnreleasedVersion: 76 | log.Warn().Str("version", currentVersion).Msg("not calling a released version of SpiceDB") 77 | return 78 | 79 | case releases.UpdateAvailable: 80 | log.Warn().Str("this-version", currentVersion).Str("latest-released-version", release.Version).Msgf("the version of SpiceDB being called is out of date. See: %s", release.ViewURL) 81 | return 82 | 83 | case releases.UpToDate: 84 | log.Debug().Str("latest-released-version", release.Version).Msg("the version of SpiceDB being called is the latest released version") 85 | return 86 | 87 | case releases.Unknown: 88 | log.Warn().Str("unknown-released-version", release.Version).Msg("unable to check for a new SpiceDB version") 89 | return 90 | 91 | default: 92 | panic("Unknown state for CheckAndLogRunE") 93 | } 94 | } 95 | }) 96 | 97 | return nil 98 | } 99 | 100 | // LogDispatchTrailers implements a gRPC unary interceptor that logs the 101 | // dispatch metadata that is present in response trailers from SpiceDB. 102 | func LogDispatchTrailers( 103 | ctx context.Context, 104 | method string, 105 | req, reply any, 106 | cc *grpc.ClientConn, 107 | invoker grpc.UnaryInvoker, 108 | callOpts ...grpc.CallOption, 109 | ) error { 110 | var trailerMD metadata.MD 111 | err := invoker(ctx, method, req, reply, cc, append(callOpts, grpc.Trailer(&trailerMD))...) 112 | outputDispatchTrailers(trailerMD) 113 | return err 114 | } 115 | 116 | func outputDispatchTrailers(trailerMD metadata.MD) { 117 | log.Trace().Interface("trailers", trailerMD).Msg("parsed trailers") 118 | 119 | dispatchCount, trailerErr := responsemeta.GetIntResponseTrailerMetadata( 120 | trailerMD, 121 | responsemeta.DispatchedOperationsCount, 122 | ) 123 | if trailerErr != nil { 124 | log.Debug().Err(trailerErr).Msg("error reading dispatched operations trailer") 125 | } 126 | 127 | cachedCount, trailerErr := responsemeta.GetIntResponseTrailerMetadata( 128 | trailerMD, 129 | responsemeta.CachedOperationsCount, 130 | ) 131 | if trailerErr != nil { 132 | log.Debug().Err(trailerErr).Msg("error reading cached operations trailer") 133 | } 134 | 135 | log.Debug(). 136 | Int("dispatch", dispatchCount). 137 | Int("cached", cachedCount). 138 | Msg("extracted response dispatch metadata") 139 | } 140 | 141 | // StreamLogDispatchTrailers implements a gRPC stream interceptor that logs the 142 | // dispatch metadata that is present in response trailers from SpiceDB. 143 | func StreamLogDispatchTrailers( 144 | ctx context.Context, 145 | desc *grpc.StreamDesc, 146 | cc *grpc.ClientConn, 147 | method string, 148 | streamer grpc.Streamer, 149 | callOpts ...grpc.CallOption, 150 | ) (grpc.ClientStream, error) { 151 | stream, err := streamer(ctx, desc, cc, method, callOpts...) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | return &wrappedStream{stream}, nil 157 | } 158 | 159 | type wrappedStream struct { 160 | grpc.ClientStream 161 | } 162 | 163 | func (w *wrappedStream) RecvMsg(m any) error { 164 | err := w.ClientStream.RecvMsg(m) 165 | if err != nil && errors.Is(err, io.EOF) { 166 | outputDispatchTrailers(w.Trailer()) 167 | } 168 | return err 169 | } 170 | -------------------------------------------------------------------------------- /internal/cmd/import.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/jzelinskie/cobrautil/v2" 11 | "github.com/rs/zerolog/log" 12 | "github.com/spf13/cobra" 13 | 14 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 15 | "github.com/authzed/spicedb/pkg/tuple" 16 | "github.com/authzed/spicedb/pkg/validationfile" 17 | 18 | "github.com/authzed/zed/internal/client" 19 | "github.com/authzed/zed/internal/commands" 20 | "github.com/authzed/zed/internal/decode" 21 | "github.com/authzed/zed/internal/grpcutil" 22 | ) 23 | 24 | func registerImportCmd(rootCmd *cobra.Command) { 25 | importCmd := &cobra.Command{ 26 | Use: "import ", 27 | Short: "Imports schema and relationships from a file or url", 28 | Example: ` 29 | From a gist: 30 | zed import https://gist.github.com/ecordell/8e3b613a677e3c844742cf24421c08b6 31 | 32 | From a playground link: 33 | zed import https://play.authzed.com/s/iksdFvCtvnkR/schema 34 | 35 | From pastebin: 36 | zed import https://pastebin.com/8qU45rVK 37 | 38 | From a devtools instance: 39 | zed import https://localhost:8443/download 40 | 41 | From a local file (with prefix): 42 | zed import file:///Users/zed/Downloads/authzed-x7izWU8_2Gw3.yaml 43 | 44 | From a local file (no prefix): 45 | zed import authzed-x7izWU8_2Gw3.yaml 46 | 47 | Only schema: 48 | zed import --relationships=false file:///Users/zed/Downloads/authzed-x7izWU8_2Gw3.yaml 49 | 50 | Only relationships: 51 | zed import --schema=false file:///Users/zed/Downloads/authzed-x7izWU8_2Gw3.yaml 52 | 53 | With schema definition prefix: 54 | zed import --schema-definition-prefix=mypermsystem file:///Users/zed/Downloads/authzed-x7izWU8_2Gw3.yaml 55 | `, 56 | Args: commands.ValidationWrapper(cobra.ExactArgs(1)), 57 | RunE: importCmdFunc, 58 | } 59 | 60 | rootCmd.AddCommand(importCmd) 61 | importCmd.Flags().Int("batch-size", 1000, "import batch size") 62 | importCmd.Flags().Int("workers", 1, "number of concurrent batching workers") 63 | importCmd.Flags().Bool("schema", true, "import schema") 64 | importCmd.Flags().Bool("relationships", true, "import relationships") 65 | importCmd.Flags().String("schema-definition-prefix", "", "prefix to add to the schema's definition(s) before importing") 66 | } 67 | 68 | func importCmdFunc(cmd *cobra.Command, args []string) error { 69 | client, err := client.NewClient(cmd) 70 | if err != nil { 71 | return err 72 | } 73 | u, err := url.Parse(args[0]) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | decoder, err := decode.DecoderForURL(u) 79 | if err != nil { 80 | return err 81 | } 82 | var p validationfile.ValidationFile 83 | if _, _, err := decoder(&p); err != nil { 84 | return err 85 | } 86 | 87 | prefix, err := determinePrefixForSchema(cmd.Context(), cobrautil.MustGetString(cmd, "schema-definition-prefix"), client, nil) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | if cobrautil.MustGetBool(cmd, "schema") { 93 | if err := importSchema(cmd.Context(), client, p.Schema.Schema, prefix); err != nil { 94 | return err 95 | } 96 | } 97 | 98 | if cobrautil.MustGetBool(cmd, "relationships") { 99 | batchSize := cobrautil.MustGetInt(cmd, "batch-size") 100 | workers := cobrautil.MustGetInt(cmd, "workers") 101 | if err := importRelationships(cmd.Context(), client, p.Relationships.RelationshipsString, prefix, batchSize, workers); err != nil { 102 | return err 103 | } 104 | } 105 | 106 | return err 107 | } 108 | 109 | func importSchema(ctx context.Context, client client.Client, schema string, definitionPrefix string) error { 110 | log.Info().Msg("importing schema") 111 | 112 | // Recompile the schema with the specified prefix. 113 | schemaText, err := rewriteSchema(schema, definitionPrefix) 114 | if err != nil { 115 | return err 116 | } 117 | 118 | // Write the recompiled and regenerated schema. 119 | request := &v1.WriteSchemaRequest{Schema: schemaText} 120 | log.Trace().Interface("request", request).Str("schema", schemaText).Msg("writing schema") 121 | 122 | if _, err := client.WriteSchema(ctx, request); err != nil { 123 | return err 124 | } 125 | 126 | return nil 127 | } 128 | 129 | func importRelationships(ctx context.Context, client client.Client, relationships string, definitionPrefix string, batchSize int, workers int) error { 130 | relationshipUpdates := make([]*v1.RelationshipUpdate, 0) 131 | scanner := bufio.NewScanner(strings.NewReader(relationships)) 132 | for scanner.Scan() { 133 | line := strings.TrimSpace(scanner.Text()) 134 | if line == "" { 135 | continue 136 | } 137 | if strings.HasPrefix(line, "//") { 138 | continue 139 | } 140 | rel, err := tuple.ParseV1Rel(line) 141 | if err != nil { 142 | return fmt.Errorf("failed to parse %s as relationship", line) 143 | } 144 | log.Trace().Str("line", line).Send() 145 | 146 | // Rewrite the prefix on the references, if any. 147 | if len(definitionPrefix) > 0 { 148 | rel.Resource.ObjectType = fmt.Sprintf("%s/%s", definitionPrefix, rel.Resource.ObjectType) 149 | rel.Subject.Object.ObjectType = fmt.Sprintf("%s/%s", definitionPrefix, rel.Subject.Object.ObjectType) 150 | } 151 | 152 | relationshipUpdates = append(relationshipUpdates, &v1.RelationshipUpdate{ 153 | Operation: v1.RelationshipUpdate_OPERATION_TOUCH, 154 | Relationship: rel, 155 | }) 156 | } 157 | if err := scanner.Err(); err != nil { 158 | return err 159 | } 160 | 161 | log.Info(). 162 | Int("batch_size", batchSize). 163 | Int("workers", workers). 164 | Int("count", len(relationshipUpdates)). 165 | Msg("importing relationships") 166 | 167 | err := grpcutil.ConcurrentBatch(ctx, len(relationshipUpdates), batchSize, workers, func(ctx context.Context, no int, start int, end int) error { 168 | request := &v1.WriteRelationshipsRequest{Updates: relationshipUpdates[start:end]} 169 | _, err := client.WriteRelationships(ctx, request) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | log.Info(). 175 | Int("batch_no", no). 176 | Int("count", len(relationshipUpdates[start:end])). 177 | Msg("imported relationships") 178 | return nil 179 | }) 180 | return err 181 | } 182 | -------------------------------------------------------------------------------- /internal/cmd/context.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/jzelinskie/cobrautil/v2" 8 | "github.com/jzelinskie/stringz" 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/authzed/zed/internal/client" 12 | "github.com/authzed/zed/internal/commands" 13 | "github.com/authzed/zed/internal/console" 14 | "github.com/authzed/zed/internal/printers" 15 | "github.com/authzed/zed/internal/storage" 16 | ) 17 | 18 | func registerContextCmd(rootCmd *cobra.Command) { 19 | contextCmd := &cobra.Command{ 20 | Use: "context ", 21 | Short: "Manage configurations for connecting to SpiceDB deployments", 22 | Aliases: []string{"ctx"}, 23 | } 24 | 25 | contextListCmd := &cobra.Command{ 26 | Use: "list", 27 | Short: "Lists all available contexts", 28 | Aliases: []string{"ls"}, 29 | Args: commands.ValidationWrapper(cobra.ExactArgs(0)), 30 | ValidArgsFunction: cobra.NoFileCompletions, 31 | RunE: contextListCmdFunc, 32 | } 33 | 34 | contextSetCmd := &cobra.Command{ 35 | Use: "set ", 36 | Short: "Creates or overwrite a context", 37 | Args: commands.ValidationWrapper(cobra.ExactArgs(3)), 38 | ValidArgsFunction: cobra.NoFileCompletions, 39 | RunE: contextSetCmdFunc, 40 | } 41 | 42 | contextRemoveCmd := &cobra.Command{ 43 | Use: "remove ", 44 | Short: "Removes a context by name", 45 | Aliases: []string{"rm"}, 46 | Args: commands.ValidationWrapper(cobra.ExactArgs(1)), 47 | ValidArgsFunction: ContextGet, 48 | RunE: contextRemoveCmdFunc, 49 | } 50 | 51 | contextUseCmd := &cobra.Command{ 52 | Use: "use ", 53 | Short: "Sets a context as the current context", 54 | Args: commands.ValidationWrapper(cobra.MaximumNArgs(1)), 55 | ValidArgsFunction: ContextGet, 56 | RunE: contextUseCmdFunc, 57 | } 58 | 59 | rootCmd.AddCommand(contextCmd) 60 | contextCmd.AddCommand(contextListCmd) 61 | contextListCmd.Flags().Bool("reveal-tokens", false, "display secrets in results") 62 | contextCmd.AddCommand(contextSetCmd) 63 | contextCmd.AddCommand(contextRemoveCmd) 64 | contextCmd.AddCommand(contextUseCmd) 65 | } 66 | 67 | func ContextGet(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 68 | _, secretStore := client.DefaultStorage() 69 | secrets, err := secretStore.Get() 70 | if err != nil { 71 | return nil, cobra.ShellCompDirectiveError 72 | } 73 | 74 | names := make([]string, 0, len(secrets.Tokens)) 75 | for _, token := range secrets.Tokens { 76 | names = append(names, token.Name) 77 | } 78 | 79 | return names, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveKeepOrder 80 | } 81 | 82 | func contextListCmdFunc(cmd *cobra.Command, _ []string) error { 83 | cfgStore, secretStore := client.DefaultStorage() 84 | secrets, err := secretStore.Get() 85 | if err != nil { 86 | return err 87 | } 88 | 89 | cfg, err := cfgStore.Get() 90 | if err != nil { 91 | return err 92 | } 93 | 94 | rows := make([][]string, 0, len(secrets.Tokens)) 95 | for _, token := range secrets.Tokens { 96 | current := "" 97 | if token.Name == cfg.CurrentToken { 98 | current = " ✓ " 99 | } 100 | secret := token.APIToken 101 | if !cobrautil.MustGetBool(cmd, "reveal-tokens") { 102 | secret = token.Redacted() 103 | } 104 | 105 | var certStr string 106 | if token.IsInsecure() { 107 | certStr = "insecure" 108 | } else if token.HasNoVerifyCA() { 109 | certStr = "no-verify-ca" 110 | } else if _, ok := token.Certificate(); ok { 111 | certStr = "custom" 112 | } else { 113 | certStr = "system" 114 | } 115 | 116 | rows = append(rows, []string{ 117 | current, 118 | token.Name, 119 | token.Endpoint, 120 | secret, 121 | certStr, 122 | }) 123 | } 124 | 125 | printers.PrintTable(os.Stdout, []string{"current", "name", "endpoint", "token", "tls cert"}, rows) 126 | 127 | return nil 128 | } 129 | 130 | func contextSetCmdFunc(cmd *cobra.Command, args []string) error { 131 | var name, endpoint, apiToken string 132 | err := stringz.Unpack(args, &name, &endpoint, &apiToken) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | certPath := cobrautil.MustGetStringExpanded(cmd, "certificate-path") 138 | var certBytes []byte 139 | if certPath != "" { 140 | certBytes, err = os.ReadFile(certPath) 141 | if err != nil { 142 | return fmt.Errorf("failed to read certificate: %w", err) 143 | } 144 | } 145 | 146 | insecure := cobrautil.MustGetBool(cmd, "insecure") 147 | noVerifyCA := cobrautil.MustGetBool(cmd, "no-verify-ca") 148 | cfgStore, secretStore := client.DefaultStorage() 149 | err = storage.PutToken(storage.Token{ 150 | Name: name, 151 | Endpoint: stringz.DefaultEmpty(endpoint, "grpc.authzed.com:443"), 152 | APIToken: apiToken, 153 | Insecure: &insecure, 154 | NoVerifyCA: &noVerifyCA, 155 | CACert: certBytes, 156 | }, secretStore) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | return storage.SetCurrentToken(name, cfgStore, secretStore) 162 | } 163 | 164 | func contextRemoveCmdFunc(_ *cobra.Command, args []string) error { 165 | // If the token is what's currently being used, remove it from the config. 166 | cfgStore, secretStore := client.DefaultStorage() 167 | cfg, err := cfgStore.Get() 168 | if err != nil { 169 | return err 170 | } 171 | 172 | if cfg.CurrentToken == args[0] { 173 | cfg.CurrentToken = "" 174 | } 175 | 176 | err = cfgStore.Put(cfg) 177 | if err != nil { 178 | return err 179 | } 180 | 181 | return storage.RemoveToken(args[0], secretStore) 182 | } 183 | 184 | func contextUseCmdFunc(_ *cobra.Command, args []string) error { 185 | cfgStore, secretStore := client.DefaultStorage() 186 | switch len(args) { 187 | case 0: 188 | cfg, err := cfgStore.Get() 189 | if err != nil { 190 | return err 191 | } 192 | console.Println(cfg.CurrentToken) 193 | case 1: 194 | return storage.SetCurrentToken(args[0], cfgStore, secretStore) 195 | default: 196 | panic("cobra command did not enforce valid number of args") 197 | } 198 | 199 | return nil 200 | } 201 | -------------------------------------------------------------------------------- /internal/schemaexamples/schemas/user-defined-roles/schema-and-data.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | schema: | 3 | definition user {} 4 | 5 | definition project { 6 | relation issue_creator: role#member 7 | relation issue_assigner: role#member 8 | relation any_issue_resolver: role#member 9 | relation assigned_issue_resolver: role#member 10 | relation comment_creator: role#member 11 | relation comment_deleter: role#member 12 | relation role_manager: role#member 13 | 14 | permission create_issue = issue_creator 15 | permission create_role = role_manager 16 | } 17 | 18 | definition role { 19 | relation project: project 20 | relation member: user 21 | relation built_in_role: project 22 | 23 | permission delete = project->role_manager - built_in_role->role_manager 24 | permission add_user = project->role_manager 25 | permission add_permission = project->role_manager - built_in_role->role_manager 26 | permission remove_permission = project->role_manager - built_in_role->role_manager 27 | } 28 | 29 | definition issue { 30 | relation project: project 31 | relation assigned: user 32 | 33 | permission assign = project->issue_assigner 34 | permission resolve = (project->assigned_issue_resolver & assigned) + project->any_issue_resolver 35 | permission create_comment = project->comment_creator 36 | 37 | // synthetic relation 38 | permission project_comment_deleter = project->comment_deleter 39 | } 40 | 41 | definition comment { 42 | relation issue: issue 43 | permission delete = issue->project_comment_deleter 44 | } 45 | relationships: | 46 | issue:move_the_servers#project@project:pied_piper 47 | issue:move_the_servers#assigned@user:gilfoyle 48 | 49 | issue:too_slow#project@project:pied_piper 50 | comment:try_middle_out#issue@issue:too_slow 51 | 52 | role:admin#project@project:pied_piper 53 | role:admin#built_in_role@project:pied_piper 54 | role:developer#project@project:pied_piper 55 | role:developer#built_in_role@project:pied_piper 56 | role:user#project@project:pied_piper 57 | role:user#built_in_role@project:pied_piper 58 | role:project_manager#project@project:pied_piper 59 | role:legal#project@project:pied_piper 60 | 61 | project:pied_piper#issue_creator@role:admin#member 62 | project:pied_piper#issue_creator@role:developer#member 63 | project:pied_piper#issue_creator@role:user#member 64 | 65 | project:pied_piper#issue_assigner@role:admin#member 66 | project:pied_piper#issue_assigner@role:project_manager#member 67 | 68 | project:pied_piper#any_issue_resolver@role:admin#member 69 | project:pied_piper#any_issue_resolver@role:project_manager#member 70 | 71 | project:pied_piper#assigned_issue_resolver@role:admin#member 72 | project:pied_piper#assigned_issue_resolver@role:developer#member 73 | 74 | project:pied_piper#comment_creator@role:admin#member 75 | project:pied_piper#comment_creator@role:developer#member 76 | project:pied_piper#comment_creator@role:user#member 77 | 78 | project:pied_piper#comment_deleter@role:admin#member 79 | project:pied_piper#comment_deleter@role:legal#member 80 | 81 | project:pied_piper#role_manager@role:admin#member 82 | 83 | role:admin#member@user:richard 84 | role:developer#member@user:gilfoyle 85 | role:user#member@user:monica 86 | role:project_manager#member@user:jared 87 | role:legal#member@user:ron 88 | assertions: 89 | assertTrue: [] 90 | assertFalse: [] 91 | validation: 92 | comment:try_middle_out#delete: 93 | - "[role:admin#member] is " 94 | - "[role:legal#member] is " 95 | - "[user:richard] is " 96 | - "[user:ron] is " 97 | issue:move_the_servers#assign: 98 | - "[role:admin#member] is " 99 | - "[role:project_manager#member] is " 100 | - "[user:jared] is " 101 | - "[user:richard] is " 102 | issue:move_the_servers#resolve: 103 | - "[role:admin#member] is " 104 | - "[role:project_manager#member] is " 105 | - "[user:gilfoyle] is /" 106 | - "[user:jared] is " 107 | - "[user:richard] is " 108 | issue:too_slow#assign: 109 | - "[role:admin#member] is " 110 | - "[role:project_manager#member] is " 111 | - "[user:jared] is " 112 | - "[user:richard] is " 113 | issue:too_slow#create_comment: 114 | - "[role:admin#member] is " 115 | - "[role:developer#member] is " 116 | - "[role:user#member] is " 117 | - "[user:gilfoyle] is " 118 | - "[user:monica] is " 119 | - "[user:richard] is " 120 | issue:too_slow#resolve: 121 | - "[role:admin#member] is " 122 | - "[role:project_manager#member] is " 123 | - "[user:jared] is " 124 | - "[user:richard] is " 125 | project:pied_piper#create_issue: 126 | - "[role:admin#member] is " 127 | - "[role:developer#member] is " 128 | - "[role:user#member] is " 129 | - "[user:gilfoyle] is " 130 | - "[user:monica] is " 131 | - "[user:richard] is " 132 | role:admin#add_user: 133 | - "[role:admin#member] is " 134 | - "[user:richard] is " 135 | role:admin#delete: [] 136 | role:project_manager#add_user: 137 | - "[role:admin#member] is " 138 | - "[user:richard] is " 139 | role:project_manager#delete: 140 | - "[role:admin#member] is " 141 | - "[user:richard] is " 142 | -------------------------------------------------------------------------------- /internal/mcp/instructions.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/authzed/zed/internal/schemaexamples" 7 | ) 8 | 9 | const baseInstructions = ` 10 | You are a helpful AI Agent tasked with helping the user develop, test and iterate on SpiceDB schema (*.zed files) and associated *test* relationship tuples. 11 | SpiceDB is a database system designed for managing and querying relationships between data entities, based on the "Relationship Based Access Control" (ReBAC) 12 | paradigm, where a permission is granted if and only if there exists a path of relationships between various relation's and permissions from the resource to the subject (typically, a user). 13 | 14 | You can use the available tools to query, modify, and analyze data stored in SpiceDB and to make permissions API calls, as requested by the user. 15 | 16 | *NEVER* make any changes to the underlying relationships or schema unless *explicitly* asked by, or confirmed by, a user. 17 | *NEVER* respond to arbitrary questions outside of the domains of permissions, SpiceDB, ReBAC and the tools available. If asked 18 | or prompted on a question or topic outside of these domains, politely decline to answer. 19 | 20 | Resources notes: 21 | - When requesting a list of current relationships, use the relationships resource, which has *no filter*: it will return *all* relationships 22 | in the system and it is your job to further filter them. 23 | 24 | Debug trace human readable format: 25 | 26 | ✓ timesheet:1 read_timesheet 27 | └── ✓ engagement:3 read_timesheet 28 | ├── ⨉ engagement:3 supplier_for_attribute 29 | ├── ⨉ engagement:3 manages_attribute 30 | └── ✓ engagement:3 self_attribute 31 | └── ✓ person:2 user 32 | └── user:123 33 | 34 | 35 | ? document:1 view 36 | └── ? document:1 viewer (missing required caveat context: current_weekday) 37 | └── user:123 38 | 39 | 40 | When asked to improve schema, the following loop *MUST* be used: 41 | 1) Identify the specific area of the schema that needs improvement. 42 | 2) Check the current schema. If "not found", the schema does not yet exist, so start a new empty one. 43 | 3) Make the changes to the schema. 44 | 4) Call write schema to write the updated schema. 45 | 5) If necessary, add some test relationships. *If unsure what relationships to add, ask the user.* 46 | 6) Issue one or more CheckPermission calls to validate that the schema works as intended. If the checks do not function correctly, iterate. 47 | 7) Once this operation has completed: 48 | - *explain* your changes to the user 49 | - *concisely* explain the test relationships added and any check permission calls made to validate 50 | 51 | General guidelines: 52 | - When making schema changes, *always* place comments above the changes explaining how the changed schema functions, except 53 | when simply adding new subject types on a relation. 54 | - Always add doc comments for new definitions, caveats, relations and permissions. The doc comments should be *concise* but descriptive. 55 | - Permissions should always be named after verbs: "view", "edit", "can_do_something" 56 | - Relations should always be named after adjectives: "viewer", "editor", "doer_of_things" 57 | - When a name is both a verb and an adjective (e.g. "admin"), put "can_" in front for permissions and use the name for the relation, e.g "can_admin" and "admin" 58 | - When a resource type, subject type or relation/permission is not specified, retrieve the schema first to determine context. 59 | - When reference a relation or permission, the format is *always* "resource_type#relation_or_permission", e.g. "document#edit" or "group#member"; NEVER use "resource_type.relation_or_permission". 60 | - *NEVER* guess why a subject has a particular permission; when in doubt, issue a CheckPermission call to retrieve the trace. 61 | - *ALWAYS* show in a user-visible fashion the changes you make to the schema (as a schemadiff) and relationships. 62 | - Don't reference relations from arrows (i.e. don't do 'somerelation->anotherrelation'). Instead, reference a permission, e.g. 'somerelation->somepermission'. If a permission does not exist, create one, even if it simply aliases the relation, e.g. 'permission can_admin = admin'. 63 | 64 | Relationship formatting: 65 | - Relationships should always be returned in the form: "resource_type:resource_id#relation@subject_type:subject_id". 66 | - If a relationship has an optional subject relation, it is placed at the end: "resource_type:resource_id#relation@subject_type:subject_id#subject_relation". 67 | - If a relationship references a caveat, it is placed after the subject relation: "resource_type:resource_id#relation@subject_type:subject_id[caveatName]". 68 | - If a relationship has caveat context, it is JSON encoded into the caveat: "resource_type:resource_id#relation@subject_type:subject_id[caveatName:{ ... }]". 69 | - If a relationship has expiration, it is placed after the caveat in RFC 3339 format: "resource_type:resource_id#relation@subject_type:subject_id[caveatName:{ ... }][expiration:2024-01-02T12:34:45]". The timezone is *always* UTC. 70 | - Relationships can have expiration without caveats, caveats without expiration, neither, or both. 71 | 72 | Check Permission rules: 73 | - PERMISSIONSHIP_NO_PERMISSION indicates that the subject does *not* have permission 74 | - PERMISSIONSHIP_HAS_PERMISSION indicates that the subject has permission 75 | - PERMISSIONSHIP_CONDITIONAL_PERMISSION indicates that the subject has conditional permission, but some expected caveat context members (which are listed) were missing 76 | - PERMISSIONSHIP_UNKNOWN, when found in a debug trace, indicates that the permission status is mixed: the subject has permission on some resources in the trace and does not one others 77 | ` 78 | 79 | func buildInstructions() (string, error) { 80 | var instructions strings.Builder 81 | instructions.WriteString(baseInstructions) 82 | 83 | examples, err := schemaexamples.ListExampleSchemas() 84 | if err != nil { 85 | return "", err 86 | } 87 | 88 | instructions.WriteString("\n Example schemas you can draw inspiration from include:\n") 89 | for _, example := range examples { 90 | instructions.WriteString("") 91 | instructions.Write(example) 92 | instructions.WriteString("\n") 93 | } 94 | 95 | return instructions.String(), nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/printers/debug.go: -------------------------------------------------------------------------------- 1 | package printers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/gookit/color" 9 | 10 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 11 | "github.com/authzed/spicedb/pkg/tuple" 12 | ) 13 | 14 | // DisplayCheckTrace prints out the check trace found in the given debug message. 15 | func DisplayCheckTrace(checkTrace *v1.CheckDebugTrace, tp *TreePrinter, hasError bool) { 16 | displayCheckTrace(checkTrace, tp, hasError, map[string]struct{}{}) 17 | } 18 | 19 | func displayCheckTrace(checkTrace *v1.CheckDebugTrace, tp *TreePrinter, hasError bool, encountered map[string]struct{}) { 20 | red := color.FgRed.Render 21 | green := color.FgGreen.Render 22 | cyan := color.FgCyan.Render 23 | white := color.FgWhite.Render 24 | faint := color.FgGray.Render 25 | magenta := color.FgMagenta.Render 26 | yellow := color.FgYellow.Render 27 | 28 | orange := color.C256(166).Sprint 29 | purple := color.C256(99).Sprint 30 | lightgreen := color.C256(35).Sprint 31 | caveatColor := color.C256(198).Sprint 32 | 33 | hasPermission := green("✓") 34 | resourceColor := white 35 | permissionColor := color.FgWhite.Render 36 | 37 | switch checkTrace.PermissionType { 38 | case v1.CheckDebugTrace_PERMISSION_TYPE_PERMISSION: 39 | permissionColor = lightgreen 40 | case v1.CheckDebugTrace_PERMISSION_TYPE_RELATION: 41 | permissionColor = orange 42 | } 43 | 44 | switch checkTrace.Result { 45 | case v1.CheckDebugTrace_PERMISSIONSHIP_CONDITIONAL_PERMISSION: 46 | switch checkTrace.CaveatEvaluationInfo.Result { 47 | case v1.CaveatEvalInfo_RESULT_FALSE: 48 | hasPermission = red("⨉") 49 | resourceColor = faint 50 | permissionColor = faint 51 | 52 | case v1.CaveatEvalInfo_RESULT_MISSING_SOME_CONTEXT: 53 | hasPermission = magenta("?") 54 | resourceColor = faint 55 | permissionColor = faint 56 | } 57 | case v1.CheckDebugTrace_PERMISSIONSHIP_NO_PERMISSION: 58 | hasPermission = red("⨉") 59 | resourceColor = faint 60 | permissionColor = faint 61 | case v1.CheckDebugTrace_PERMISSIONSHIP_UNSPECIFIED: 62 | hasPermission = yellow("∵") 63 | } 64 | 65 | additional := "" 66 | if checkTrace.GetWasCachedResult() { 67 | sourceKind := "" 68 | source := checkTrace.Source 69 | if source != "" { 70 | parts := strings.Split(source, ":") 71 | if len(parts) > 0 { 72 | sourceKind = parts[0] 73 | } 74 | } 75 | switch sourceKind { 76 | case "": 77 | additional = cyan(" (cached)") 78 | 79 | case "spicedb": 80 | additional = cyan(" (cached by spicedb)") 81 | 82 | case "materialize": 83 | additional = purple(" (cached by materialize)") 84 | 85 | default: 86 | additional = cyan(fmt.Sprintf(" (cached by %s)", sourceKind)) 87 | } 88 | } else if hasError && isPartOfCycle(checkTrace, map[string]struct{}{}) { 89 | hasPermission = orange("!") 90 | resourceColor = white 91 | } 92 | 93 | isEndOfCycle := false 94 | if hasError { 95 | key := cycleKey(checkTrace) 96 | _, isEndOfCycle = encountered[key] 97 | if isEndOfCycle { 98 | additional = color.C256(166).Sprint(" (cycle)") 99 | } 100 | encountered[key] = struct{}{} 101 | } 102 | 103 | timing := "" 104 | if checkTrace.Duration != nil { 105 | timing = fmt.Sprintf(" (%s)", checkTrace.Duration.AsDuration().String()) 106 | } 107 | 108 | tp = tp.Child( 109 | fmt.Sprintf( 110 | "%s %s:%s %s%s%s", 111 | hasPermission, 112 | resourceColor(checkTrace.Resource.ObjectType), 113 | resourceColor(checkTrace.Resource.ObjectId), 114 | permissionColor(checkTrace.Permission), 115 | additional, 116 | timing, 117 | ), 118 | ) 119 | 120 | if isEndOfCycle { 121 | return 122 | } 123 | 124 | if checkTrace.GetCaveatEvaluationInfo() != nil { 125 | indicator := "" 126 | exprColor := color.FgWhite.Render 127 | switch checkTrace.CaveatEvaluationInfo.Result { 128 | case v1.CaveatEvalInfo_RESULT_FALSE: 129 | indicator = red("⨉") 130 | exprColor = faint 131 | 132 | case v1.CaveatEvalInfo_RESULT_TRUE: 133 | indicator = green("✓") 134 | 135 | case v1.CaveatEvalInfo_RESULT_MISSING_SOME_CONTEXT: 136 | indicator = magenta("?") 137 | } 138 | 139 | white := color.HEXStyle("fff") 140 | white.SetOpts(color.Opts{color.OpItalic}) 141 | 142 | contextMap := checkTrace.CaveatEvaluationInfo.Context.AsMap() 143 | caveatName := checkTrace.CaveatEvaluationInfo.CaveatName 144 | 145 | c := tp.Child(fmt.Sprintf("%s %s %s", indicator, exprColor(checkTrace.CaveatEvaluationInfo.Expression), caveatColor(caveatName))) 146 | if len(contextMap) > 0 { 147 | contextJSON, _ := json.MarshalIndent(contextMap, "", " ") 148 | c.Child(string(contextJSON)) 149 | } else if checkTrace.CaveatEvaluationInfo.Result != v1.CaveatEvalInfo_RESULT_MISSING_SOME_CONTEXT { 150 | c.Child(faint("(no matching context found)")) 151 | } 152 | 153 | if checkTrace.CaveatEvaluationInfo.Result == v1.CaveatEvalInfo_RESULT_MISSING_SOME_CONTEXT { 154 | c.Child("missing context: " + strings.Join(checkTrace.CaveatEvaluationInfo.PartialCaveatInfo.MissingRequiredContext, ", ")) 155 | } 156 | } 157 | 158 | if checkTrace.GetSubProblems() != nil { 159 | for _, subProblem := range checkTrace.GetSubProblems().Traces { 160 | displayCheckTrace(subProblem, tp, hasError, encountered) 161 | } 162 | } else if checkTrace.Result == v1.CheckDebugTrace_PERMISSIONSHIP_HAS_PERMISSION { 163 | tp.Child(purple(fmt.Sprintf("%s:%s %s", checkTrace.Subject.Object.ObjectType, checkTrace.Subject.Object.ObjectId, checkTrace.Subject.OptionalRelation))) 164 | } 165 | } 166 | 167 | func cycleKey(checkTrace *v1.CheckDebugTrace) string { 168 | return fmt.Sprintf("%s#%s", tuple.V1StringObjectRef(checkTrace.Resource), checkTrace.Permission) 169 | } 170 | 171 | func isPartOfCycle(checkTrace *v1.CheckDebugTrace, encountered map[string]struct{}) bool { 172 | if checkTrace.GetSubProblems() == nil { 173 | return false 174 | } 175 | 176 | encounteredCopy := make(map[string]struct{}, len(encountered)) 177 | for k, v := range encountered { 178 | encounteredCopy[k] = v 179 | } 180 | 181 | key := cycleKey(checkTrace) 182 | if _, ok := encounteredCopy[key]; ok { 183 | return true 184 | } 185 | 186 | encounteredCopy[key] = struct{}{} 187 | 188 | for _, subProblem := range checkTrace.GetSubProblems().Traces { 189 | if isPartOfCycle(subProblem, encounteredCopy) { 190 | return true 191 | } 192 | } 193 | 194 | return false 195 | } 196 | -------------------------------------------------------------------------------- /internal/commands/watch_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "reflect" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/require" 12 | "google.golang.org/grpc" 13 | "google.golang.org/grpc/codes" 14 | "google.golang.org/grpc/status" 15 | 16 | v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" 17 | 18 | "github.com/authzed/zed/internal/client" 19 | zedtesting "github.com/authzed/zed/internal/testing" 20 | ) 21 | 22 | func TestParseRelationshipFilter(t *testing.T) { 23 | tcs := []struct { 24 | input string 25 | expected *v1.RelationshipFilter 26 | }{ 27 | { 28 | input: "resourceType:resourceId", 29 | expected: &v1.RelationshipFilter{ 30 | ResourceType: "resourceType", 31 | OptionalResourceId: "resourceId", 32 | }, 33 | }, 34 | { 35 | input: "resourceType:resourceId%", 36 | expected: &v1.RelationshipFilter{ 37 | ResourceType: "resourceType", 38 | OptionalResourceIdPrefix: "resourceId", 39 | }, 40 | }, 41 | { 42 | input: "resourceType:resourceId#relation", 43 | expected: &v1.RelationshipFilter{ 44 | ResourceType: "resourceType", 45 | OptionalResourceId: "resourceId", 46 | OptionalRelation: "relation", 47 | }, 48 | }, 49 | { 50 | input: "resourceType:resourceId#relation@subjectType:subjectId", 51 | expected: &v1.RelationshipFilter{ 52 | ResourceType: "resourceType", 53 | OptionalResourceId: "resourceId", 54 | OptionalRelation: "relation", 55 | OptionalSubjectFilter: &v1.SubjectFilter{ 56 | SubjectType: "subjectType", 57 | OptionalSubjectId: "subjectId", 58 | }, 59 | }, 60 | }, 61 | { 62 | input: "#relation", 63 | expected: &v1.RelationshipFilter{ 64 | OptionalRelation: "relation", 65 | }, 66 | }, 67 | { 68 | input: "resourceType#relation", 69 | expected: &v1.RelationshipFilter{ 70 | ResourceType: "resourceType", 71 | OptionalRelation: "relation", 72 | }, 73 | }, 74 | { 75 | input: ":resourceId#relation", 76 | expected: &v1.RelationshipFilter{ 77 | OptionalResourceId: "resourceId", 78 | OptionalRelation: "relation", 79 | }, 80 | }, 81 | { 82 | input: ":resourceId%#relation", 83 | expected: &v1.RelationshipFilter{ 84 | OptionalResourceIdPrefix: "resourceId", 85 | OptionalRelation: "relation", 86 | }, 87 | }, 88 | { 89 | input: "resourceType:resourceId#relation@subjectType:subjectId#somerel", 90 | expected: &v1.RelationshipFilter{ 91 | ResourceType: "resourceType", 92 | OptionalResourceId: "resourceId", 93 | OptionalRelation: "relation", 94 | OptionalSubjectFilter: &v1.SubjectFilter{ 95 | SubjectType: "subjectType", 96 | OptionalSubjectId: "subjectId", 97 | OptionalRelation: &v1.SubjectFilter_RelationFilter{Relation: "somerel"}, 98 | }, 99 | }, 100 | }, 101 | { 102 | input: "@subjectType:subjectId#somerel", 103 | expected: &v1.RelationshipFilter{ 104 | OptionalSubjectFilter: &v1.SubjectFilter{ 105 | SubjectType: "subjectType", 106 | OptionalSubjectId: "subjectId", 107 | OptionalRelation: &v1.SubjectFilter_RelationFilter{Relation: "somerel"}, 108 | }, 109 | }, 110 | }, 111 | } 112 | 113 | for _, tc := range tcs { 114 | actual, err := parseRelationshipFilter(tc.input) 115 | if err != nil { 116 | t.Errorf("parseRelationshipFilter(%s) returned error: %v", tc.input, err) 117 | } 118 | if !reflect.DeepEqual(actual, tc.expected) { 119 | t.Errorf("parseRelationshipFilter(%s) = %v, expected %v", tc.input, actual, tc.expected) 120 | } 121 | } 122 | } 123 | 124 | type mockWatchClient struct { 125 | client.Client 126 | grpc.ServerStreamingClient[v1.WatchResponse] 127 | callCounter int 128 | } 129 | 130 | var _ v1.WatchServiceClient = (*mockWatchClient)(nil) 131 | 132 | func (m *mockWatchClient) Recv() (*v1.WatchResponse, error) { 133 | update1 := &v1.RelationshipUpdate{ 134 | Operation: v1.RelationshipUpdate_OPERATION_CREATE, 135 | Relationship: &v1.Relationship{ 136 | Resource: &v1.ObjectReference{ 137 | ObjectType: "document", 138 | ObjectId: "object1", 139 | }, 140 | Relation: "viewer", 141 | Subject: &v1.SubjectReference{ 142 | Object: &v1.ObjectReference{ 143 | ObjectType: "user", 144 | ObjectId: "alice", 145 | }, 146 | }, 147 | }, 148 | } 149 | update2 := &v1.RelationshipUpdate{ 150 | Operation: v1.RelationshipUpdate_OPERATION_CREATE, 151 | Relationship: &v1.Relationship{ 152 | Resource: &v1.ObjectReference{ 153 | ObjectType: "document", 154 | ObjectId: "object2", 155 | }, 156 | Relation: "viewer", 157 | Subject: &v1.SubjectReference{ 158 | Object: &v1.ObjectReference{ 159 | ObjectType: "user", 160 | ObjectId: "alice", 161 | }, 162 | }, 163 | }, 164 | } 165 | 166 | response1 := &v1.WatchResponse{ 167 | Updates: []*v1.RelationshipUpdate{update1}, 168 | ChangesThrough: &v1.ZedToken{Token: "revision1"}, 169 | } 170 | response2 := &v1.WatchResponse{ 171 | Updates: []*v1.RelationshipUpdate{update2}, 172 | ChangesThrough: &v1.ZedToken{Token: "revision2"}, 173 | } 174 | 175 | switch m.callCounter { 176 | case 0: 177 | m.callCounter++ 178 | return response1, nil 179 | case 1: 180 | m.callCounter++ 181 | return nil, status.Error(codes.Unavailable, "simulated error") 182 | case 2: 183 | m.callCounter++ 184 | return response2, nil 185 | default: 186 | return nil, io.EOF 187 | } 188 | } 189 | 190 | func (m *mockWatchClient) Watch(_ context.Context, _ *v1.WatchRequest, _ ...grpc.CallOption) (grpc.ServerStreamingClient[v1.WatchResponse], error) { 191 | return m, nil 192 | } 193 | 194 | func TestWatchCmdFunc(t *testing.T) { 195 | cmd := zedtesting.CreateTestCobraCommandWithFlagValue(t) 196 | 197 | ctx, cancel := context.WithCancel(context.Background()) 198 | defer cancel() 199 | cmd.SetContext(ctx) 200 | 201 | watchErr := make(chan error, 1) 202 | 203 | receivedResponses := make([]*v1.WatchResponse, 0) 204 | 205 | var wg sync.WaitGroup 206 | wg.Add(1) 207 | go func() { 208 | defer wg.Done() 209 | watchErr <- watchCmdFuncImpl(cmd, &mockWatchClient{}, func(resp *v1.WatchResponse) { 210 | receivedResponses = append(receivedResponses, resp) 211 | }) 212 | }() 213 | 214 | time.Sleep(1 * time.Second) 215 | 216 | cancel() 217 | 218 | wg.Wait() 219 | 220 | err := <-watchErr 221 | require.ErrorIs(t, err, io.EOF) 222 | 223 | require.Len(t, receivedResponses, 2) 224 | require.Equal(t, "object1", receivedResponses[0].Updates[0].Relationship.Resource.ObjectId) 225 | require.Equal(t, `token:"revision1"`, receivedResponses[0].ChangesThrough.String()) 226 | require.Equal(t, "object2", receivedResponses[1].Updates[0].Relationship.Resource.ObjectId) 227 | require.Equal(t, `token:"revision2"`, receivedResponses[1].ChangesThrough.String()) 228 | } 229 | -------------------------------------------------------------------------------- /pkg/wasm/main.go: -------------------------------------------------------------------------------- 1 | //go:build wasm 2 | // +build wasm 3 | 4 | package main 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | "strconv" 12 | "syscall/js" 13 | 14 | "github.com/gookit/color" 15 | "github.com/rs/zerolog" 16 | "github.com/rs/zerolog/log" 17 | "github.com/spf13/cobra" 18 | "github.com/spf13/pflag" 19 | "google.golang.org/protobuf/encoding/protojson" 20 | 21 | "github.com/authzed/spicedb/pkg/datastore" 22 | "github.com/authzed/spicedb/pkg/development" 23 | core "github.com/authzed/spicedb/pkg/proto/core/v1" 24 | devinterface "github.com/authzed/spicedb/pkg/proto/developer/v1" 25 | "github.com/authzed/spicedb/pkg/schemadsl/compiler" 26 | "github.com/authzed/spicedb/pkg/schemadsl/generator" 27 | 28 | "github.com/authzed/zed/internal/client" 29 | "github.com/authzed/zed/internal/commands" 30 | "github.com/authzed/zed/internal/console" 31 | ) 32 | 33 | // zedCommandResult is the struct JSON serialized to be returned to the caller. 34 | type zedCommandResult struct { 35 | UpdatedContext string `json:"updated_context"` 36 | Output string `json:"output"` 37 | Error string `json:"error"` 38 | } 39 | 40 | func main() { 41 | rootCmd := buildRootCmd() 42 | 43 | // Force color output. 44 | color.ForceColor() 45 | 46 | // Set a local client and logger. 47 | client.NewClient = func(cmd *cobra.Command) (client.Client, error) { 48 | return wasmClient{}, nil 49 | } 50 | 51 | c := make(chan struct{}, 0) 52 | js.Global().Set("runZedCommand", js.FuncOf(func(this js.Value, args []js.Value) any { 53 | if len(args) != 2 { 54 | return fmt.Sprintf("invalid argument count %d; expected 2", len(args)) 55 | } 56 | 57 | stringParams := make([]string, 0) 58 | params := args[1] 59 | length := params.Get("length").Int() 60 | 61 | for i := 0; i < length; i++ { 62 | stringParams = append(stringParams, params.Get(strconv.Itoa(i)).String()) 63 | } 64 | 65 | result := runZedCommand(rootCmd, args[0].String(), stringParams) 66 | marshaled, err := json.Marshal(result) 67 | if err != nil { 68 | return `{"error": "could not marshal result"}` 69 | } 70 | 71 | return string(marshaled) 72 | })) 73 | fmt.Println("zed initialized") 74 | <-c 75 | } 76 | 77 | func buildRootCmd() *cobra.Command { 78 | rootCmd := &cobra.Command{ 79 | Use: "zed", 80 | Short: "SpiceDB CLI, by AuthZed", 81 | Long: "A command-line client for managing SpiceDB clusters.", 82 | } 83 | 84 | // Register shared commands. 85 | commands.RegisterPermissionCmd(rootCmd) 86 | commands.RegisterRelationshipCmd(rootCmd) 87 | commands.RegisterSchemaCmd(rootCmd) 88 | 89 | return rootCmd 90 | } 91 | 92 | // From: https://github.com/golang/debug/pull/8/files 93 | func resetSubCommandFlagValues(root *cobra.Command) { 94 | for _, c := range root.Commands() { 95 | c.Flags().VisitAll(func(f *pflag.Flag) { 96 | if f.Changed { 97 | f.Value.Set(f.DefValue) 98 | f.Changed = false 99 | } 100 | }) 101 | resetSubCommandFlagValues(c) 102 | } 103 | } 104 | 105 | func runZedCommand(rootCmd *cobra.Command, requestContextJSON string, stringParams []string) zedCommandResult { 106 | ctx := rootCmd.Context() 107 | if ctx == nil { 108 | ctx = context.Background() 109 | } 110 | 111 | // Decode the request context. 112 | requestCtx := &devinterface.RequestContext{} 113 | err := protojson.Unmarshal([]byte(requestContextJSON), requestCtx) 114 | if err != nil { 115 | return zedCommandResult{Error: err.Error()} 116 | } 117 | 118 | // Build the new dev context. 119 | devCtx, devErrs, err := development.NewDevContext(ctx, requestCtx) 120 | if err != nil { 121 | return zedCommandResult{Error: err.Error()} 122 | } 123 | if devErrs != nil { 124 | return zedCommandResult{Error: "invalid schema or relationships: " + devErrs.String()} 125 | } 126 | 127 | // Run the V1 API against the dev context. 128 | conn, stop, err := devCtx.RunV1InMemoryService() 129 | defer stop() 130 | if err != nil { 131 | return zedCommandResult{Error: err.Error()} 132 | } 133 | 134 | // Set a NewClient function which constructs a wasmClient which passes 135 | // all API calls to the in-memory V1 connection. 136 | client.NewClient = func(cmd *cobra.Command) (client.Client, error) { 137 | return wasmClient{ 138 | conn: conn, 139 | }, nil 140 | } 141 | 142 | // Override Printf and Logger to collect the output. 143 | var buf bytes.Buffer 144 | console.Printf = func(format string, a ...any) { 145 | fmt.Fprintf(&buf, format, a...) 146 | } 147 | console.Print = func(a ...any) { 148 | fmt.Fprint(&buf, a...) 149 | } 150 | 151 | log.Logger = zerolog.New(&buf).With().Bool("is-log", true).Timestamp().Logger() 152 | 153 | // Set the input arguments. 154 | resetSubCommandFlagValues(rootCmd) // See: https://github.com/spf13/cobra/issues/1488 155 | rootCmd.SetArgs(stringParams) 156 | rootCmd.SetOut(&buf) 157 | rootCmd.SetErr(&buf) 158 | 159 | // Execute the command. 160 | err = rootCmd.Execute() 161 | if err != nil { 162 | return zedCommandResult{Error: err.Error()} 163 | } 164 | 165 | // Collect the updated schema and relationships. 166 | headRev, err := devCtx.Datastore.HeadRevision(ctx) 167 | if err != nil { 168 | return zedCommandResult{Error: err.Error()} 169 | } 170 | 171 | reader := devCtx.Datastore.SnapshotReader(headRev) 172 | relationships := []*core.RelationTuple{} 173 | 174 | nsDefs, err := reader.ListAllNamespaces(ctx) 175 | if err != nil { 176 | return zedCommandResult{Error: err.Error()} 177 | } 178 | 179 | for _, nsDef := range nsDefs { 180 | it, err := reader.QueryRelationships(ctx, datastore.RelationshipsFilter{OptionalResourceType: nsDef.Definition.Name}) 181 | if err != nil { 182 | return zedCommandResult{Error: err.Error()} 183 | } 184 | for rel, err := range it { 185 | if err != nil { 186 | return zedCommandResult{Error: err.Error()} 187 | } 188 | relationships = append(relationships, rel.ToCoreTuple()) 189 | } 190 | } 191 | 192 | caveatDefs, err := reader.ListAllCaveats(ctx) 193 | if err != nil { 194 | return zedCommandResult{Error: err.Error()} 195 | } 196 | 197 | schemaDefinitions := make([]compiler.SchemaDefinition, 0, len(nsDefs)+len(caveatDefs)) 198 | for _, caveatDef := range caveatDefs { 199 | schemaDefinitions = append(schemaDefinitions, caveatDef.Definition) 200 | } 201 | 202 | for _, nsDef := range nsDefs { 203 | schemaDefinitions = append(schemaDefinitions, nsDef.Definition) 204 | } 205 | 206 | schemaText, _, err := generator.GenerateSchema(schemaDefinitions) 207 | if err != nil { 208 | return zedCommandResult{Error: err.Error()} 209 | } 210 | 211 | // Build the updated request context. 212 | updatedRequestCtx := &devinterface.RequestContext{ 213 | Schema: schemaText, 214 | Relationships: relationships, 215 | } 216 | 217 | encodedUpdatedContext, err := protojson.Marshal(updatedRequestCtx) 218 | if err != nil { 219 | return zedCommandResult{Error: err.Error()} 220 | } 221 | 222 | return zedCommandResult{UpdatedContext: string(encodedUpdatedContext), Output: buf.String()} 223 | } 224 | -------------------------------------------------------------------------------- /internal/cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/jzelinskie/cobrautil/v2" 13 | "github.com/jzelinskie/cobrautil/v2/cobrazerolog" 14 | "github.com/mattn/go-isatty" 15 | "github.com/rs/zerolog" 16 | "github.com/rs/zerolog/log" 17 | "github.com/spf13/cobra" 18 | 19 | "github.com/authzed/zed/internal/commands" 20 | ) 21 | 22 | var ( 23 | SyncFlagsCmdFunc = cobrautil.SyncViperPreRunE("ZED") 24 | errParsing = errors.New("parsing error") 25 | ) 26 | 27 | func init() { 28 | // NOTE: this is mostly to set up logging in the case where 29 | // the command doesn't exist or the construction of the command 30 | // errors out before the PersistentPreRunE setup in the below function. 31 | // It helps keep log output visually consistent for a user even in 32 | // exceptional cases. 33 | var output io.Writer 34 | 35 | if isatty.IsTerminal(os.Stdout.Fd()) { 36 | output = zerolog.ConsoleWriter{Out: os.Stderr} 37 | } else { 38 | output = os.Stderr 39 | } 40 | 41 | l := zerolog.New(output).With().Timestamp().Logger() 42 | 43 | log.Logger = l 44 | } 45 | 46 | var flagError = flagErrorFunc 47 | 48 | func flagErrorFunc(cmd *cobra.Command, err error) error { 49 | cmd.Println(err) 50 | cmd.Println(cmd.UsageString()) 51 | return errParsing 52 | } 53 | 54 | // InitialiseRootCmd This function is utilised to generate docs for zed 55 | func InitialiseRootCmd(zl *cobrazerolog.Builder) *cobra.Command { 56 | rootCmd := &cobra.Command{ 57 | Use: "zed", 58 | Short: "SpiceDB CLI, built by AuthZed", 59 | Long: "A command-line client for managing SpiceDB clusters.", 60 | Example: ` 61 | zed context list 62 | zed context set dev localhost:80 testpresharedkey --insecure 63 | zed context set prod grpc.authzed.com:443 tc_zed_my_laptop_deadbeefdeadbeefdeadbeefdeadbeef 64 | zed context use dev 65 | zed permission check --explain document:firstdoc writer user:emilia 66 | `, 67 | PersistentPreRunE: cobrautil.CommandStack( 68 | zl.RunE(), 69 | SyncFlagsCmdFunc, 70 | commands.InjectRequestID, 71 | ), 72 | SilenceErrors: true, 73 | SilenceUsage: true, 74 | } 75 | rootCmd.SetFlagErrorFunc(func(command *cobra.Command, err error) error { 76 | return flagError(command, err) 77 | }) 78 | 79 | zl.RegisterFlags(rootCmd.PersistentFlags()) 80 | 81 | rootCmd.PersistentFlags().String("endpoint", "", "spicedb gRPC API endpoint") 82 | rootCmd.PersistentFlags().String("permissions-system", "", "permissions system to query") 83 | rootCmd.PersistentFlags().String("hostname-override", "", "override the hostname used in the connection to the endpoint") 84 | rootCmd.PersistentFlags().String("token", "", "token used to authenticate to SpiceDB") 85 | rootCmd.PersistentFlags().String("certificate-path", "", "path to certificate authority used to verify secure connections") 86 | rootCmd.PersistentFlags().Bool("insecure", false, "connect over a plaintext connection") 87 | rootCmd.PersistentFlags().Bool("skip-version-check", false, "if true, no version check is performed against the server") 88 | rootCmd.PersistentFlags().Bool("no-verify-ca", false, "do not attempt to verify the server's certificate chain and host name") 89 | rootCmd.PersistentFlags().Bool("debug", false, "enable debug logging") 90 | rootCmd.PersistentFlags().String("request-id", "", "optional id to send along with SpiceDB requests for tracing") 91 | rootCmd.PersistentFlags().Int("max-message-size", 0, "maximum size *in bytes* (defaults to 4_194_304 bytes ~= 4MB) of a gRPC message that can be sent or received by zed") 92 | rootCmd.PersistentFlags().String("proxy", "", "specify a SOCKS5 proxy address") 93 | rootCmd.PersistentFlags().Uint("max-retries", 10, "maximum number of sequential retries to attempt when a request fails") 94 | _ = rootCmd.PersistentFlags().MarkHidden("debug") // This cannot return its error. 95 | 96 | versionCmd := &cobra.Command{ 97 | Use: "version", 98 | Short: "Display zed and SpiceDB version information", 99 | RunE: versionCmdFunc, 100 | } 101 | cobrautil.RegisterVersionFlags(versionCmd.Flags()) 102 | versionCmd.Flags().Bool("include-remote-version", true, "whether to display the version of Authzed or SpiceDB for the current context") 103 | rootCmd.AddCommand(versionCmd) 104 | 105 | // Register root-level aliases 106 | rootCmd.AddCommand(&cobra.Command{ 107 | Use: "use ", 108 | Short: "Alias for `zed context use`", 109 | Args: commands.ValidationWrapper(cobra.MaximumNArgs(1)), 110 | RunE: contextUseCmdFunc, 111 | ValidArgsFunction: ContextGet, 112 | }) 113 | 114 | // Register CLI-only commands. 115 | registerContextCmd(rootCmd) 116 | registerImportCmd(rootCmd) 117 | registerValidateCmd(rootCmd) 118 | registerBackupCmd(rootCmd) 119 | registerMCPCmd(rootCmd) 120 | 121 | // Register shared commands. 122 | commands.RegisterPermissionCmd(rootCmd) 123 | 124 | relCmd := commands.RegisterRelationshipCmd(rootCmd) 125 | 126 | commands.RegisterWatchCmd(rootCmd) 127 | commands.RegisterWatchRelationshipCmd(relCmd) 128 | 129 | schemaCmd := commands.RegisterSchemaCmd(rootCmd) 130 | schemaCompileCmd := registerAdditionalSchemaCmds(schemaCmd) 131 | registerPreviewCmd(rootCmd, schemaCompileCmd) 132 | 133 | return rootCmd 134 | } 135 | 136 | func Run() { 137 | if err := runWithoutExit(); err != nil { 138 | os.Exit(1) 139 | } 140 | } 141 | 142 | func runWithoutExit() error { 143 | zl := cobrazerolog.New(cobrazerolog.WithPreRunLevel(zerolog.DebugLevel)) 144 | 145 | rootCmd := InitialiseRootCmd(zl) 146 | 147 | ctx, cancel := context.WithCancel(context.Background()) 148 | defer cancel() 149 | 150 | signalChan := make(chan os.Signal, 2) 151 | signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) 152 | defer func() { 153 | signal.Stop(signalChan) 154 | cancel() 155 | }() 156 | 157 | go func() { 158 | select { 159 | case <-signalChan: 160 | cancel() 161 | case <-ctx.Done(): 162 | } 163 | }() 164 | 165 | return handleError(rootCmd, rootCmd.ExecuteContext(ctx)) 166 | } 167 | 168 | func handleError(command *cobra.Command, err error) error { 169 | if err == nil { 170 | return nil 171 | } 172 | // this snippet of code is taken from Command.ExecuteC in order to determine the command that was ultimately 173 | // parsed. This is necessary to be able to print the proper command-specific usage 174 | var findErr error 175 | var cmdToExecute *cobra.Command 176 | args := os.Args[1:] 177 | if command.TraverseChildren { 178 | cmdToExecute, _, findErr = command.Traverse(args) 179 | } else { 180 | cmdToExecute, _, findErr = command.Find(args) 181 | } 182 | if findErr != nil { 183 | cmdToExecute = command 184 | } 185 | 186 | switch { 187 | case errors.Is(err, commands.ValidationError{}): 188 | _ = flagError(cmdToExecute, err) 189 | case err != nil && strings.Contains(err.Error(), "unknown command"): 190 | _ = flagError(cmdToExecute, err) 191 | case !errors.Is(err, errParsing): 192 | log.Err(err).Msg("terminated with errors") 193 | } 194 | 195 | return err 196 | } 197 | --------------------------------------------------------------------------------