diff --git a/.gitignore b/.gitignore index 103b37087..189ba2c22 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ node_modules pkg/development/wasm/main.wasm pkg/development/wasm/play.wasm +/permify diff --git a/Makefile b/Makefile index aabf1f021..2dc6ee3c9 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,10 @@ test: ### run tests and gather coverage integration-test: ### run integration-test go clean -testcache && go test -v ./integration-test/... +.PHONY: vet +vet: ## Run go vet + go vet ./... + .PHONY: build build: ## Build/compile the Permify service go build -o ./permify ./cmd/permify diff --git a/cmd/permify/permify.go b/cmd/permify/permify.go index 4dfd520b0..980f039a9 100644 --- a/cmd/permify/permify.go +++ b/cmd/permify/permify.go @@ -51,6 +51,12 @@ func main() { repair := cmd.NewRepairCommand() root.AddCommand(repair) + // Remote management (gRPC client) commands + root.AddCommand(cmd.NewCheckCommand()) + root.AddCommand(cmd.NewSchemaCommand()) + root.AddCommand(cmd.NewDataCommand()) + root.AddCommand(cmd.NewTenantCommand()) + if err := root.Execute(); err != nil { os.Exit(1) } diff --git a/pkg/cmd/check.go b/pkg/cmd/check.go new file mode 100644 index 000000000..60cbf97ad --- /dev/null +++ b/pkg/cmd/check.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +// NewCheckCommand runs a permission Check against a remote Permify gRPC server. +func NewCheckCommand() *cobra.Command { + var ( + credentialsPath string + tenantID string + subjectStr string + resourceStr string + permission string + ) + + cmd := &cobra.Command{ + Use: "check", + Short: "Check whether a subject has a permission on a resource", + Long: `Calls the Permify Permission.Check RPC. + +Subject is --entity (e.g. user:1). Resource is --resource (e.g. document:1).`, + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(tenantID) == "" { + return fmt.Errorf("--tenant-id is required") + } + if strings.TrimSpace(subjectStr) == "" { + return fmt.Errorf("--entity is required") + } + if strings.TrimSpace(resourceStr) == "" { + return fmt.Errorf("--resource is required") + } + if strings.TrimSpace(permission) == "" { + return fmt.Errorf("--permission is required") + } + + subject, err := ParseSubjectRef(subjectStr) + if err != nil { + return fmt.Errorf("parse subject: %w", err) + } + entity, err := ParseEntityRef(resourceStr) + if err != nil { + return fmt.Errorf("parse resource: %w", err) + } + + conn, err := DialGRPC(credentialsPath) + if err != nil { + return fmt.Errorf("connect to permify: %w", err) + } + defer func() { _ = conn.Close() }() + + rpcCtx, cancel := newGRPCCallContext(cmd.Context()) + defer cancel() + + client := basev1.NewPermissionClient(conn) + return runPermissionCheck(rpcCtx, os.Stdout, client, tenantID, entity, subject, permission) + }, + } + + fs := cmd.Flags() + fs.StringVar(&credentialsPath, "credentials", "", "path to gRPC credentials file (default: $HOME/.permify/credentials)") + fs.StringVar(&tenantID, "tenant-id", "", "tenant identifier (required)") + fs.StringVar(&subjectStr, "entity", "", "subject as type:id (e.g. user:1)") + fs.StringVar(&resourceStr, "resource", "", "resource entity as type:id (e.g. document:1)") + fs.StringVar(&permission, "permission", "", "permission name to evaluate (e.g. view)") + _ = cmd.MarkFlagRequired("tenant-id") + _ = cmd.MarkFlagRequired("entity") + _ = cmd.MarkFlagRequired("resource") + _ = cmd.MarkFlagRequired("permission") + + return cmd +} + +type permissionCheckClient interface { + Check(ctx context.Context, in *basev1.PermissionCheckRequest, opts ...grpc.CallOption) (*basev1.PermissionCheckResponse, error) +} + +func runPermissionCheck( + ctx context.Context, + w io.Writer, + client permissionCheckClient, + tenantID string, + entity *basev1.Entity, + subject *basev1.Subject, + permission string, +) error { + req := &basev1.PermissionCheckRequest{ + TenantId: tenantID, + Metadata: &basev1.PermissionCheckRequestMetadata{}, + Entity: entity, + Subject: subject, + Permission: permission, + } + + resp, err := client.Check(ctx, req) + if err != nil { + return GRPCStatusError(err) + } + formatCheckResult(w, resp) + return nil +} diff --git a/pkg/cmd/check_test.go b/pkg/cmd/check_test.go new file mode 100644 index 000000000..9c8183fd8 --- /dev/null +++ b/pkg/cmd/check_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +func TestRunPermissionCheck_Allowed(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubPermissionClient{ + checkFn: func(_ context.Context, in *basev1.PermissionCheckRequest, _ ...grpc.CallOption) (*basev1.PermissionCheckResponse, error) { + assert.Equal(t, "t1", in.GetTenantId()) + assert.Equal(t, "view", in.GetPermission()) + return &basev1.PermissionCheckResponse{Can: basev1.CheckResult_CHECK_RESULT_ALLOWED}, nil + }, + } + ent := &basev1.Entity{Type: "document", Id: "1"} + sub := &basev1.Subject{Type: "user", Id: "1"} + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runPermissionCheck(rpcCtx, &buf, stub, "t1", ent, sub, "view") + require.NoError(t, err) + assert.Contains(t, buf.String(), "allowed") +} + +func TestRunPermissionCheck_Denied(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubPermissionClient{ + checkFn: func(_ context.Context, _ *basev1.PermissionCheckRequest, _ ...grpc.CallOption) (*basev1.PermissionCheckResponse, error) { + return &basev1.PermissionCheckResponse{Can: basev1.CheckResult_CHECK_RESULT_DENIED}, nil + }, + } + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runPermissionCheck(rpcCtx, &buf, stub, "t1", + &basev1.Entity{Type: "document", Id: "1"}, + &basev1.Subject{Type: "user", Id: "1"}, "edit") + require.NoError(t, err) + assert.Contains(t, buf.String(), "denied") +} + +func TestRunPermissionCheck_RPCError(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubPermissionClient{ + checkFn: func(_ context.Context, _ *basev1.PermissionCheckRequest, _ ...grpc.CallOption) (*basev1.PermissionCheckResponse, error) { + return nil, status.Errorf(codes.FailedPrecondition, "schema missing") + }, + } + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runPermissionCheck(rpcCtx, &buf, stub, "t1", + &basev1.Entity{Type: "document", Id: "1"}, + &basev1.Subject{Type: "user", Id: "1"}, "view") + require.Error(t, err) + assert.Contains(t, err.Error(), "schema missing") +} + +func TestNewCheckCommand_RequiredFlags(t *testing.T) { + t.Parallel() + cmd := NewCheckCommand() + cmd.SetArgs([]string{}) + cmd.SetOut(bytes.NewBuffer(nil)) + cmd.SetErr(bytes.NewBuffer(nil)) + err := cmd.Execute() + require.Error(t, err) +} diff --git a/pkg/cmd/credentials.go b/pkg/cmd/credentials.go new file mode 100644 index 000000000..f59744fb5 --- /dev/null +++ b/pkg/cmd/credentials.go @@ -0,0 +1,140 @@ +package cmd + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "path/filepath" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "gopkg.in/yaml.v3" +) + +// CredentialsFile is the YAML format stored at ~/.permify/credentials (endpoint, optional api_token, tls_ca_path). +type CredentialsFile struct { + Endpoint string `yaml:"endpoint"` + APIToken string `yaml:"api_token"` + TLSCAPath string `yaml:"tls_ca_path"` +} + +// DefaultCredentialsPath returns $HOME/.permify/credentials. +func DefaultCredentialsPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".permify", "credentials"), nil +} + +// ResolveCredentialsPath returns flagPath if set, otherwise DefaultCredentialsPath. +func ResolveCredentialsPath(flagPath string) (string, error) { + if strings.TrimSpace(flagPath) != "" { + abs, err := filepath.Abs(flagPath) + if err != nil { + return "", fmt.Errorf("resolve credentials path: %w", err) + } + return abs, nil + } + return DefaultCredentialsPath() +} + +// LoadCredentials reads and parses a credentials YAML file. +func LoadCredentials(path string) (*CredentialsFile, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("credentials file not found at %q; create it with endpoint (and optional api_token, tls_ca_path): %w", path, err) + } + return nil, fmt.Errorf("read credentials file: %w", err) + } + + var c CredentialsFile + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parse credentials YAML: %w", err) + } + + c.Endpoint = strings.TrimSpace(c.Endpoint) + c.APIToken = strings.TrimSpace(c.APIToken) + c.TLSCAPath = strings.TrimSpace(c.TLSCAPath) + if c.TLSCAPath != "" && !filepath.IsAbs(c.TLSCAPath) { + c.TLSCAPath = filepath.Join(filepath.Dir(path), c.TLSCAPath) + } + + if c.Endpoint == "" { + return nil, fmt.Errorf("credentials file %q: endpoint is required", path) + } + + return &c, nil +} + +type bearerTokenCreds struct { + token string +} + +func (b bearerTokenCreds) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{"authorization": "Bearer " + b.token}, nil +} + +func (b bearerTokenCreds) RequireTransportSecurity() bool { + return true +} + +// GRPCDialOptions builds dial options: TLS + bearer token when api_token is set, otherwise insecure. +func GRPCDialOptions(c *CredentialsFile) ([]grpc.DialOption, error) { + token := strings.TrimSpace(c.APIToken) + if token == "" { + return []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, nil + } + + var tlsCfg *tls.Config + if c.TLSCAPath != "" { + pemData, err := os.ReadFile(c.TLSCAPath) + if err != nil { + return nil, fmt.Errorf("read tls_ca_path: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemData) { + return nil, fmt.Errorf("tls_ca_path: no PEM certificates found") + } + tlsCfg = &tls.Config{ + RootCAs: pool, + MinVersion: tls.VersionTLS12, + } + } else { + tlsCfg = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + } + + tc := credentials.NewTLS(tlsCfg) + return []grpc.DialOption{ + grpc.WithTransportCredentials(tc), + grpc.WithPerRPCCredentials(bearerTokenCreds{token: token}), + }, nil +} + +// DialGRPC opens a client connection using a credentials file path. +func DialGRPC(credPath string) (*grpc.ClientConn, error) { + path, err := ResolveCredentialsPath(credPath) + if err != nil { + return nil, fmt.Errorf("resolve credentials path: %w", err) + } + creds, err := LoadCredentials(path) + if err != nil { + return nil, fmt.Errorf("load credentials: %w", err) + } + opts, err := GRPCDialOptions(creds) + if err != nil { + return nil, fmt.Errorf("gRPC dial options: %w", err) + } + conn, err := grpc.NewClient(creds.Endpoint, opts...) + if err != nil { + return nil, fmt.Errorf("dial gRPC %q: %w", creds.Endpoint, err) + } + return conn, nil +} diff --git a/pkg/cmd/credentials_test.go b/pkg/cmd/credentials_test.go new file mode 100644 index 000000000..54e6ec181 --- /dev/null +++ b/pkg/cmd/credentials_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadCredentials_MissingFile(t *testing.T) { + t.Parallel() + _, err := LoadCredentials(filepath.Join(t.TempDir(), "nope")) + require.Error(t, err) + assert.Contains(t, err.Error(), "credentials file not found") + assert.True(t, errors.Is(err, os.ErrNotExist)) +} + +func TestLoadCredentials_Valid(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + err := os.WriteFile(path, []byte("endpoint: localhost:3478\n"), 0o600) + require.NoError(t, err) + + c, err := LoadCredentials(path) + require.NoError(t, err) + assert.Equal(t, "localhost:3478", c.Endpoint) +} + +func TestLoadCredentials_RelativeTLSCAPath(t *testing.T) { + t.Parallel() + dir := t.TempDir() + credPath := filepath.Join(dir, "credentials") + err := os.WriteFile(credPath, []byte("endpoint: localhost:3478\ntls_ca_path: myca.pem\n"), 0o600) + require.NoError(t, err) + + c, err := LoadCredentials(credPath) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "myca.pem"), c.TLSCAPath) +} + +func TestLoadCredentials_MissingEndpoint(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "credentials") + err := os.WriteFile(path, []byte("api_token: x\n"), 0o600) + require.NoError(t, err) + + _, err = LoadCredentials(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "endpoint is required") +} + +func TestGRPCDialOptions_InsecureWithoutToken(t *testing.T) { + t.Parallel() + opts, err := GRPCDialOptions(&CredentialsFile{Endpoint: "x", APIToken: ""}) + require.NoError(t, err) + assert.Len(t, opts, 1) +} + +func TestGRPCDialOptions_TLSWithToken(t *testing.T) { + t.Parallel() + opts, err := GRPCDialOptions(&CredentialsFile{Endpoint: "x", APIToken: "secret"}) + require.NoError(t, err) + assert.Len(t, opts, 2) +} + +func TestGRPCDialOptions_InvalidCA(t *testing.T) { + t.Parallel() + dir := t.TempDir() + caPath := filepath.Join(dir, "ca.pem") + err := os.WriteFile(caPath, []byte("not pem"), 0o600) + require.NoError(t, err) + + _, err = GRPCDialOptions(&CredentialsFile{APIToken: "t", TLSCAPath: caPath}) + require.Error(t, err) +} diff --git a/pkg/cmd/data.go b/pkg/cmd/data.go new file mode 100644 index 000000000..be019ed4d --- /dev/null +++ b/pkg/cmd/data.go @@ -0,0 +1,268 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + "gopkg.in/yaml.v3" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +// NewDataCommand groups data RPCs (write, read). +func NewDataCommand() *cobra.Command { + root := &cobra.Command{ + Use: "data", + Short: "Write or read relationship data on a remote Permify server", + } + root.AddCommand(newDataWriteCommand()) + root.AddCommand(newDataReadCommand()) + return root +} + +type dataYAMLDoc struct { + Metadata struct { + SchemaVersion string `yaml:"schema_version"` + } `yaml:"metadata"` + Tuples []dataYAMLTuple `yaml:"tuples"` +} + +type dataYAMLTuple struct { + Entity dataYAMLRef `yaml:"entity"` + Relation string `yaml:"relation"` + Subject dataYAMLSubject `yaml:"subject"` +} + +type dataYAMLRef struct { + Type string `yaml:"type"` + ID string `yaml:"id"` +} + +type dataYAMLSubject struct { + Type string `yaml:"type"` + ID string `yaml:"id"` + Relation string `yaml:"relation"` +} + +func newDataWriteCommand() *cobra.Command { + var credentialsPath, tenantID, filePath string + + cmd := &cobra.Command{ + Use: "write", + Short: "Write tuples from a YAML file (Data.Write)", + Long: `YAML format: + +metadata: + schema_version: "" # optional; empty uses latest schema +tuples: + - entity: {type: organization, id: "1"} + relation: admin + subject: {type: user, id: "3"}`, + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(tenantID) == "" { + return fmt.Errorf("--tenant-id is required") + } + if strings.TrimSpace(filePath) == "" { + return fmt.Errorf("--file is required") + } + raw, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("read data file: %w", err) + } + + tuples, meta, err := parseDataYAML(raw) + if err != nil { + return fmt.Errorf("parse data YAML: %w", err) + } + + conn, err := DialGRPC(credentialsPath) + if err != nil { + return fmt.Errorf("connect to permify: %w", err) + } + defer func() { _ = conn.Close() }() + + rpcCtx, cancel := newGRPCCallContext(cmd.Context()) + defer cancel() + + client := basev1.NewDataClient(conn) + return runDataWrite(rpcCtx, os.Stdout, client, tenantID, meta, tuples) + }, + } + + fs := cmd.Flags() + fs.StringVar(&credentialsPath, "credentials", "", "path to gRPC credentials file (default: $HOME/.permify/credentials)") + fs.StringVar(&tenantID, "tenant-id", "", "tenant identifier (required)") + fs.StringVar(&filePath, "file", "", "path to YAML data file") + _ = cmd.MarkFlagRequired("tenant-id") + _ = cmd.MarkFlagRequired("file") + return cmd +} + +func parseDataYAML(raw []byte) ([]*basev1.Tuple, *basev1.DataWriteRequestMetadata, error) { + var doc dataYAMLDoc + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, nil, fmt.Errorf("parse data YAML: %w", err) + } + if len(doc.Tuples) == 0 { + return nil, nil, fmt.Errorf("data YAML: at least one tuple is required") + } + meta := &basev1.DataWriteRequestMetadata{ + SchemaVersion: strings.TrimSpace(doc.Metadata.SchemaVersion), + } + var out []*basev1.Tuple + for i, row := range doc.Tuples { + entType := strings.TrimSpace(row.Entity.Type) + entID := strings.TrimSpace(row.Entity.ID) + rel := strings.TrimSpace(row.Relation) + subType := strings.TrimSpace(row.Subject.Type) + subID := strings.TrimSpace(row.Subject.ID) + subRel := strings.TrimSpace(row.Subject.Relation) + n := i + 1 + if entType == "" || entID == "" { + return nil, nil, fmt.Errorf("tuple %d: entity type and id are required", n) + } + if subType == "" || subID == "" { + return nil, nil, fmt.Errorf("tuple %d: subject type and id are required", n) + } + if rel == "" { + return nil, nil, fmt.Errorf("tuple %d: relation is required", n) + } + out = append(out, &basev1.Tuple{ + Entity: &basev1.Entity{ + Type: entType, + Id: entID, + }, + Relation: rel, + Subject: &basev1.Subject{ + Type: subType, + Id: subID, + Relation: subRel, + }, + }) + } + return out, meta, nil +} + +func newDataReadCommand() *cobra.Command { + var ( + credentialsPath string + tenantID string + entityStr string + pageSize uint32 + ) + + cmd := &cobra.Command{ + Use: "read", + Short: "Read relationships for an entity (Data.ReadRelationships)", + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(tenantID) == "" { + return fmt.Errorf("--tenant-id is required") + } + if strings.TrimSpace(entityStr) == "" { + return fmt.Errorf("--entity is required") + } + ent, err := ParseEntityRef(entityStr) + if err != nil { + return fmt.Errorf("parse entity: %w", err) + } + if pageSize == 0 { + pageSize = 100 + } + if pageSize > 100 { + return fmt.Errorf("--page-size must be between 1 and 100") + } + + conn, err := DialGRPC(credentialsPath) + if err != nil { + return fmt.Errorf("connect to permify: %w", err) + } + defer func() { _ = conn.Close() }() + + rpcCtx, cancel := newGRPCCallContext(cmd.Context()) + defer cancel() + + client := basev1.NewDataClient(conn) + return runDataReadRelationships(rpcCtx, os.Stdout, client, tenantID, ent, pageSize) + }, + } + + fs := cmd.Flags() + fs.StringVar(&credentialsPath, "credentials", "", "path to gRPC credentials file (default: $HOME/.permify/credentials)") + fs.StringVar(&tenantID, "tenant-id", "", "tenant identifier (required)") + fs.StringVar(&entityStr, "entity", "", "entity filter as type:id (e.g. document:1)") + fs.Uint32Var(&pageSize, "page-size", 100, "maximum tuples to return (1–100)") + _ = cmd.MarkFlagRequired("tenant-id") + _ = cmd.MarkFlagRequired("entity") + return cmd +} + +type dataWriteClient interface { + Write(ctx context.Context, in *basev1.DataWriteRequest, opts ...grpc.CallOption) (*basev1.DataWriteResponse, error) +} + +type dataReadClient interface { + ReadRelationships(ctx context.Context, in *basev1.RelationshipReadRequest, opts ...grpc.CallOption) (*basev1.RelationshipReadResponse, error) +} + +func runDataWrite( + ctx context.Context, + w io.Writer, + client dataWriteClient, + tenantID string, + meta *basev1.DataWriteRequestMetadata, + tuples []*basev1.Tuple, +) error { + resp, err := client.Write(ctx, &basev1.DataWriteRequest{ + TenantId: tenantID, + Metadata: meta, + Tuples: tuples, + }) + if err != nil { + return GRPCStatusError(err) + } + _, _ = fmt.Fprintf(w, "Write succeeded.\nSnap token: %s\n", resp.GetSnapToken()) + return nil +} + +func runDataReadRelationships( + ctx context.Context, + w io.Writer, + client dataReadClient, + tenantID string, + entity *basev1.Entity, + pageSize uint32, +) error { + req := &basev1.RelationshipReadRequest{ + TenantId: tenantID, + Metadata: &basev1.RelationshipReadRequestMetadata{}, + Filter: &basev1.TupleFilter{ + Entity: &basev1.EntityFilter{ + Type: entity.GetType(), + Ids: []string{entity.GetId()}, + }, + }, + PageSize: pageSize, + } + + resp, err := client.ReadRelationships(ctx, req) + if err != nil { + return GRPCStatusError(err) + } + if len(resp.GetTuples()) == 0 { + _, _ = fmt.Fprintln(w, "No relationships found.") + return nil + } + _, _ = fmt.Fprintf(w, "Relationships (%d):\n", len(resp.GetTuples())) + for _, t := range resp.GetTuples() { + _, _ = fmt.Fprintln(w, " ", formatTupleLine(t)) + } + if tok := resp.GetContinuousToken(); tok != "" { + _, _ = fmt.Fprintf(w, "More results available (continuous_token present). Re-run with pagination when supported.\n") + } + return nil +} diff --git a/pkg/cmd/data_test.go b/pkg/cmd/data_test.go new file mode 100644 index 000000000..c8fec6d6f --- /dev/null +++ b/pkg/cmd/data_test.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +func TestParseDataYAML(t *testing.T) { + t.Parallel() + raw := []byte(` +metadata: + schema_version: "" +tuples: + - entity: {type: organization, id: "1"} + relation: admin + subject: {type: user, id: "3"} +`) + tuples, meta, err := parseDataYAML(raw) + require.NoError(t, err) + require.Len(t, tuples, 1) + assert.Equal(t, "organization", tuples[0].GetEntity().GetType()) + assert.Equal(t, "admin", tuples[0].GetRelation()) + require.NotNil(t, meta) +} + +func TestParseDataYAML_NoTuples(t *testing.T) { + t.Parallel() + _, _, err := parseDataYAML([]byte(`tuples: []`)) + require.Error(t, err) +} + +func TestRunDataWrite(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubDataClient{ + writeFn: func(_ context.Context, in *basev1.DataWriteRequest, _ ...grpc.CallOption) (*basev1.DataWriteResponse, error) { + assert.Equal(t, "t1", in.GetTenantId()) + assert.Len(t, in.GetTuples(), 1) + return &basev1.DataWriteResponse{SnapToken: "snap-1"}, nil + }, + } + tuples := []*basev1.Tuple{ + { + Entity: &basev1.Entity{Type: "document", Id: "1"}, + Relation: "viewer", + Subject: &basev1.Subject{Type: "user", Id: "1"}, + }, + } + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runDataWrite(rpcCtx, &buf, stub, "t1", &basev1.DataWriteRequestMetadata{}, tuples) + require.NoError(t, err) + assert.Contains(t, buf.String(), "snap-1") +} + +func TestRunDataReadRelationships(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubDataClient{ + readRelFn: func(_ context.Context, in *basev1.RelationshipReadRequest, _ ...grpc.CallOption) (*basev1.RelationshipReadResponse, error) { + assert.Equal(t, "document", in.GetFilter().GetEntity().GetType()) + return &basev1.RelationshipReadResponse{ + Tuples: []*basev1.Tuple{ + { + Entity: &basev1.Entity{Type: "document", Id: "1"}, + Relation: "viewer", + Subject: &basev1.Subject{Type: "user", Id: "2"}, + }, + }, + }, nil + }, + } + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runDataReadRelationships(rpcCtx, &buf, stub, "t1", &basev1.Entity{Type: "document", Id: "1"}, 10) + require.NoError(t, err) + assert.Contains(t, buf.String(), "document:1") +} + +func TestNewDataWriteCommand_RequiredFlags(t *testing.T) { + t.Parallel() + cmd := newDataWriteCommand() + cmd.SetArgs([]string{}) + cmd.SetOut(bytes.NewBuffer(nil)) + cmd.SetErr(bytes.NewBuffer(nil)) + require.Error(t, cmd.Execute()) +} diff --git a/pkg/cmd/grpc_context.go b/pkg/cmd/grpc_context.go new file mode 100644 index 000000000..083aed0b7 --- /dev/null +++ b/pkg/cmd/grpc_context.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "context" + "time" +) + +const defaultGRPCTimeout = 30 * time.Second + +// newGRPCCallContext returns a context canceled after defaultGRPCTimeout or when parent is done. +func newGRPCCallContext(parent context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(parent, defaultGRPCTimeout) +} diff --git a/pkg/cmd/grpc_mocks_test.go b/pkg/cmd/grpc_mocks_test.go new file mode 100644 index 000000000..c45080a90 --- /dev/null +++ b/pkg/cmd/grpc_mocks_test.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +type stubPermissionClient struct { + checkFn func(context.Context, *basev1.PermissionCheckRequest, ...grpc.CallOption) (*basev1.PermissionCheckResponse, error) +} + +func (s *stubPermissionClient) Check(ctx context.Context, in *basev1.PermissionCheckRequest, opts ...grpc.CallOption) (*basev1.PermissionCheckResponse, error) { + if s.checkFn != nil { + return s.checkFn(ctx, in, opts...) + } + return nil, status.Errorf(codes.Unimplemented, "Check") +} + +func (s *stubPermissionClient) BulkCheck(context.Context, *basev1.PermissionBulkCheckRequest, ...grpc.CallOption) (*basev1.PermissionBulkCheckResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "BulkCheck") +} + +func (s *stubPermissionClient) Expand(context.Context, *basev1.PermissionExpandRequest, ...grpc.CallOption) (*basev1.PermissionExpandResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "Expand") +} + +func (s *stubPermissionClient) LookupEntity(context.Context, *basev1.PermissionLookupEntityRequest, ...grpc.CallOption) (*basev1.PermissionLookupEntityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "LookupEntity") +} + +func (s *stubPermissionClient) LookupEntityStream(context.Context, *basev1.PermissionLookupEntityRequest, ...grpc.CallOption) (grpc.ServerStreamingClient[basev1.PermissionLookupEntityStreamResponse], error) { + return nil, status.Errorf(codes.Unimplemented, "LookupEntityStream") +} + +func (s *stubPermissionClient) LookupSubject(context.Context, *basev1.PermissionLookupSubjectRequest, ...grpc.CallOption) (*basev1.PermissionLookupSubjectResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "LookupSubject") +} + +func (s *stubPermissionClient) SubjectPermission(context.Context, *basev1.PermissionSubjectPermissionRequest, ...grpc.CallOption) (*basev1.PermissionSubjectPermissionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "SubjectPermission") +} + +type stubSchemaClient struct { + writeFn func(context.Context, *basev1.SchemaWriteRequest, ...grpc.CallOption) (*basev1.SchemaWriteResponse, error) + readFn func(context.Context, *basev1.SchemaReadRequest, ...grpc.CallOption) (*basev1.SchemaReadResponse, error) +} + +func (s *stubSchemaClient) Write(ctx context.Context, in *basev1.SchemaWriteRequest, opts ...grpc.CallOption) (*basev1.SchemaWriteResponse, error) { + if s.writeFn != nil { + return s.writeFn(ctx, in, opts...) + } + return nil, status.Errorf(codes.Unimplemented, "Write") +} + +func (s *stubSchemaClient) Read(ctx context.Context, in *basev1.SchemaReadRequest, opts ...grpc.CallOption) (*basev1.SchemaReadResponse, error) { + if s.readFn != nil { + return s.readFn(ctx, in, opts...) + } + return nil, status.Errorf(codes.Unimplemented, "Read") +} + +func (s *stubSchemaClient) PartialWrite(context.Context, *basev1.SchemaPartialWriteRequest, ...grpc.CallOption) (*basev1.SchemaPartialWriteResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "PartialWrite") +} + +func (s *stubSchemaClient) List(context.Context, *basev1.SchemaListRequest, ...grpc.CallOption) (*basev1.SchemaListResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "List") +} + +type stubDataClient struct { + writeFn func(context.Context, *basev1.DataWriteRequest, ...grpc.CallOption) (*basev1.DataWriteResponse, error) + readRelFn func(context.Context, *basev1.RelationshipReadRequest, ...grpc.CallOption) (*basev1.RelationshipReadResponse, error) +} + +func (s *stubDataClient) Write(ctx context.Context, in *basev1.DataWriteRequest, opts ...grpc.CallOption) (*basev1.DataWriteResponse, error) { + if s.writeFn != nil { + return s.writeFn(ctx, in, opts...) + } + return nil, status.Errorf(codes.Unimplemented, "Write") +} + +func (s *stubDataClient) ReadRelationships(ctx context.Context, in *basev1.RelationshipReadRequest, opts ...grpc.CallOption) (*basev1.RelationshipReadResponse, error) { + if s.readRelFn != nil { + return s.readRelFn(ctx, in, opts...) + } + return nil, status.Errorf(codes.Unimplemented, "ReadRelationships") +} + +func (s *stubDataClient) WriteRelationships(context.Context, *basev1.RelationshipWriteRequest, ...grpc.CallOption) (*basev1.RelationshipWriteResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "WriteRelationships") +} + +func (s *stubDataClient) ReadAttributes(context.Context, *basev1.AttributeReadRequest, ...grpc.CallOption) (*basev1.AttributeReadResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "ReadAttributes") +} + +func (s *stubDataClient) Delete(context.Context, *basev1.DataDeleteRequest, ...grpc.CallOption) (*basev1.DataDeleteResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "Delete") +} + +func (s *stubDataClient) DeleteRelationships(context.Context, *basev1.RelationshipDeleteRequest, ...grpc.CallOption) (*basev1.RelationshipDeleteResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "DeleteRelationships") +} + +func (s *stubDataClient) RunBundle(context.Context, *basev1.BundleRunRequest, ...grpc.CallOption) (*basev1.BundleRunResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "RunBundle") +} + +type stubTenancyClient struct { + createFn func(context.Context, *basev1.TenantCreateRequest, ...grpc.CallOption) (*basev1.TenantCreateResponse, error) + listFn func(context.Context, *basev1.TenantListRequest, ...grpc.CallOption) (*basev1.TenantListResponse, error) + deleteFn func(context.Context, *basev1.TenantDeleteRequest, ...grpc.CallOption) (*basev1.TenantDeleteResponse, error) +} + +func (s *stubTenancyClient) Create(ctx context.Context, in *basev1.TenantCreateRequest, opts ...grpc.CallOption) (*basev1.TenantCreateResponse, error) { + if s.createFn != nil { + return s.createFn(ctx, in, opts...) + } + return nil, status.Errorf(codes.Unimplemented, "Create") +} + +func (s *stubTenancyClient) List(ctx context.Context, in *basev1.TenantListRequest, opts ...grpc.CallOption) (*basev1.TenantListResponse, error) { + if s.listFn != nil { + return s.listFn(ctx, in, opts...) + } + return nil, status.Errorf(codes.Unimplemented, "List") +} + +func (s *stubTenancyClient) Delete(ctx context.Context, in *basev1.TenantDeleteRequest, opts ...grpc.CallOption) (*basev1.TenantDeleteResponse, error) { + if s.deleteFn != nil { + return s.deleteFn(ctx, in, opts...) + } + return nil, status.Errorf(codes.Unimplemented, "Delete") +} + +// Compile-time checks: stubs must satisfy the full gRPC client interfaces. +var ( + _ basev1.PermissionClient = (*stubPermissionClient)(nil) + _ basev1.SchemaClient = (*stubSchemaClient)(nil) + _ basev1.DataClient = (*stubDataClient)(nil) + _ basev1.TenancyClient = (*stubTenancyClient)(nil) +) diff --git a/pkg/cmd/grpc_output.go b/pkg/cmd/grpc_output.go new file mode 100644 index 000000000..672fdbca8 --- /dev/null +++ b/pkg/cmd/grpc_output.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "fmt" + "io" + "sort" + "strings" + + "google.golang.org/grpc/status" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +// GRPCStatusError wraps gRPC status errors with context for callers and logging. +func GRPCStatusError(err error) error { + if s, ok := status.FromError(err); ok { + return fmt.Errorf("rpc: %s: %w", s.Message(), err) + } + return err +} + +func formatCheckResult(w io.Writer, res *basev1.PermissionCheckResponse) { + switch res.GetCan() { + case basev1.CheckResult_CHECK_RESULT_ALLOWED: + _, _ = fmt.Fprintln(w, "Result: allowed") + case basev1.CheckResult_CHECK_RESULT_DENIED: + _, _ = fmt.Fprintln(w, "Result: denied") + default: + _, _ = fmt.Fprintln(w, "Result: unspecified") + } + if m := res.GetMetadata(); m != nil && m.GetCheckCount() > 0 { + _, _ = fmt.Fprintf(w, "Check count: %d\n", m.GetCheckCount()) + } +} + +func formatSchemaSummary(w io.Writer, schema *basev1.SchemaDefinition) { + if schema == nil { + _, _ = fmt.Fprintln(w, "(empty schema)") + return + } + var entities []string + for name := range schema.GetEntityDefinitions() { + entities = append(entities, name) + } + sort.Strings(entities) + if len(entities) == 0 { + _, _ = fmt.Fprintln(w, "No entity definitions.") + return + } + _, _ = fmt.Fprintf(w, "Entities (%d):\n", len(entities)) + for _, name := range entities { + def := schema.GetEntityDefinitions()[name] + _, _ = fmt.Fprintf(w, " • %s\n", name) + if def == nil { + continue + } + var rels []string + for r := range def.GetRelations() { + rels = append(rels, r) + } + sort.Strings(rels) + if len(rels) > 0 { + _, _ = fmt.Fprintf(w, " relations: %s\n", strings.Join(rels, ", ")) + } + var perms []string + for p := range def.GetPermissions() { + perms = append(perms, p) + } + sort.Strings(perms) + if len(perms) > 0 { + _, _ = fmt.Fprintf(w, " permissions: %s\n", strings.Join(perms, ", ")) + } + } + var rules []string + for name := range schema.GetRuleDefinitions() { + rules = append(rules, name) + } + sort.Strings(rules) + if len(rules) > 0 { + _, _ = fmt.Fprintf(w, "Rules (%d): %s\n", len(rules), strings.Join(rules, ", ")) + } +} + +func formatTupleLine(t *basev1.Tuple) string { + if t == nil { + return "" + } + subj := t.GetSubject() + ent := t.GetEntity() + subjStr := "?" + if subj != nil { + subjStr = subj.GetType() + ":" + subj.GetId() + if r := subj.GetRelation(); r != "" { + subjStr += "#" + r + } + } + entStr := "?" + if ent != nil { + entStr = ent.GetType() + ":" + ent.GetId() + } + return fmt.Sprintf("%s#%s@%s", entStr, t.GetRelation(), subjStr) +} diff --git a/pkg/cmd/grpc_output_test.go b/pkg/cmd/grpc_output_test.go new file mode 100644 index 000000000..cd4c2b3eb --- /dev/null +++ b/pkg/cmd/grpc_output_test.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestGRPCStatusError(t *testing.T) { + t.Parallel() + err := GRPCStatusError(status.Errorf(codes.InvalidArgument, "bad request")) + require.Error(t, err) + assert.Contains(t, err.Error(), "bad request") +} + +func TestGRPCStatusError_NonGRPC(t *testing.T) { + t.Parallel() + err := GRPCStatusError(assert.AnError) + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) +} diff --git a/pkg/cmd/parse_refs.go b/pkg/cmd/parse_refs.go new file mode 100644 index 000000000..3b9bf1e05 --- /dev/null +++ b/pkg/cmd/parse_refs.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + "strings" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +// ParseEntityRef parses "type:id" into a base Entity. +func ParseEntityRef(s string) (*basev1.Entity, error) { + s = strings.TrimSpace(s) + typ, id, err := splitTypeID(s) + if err != nil { + return nil, fmt.Errorf("entity %q: %w", s, err) + } + return &basev1.Entity{Type: typ, Id: id}, nil +} + +// ParseSubjectRef parses "type:id" into a base Subject (optional relation is not used by this helper). +func ParseSubjectRef(s string) (*basev1.Subject, error) { + s = strings.TrimSpace(s) + typ, id, err := splitTypeID(s) + if err != nil { + return nil, fmt.Errorf("subject %q: %w", s, err) + } + return &basev1.Subject{Type: typ, Id: id}, nil +} + +func splitTypeID(s string) (typ, id string, err error) { + if s == "" { + return "", "", fmt.Errorf("expected non-empty type:id") + } + i := strings.Index(s, ":") + if i <= 0 || i == len(s)-1 { + return "", "", fmt.Errorf("expected type:id (e.g. user:1)") + } + if strings.Contains(s[i+1:], ":") { + return "", "", fmt.Errorf("expected exactly one colon in type:id (e.g. user:1), got: %s", s) + } + return s[:i], s[i+1:], nil +} diff --git a/pkg/cmd/parse_refs_test.go b/pkg/cmd/parse_refs_test.go new file mode 100644 index 000000000..7698ac15c --- /dev/null +++ b/pkg/cmd/parse_refs_test.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseEntityRef(t *testing.T) { + t.Parallel() + e, err := ParseEntityRef("document:1") + require.NoError(t, err) + assert.Equal(t, "document", e.GetType()) + assert.Equal(t, "1", e.GetId()) +} + +func TestParseEntityRef_Invalid(t *testing.T) { + t.Parallel() + _, err := ParseEntityRef("nocolon") + require.Error(t, err) +} + +func TestParseEntityRef_MultipleColons(t *testing.T) { + t.Parallel() + _, err := ParseEntityRef("document:1:extra") + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one colon") +} + +func TestParseSubjectRef(t *testing.T) { + t.Parallel() + s, err := ParseSubjectRef("user:alice") + require.NoError(t, err) + assert.Equal(t, "user", s.GetType()) + assert.Equal(t, "alice", s.GetId()) +} diff --git a/pkg/cmd/schema.go b/pkg/cmd/schema.go new file mode 100644 index 000000000..c9d7fdd92 --- /dev/null +++ b/pkg/cmd/schema.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +// NewSchemaCommand groups schema RPCs (write, read). +func NewSchemaCommand() *cobra.Command { + root := &cobra.Command{ + Use: "schema", + Short: "Read or write authorization schema on a remote Permify server", + } + + root.AddCommand(newSchemaWriteCommand()) + root.AddCommand(newSchemaReadCommand()) + return root +} + +func newSchemaWriteCommand() *cobra.Command { + var credentialsPath, tenantID, filePath string + + cmd := &cobra.Command{ + Use: "write", + Short: "Write schema from a file (Schema.Write)", + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(tenantID) == "" { + return fmt.Errorf("--tenant-id is required") + } + if strings.TrimSpace(filePath) == "" { + return fmt.Errorf("--file is required") + } + schemaBytes, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("read schema file: %w", err) + } + + conn, err := DialGRPC(credentialsPath) + if err != nil { + return fmt.Errorf("connect to permify: %w", err) + } + defer func() { _ = conn.Close() }() + + rpcCtx, cancel := newGRPCCallContext(cmd.Context()) + defer cancel() + + client := basev1.NewSchemaClient(conn) + return runSchemaWrite(rpcCtx, os.Stdout, client, tenantID, string(schemaBytes)) + }, + } + + fs := cmd.Flags() + fs.StringVar(&credentialsPath, "credentials", "", "path to gRPC credentials file (default: $HOME/.permify/credentials)") + fs.StringVar(&tenantID, "tenant-id", "", "tenant identifier (required)") + fs.StringVar(&filePath, "file", "", "path to Permify schema file (.perm)") + _ = cmd.MarkFlagRequired("tenant-id") + _ = cmd.MarkFlagRequired("file") + return cmd +} + +func newSchemaReadCommand() *cobra.Command { + var credentialsPath, tenantID, schemaVersion string + + cmd := &cobra.Command{ + Use: "read", + Short: "Read the latest (or pinned) schema (Schema.Read)", + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(tenantID) == "" { + return fmt.Errorf("--tenant-id is required") + } + + conn, err := DialGRPC(credentialsPath) + if err != nil { + return fmt.Errorf("connect to permify: %w", err) + } + defer func() { _ = conn.Close() }() + + rpcCtx, cancel := newGRPCCallContext(cmd.Context()) + defer cancel() + + client := basev1.NewSchemaClient(conn) + return runSchemaRead(rpcCtx, os.Stdout, client, tenantID, schemaVersion) + }, + } + + fs := cmd.Flags() + fs.StringVar(&credentialsPath, "credentials", "", "path to gRPC credentials file (default: $HOME/.permify/credentials)") + fs.StringVar(&tenantID, "tenant-id", "", "tenant identifier (required)") + fs.StringVar(&schemaVersion, "schema-version", "", "optional schema version; empty uses server default") + _ = cmd.MarkFlagRequired("tenant-id") + return cmd +} + +type schemaWriteClient interface { + Write(ctx context.Context, in *basev1.SchemaWriteRequest, opts ...grpc.CallOption) (*basev1.SchemaWriteResponse, error) +} + +type schemaReadClient interface { + Read(ctx context.Context, in *basev1.SchemaReadRequest, opts ...grpc.CallOption) (*basev1.SchemaReadResponse, error) +} + +func runSchemaWrite(ctx context.Context, w io.Writer, client schemaWriteClient, tenantID, schema string) error { + resp, err := client.Write(ctx, &basev1.SchemaWriteRequest{ + TenantId: tenantID, + Schema: schema, + }) + if err != nil { + return GRPCStatusError(err) + } + _, _ = fmt.Fprintf(w, "Schema written.\nSchema version: %s\n", resp.GetSchemaVersion()) + return nil +} + +func runSchemaRead(ctx context.Context, w io.Writer, client schemaReadClient, tenantID, schemaVersion string) error { + resp, err := client.Read(ctx, &basev1.SchemaReadRequest{ + TenantId: tenantID, + Metadata: &basev1.SchemaReadRequestMetadata{ + SchemaVersion: schemaVersion, + }, + }) + if err != nil { + return GRPCStatusError(err) + } + formatSchemaSummary(w, resp.GetSchema()) + return nil +} diff --git a/pkg/cmd/schema_test.go b/pkg/cmd/schema_test.go new file mode 100644 index 000000000..8430d6e61 --- /dev/null +++ b/pkg/cmd/schema_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +func TestRunSchemaWrite(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubSchemaClient{ + writeFn: func(_ context.Context, in *basev1.SchemaWriteRequest, _ ...grpc.CallOption) (*basev1.SchemaWriteResponse, error) { + assert.Equal(t, "t1", in.GetTenantId()) + assert.Contains(t, in.GetSchema(), "entity") + return &basev1.SchemaWriteResponse{SchemaVersion: "v1"}, nil + }, + } + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runSchemaWrite(rpcCtx, &buf, stub, "t1", "entity user {}") + require.NoError(t, err) + assert.Contains(t, buf.String(), "v1") +} + +func TestRunSchemaRead(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubSchemaClient{ + readFn: func(_ context.Context, in *basev1.SchemaReadRequest, _ ...grpc.CallOption) (*basev1.SchemaReadResponse, error) { + assert.Equal(t, "t1", in.GetTenantId()) + return &basev1.SchemaReadResponse{ + Schema: &basev1.SchemaDefinition{ + EntityDefinitions: map[string]*basev1.EntityDefinition{ + "user": {Name: "user"}, + }, + }, + }, nil + }, + } + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runSchemaRead(rpcCtx, &buf, stub, "t1", "") + require.NoError(t, err) + assert.Contains(t, buf.String(), "user") +} + +func TestNewSchemaWriteCommand_RequiredFlags(t *testing.T) { + t.Parallel() + cmd := newSchemaWriteCommand() + cmd.SetArgs([]string{}) + cmd.SetOut(bytes.NewBuffer(nil)) + cmd.SetErr(bytes.NewBuffer(nil)) + require.Error(t, cmd.Execute()) +} diff --git a/pkg/cmd/tenant.go b/pkg/cmd/tenant.go new file mode 100644 index 000000000..00004ddcd --- /dev/null +++ b/pkg/cmd/tenant.go @@ -0,0 +1,208 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +// NewTenantCommand groups tenant RPCs (create, list, delete). +func NewTenantCommand() *cobra.Command { + root := &cobra.Command{ + Use: "tenant", + Short: "Create, list, or delete tenants on a remote Permify server", + } + root.AddCommand(newTenantCreateCommand()) + root.AddCommand(newTenantListCommand()) + root.AddCommand(newTenantDeleteCommand()) + return root +} + +func newTenantCreateCommand() *cobra.Command { + var credentialsPath, id, name string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a tenant (Tenancy.Create)", + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("--id is required") + } + if strings.TrimSpace(name) == "" { + return fmt.Errorf("--name is required") + } + + conn, err := DialGRPC(credentialsPath) + if err != nil { + return fmt.Errorf("connect to permify: %w", err) + } + defer func() { _ = conn.Close() }() + + rpcCtx, cancel := newGRPCCallContext(cmd.Context()) + defer cancel() + + client := basev1.NewTenancyClient(conn) + return runTenantCreate(rpcCtx, os.Stdout, client, id, name) + }, + } + + fs := cmd.Flags() + fs.StringVar(&credentialsPath, "credentials", "", "path to gRPC credentials file (default: $HOME/.permify/credentials)") + fs.StringVar(&id, "id", "", "tenant id (required)") + fs.StringVar(&name, "name", "", "tenant display name (required)") + _ = cmd.MarkFlagRequired("id") + _ = cmd.MarkFlagRequired("name") + return cmd +} + +func newTenantListCommand() *cobra.Command { + var credentialsPath string + var pageSize uint32 + + cmd := &cobra.Command{ + Use: "list", + Short: "List tenants (Tenancy.List)", + RunE: func(cmd *cobra.Command, _ []string) error { + if pageSize == 0 { + pageSize = 100 + } + + conn, err := DialGRPC(credentialsPath) + if err != nil { + return fmt.Errorf("connect to permify: %w", err) + } + defer func() { _ = conn.Close() }() + + rpcCtx, cancel := newGRPCCallContext(cmd.Context()) + defer cancel() + + client := basev1.NewTenancyClient(conn) + return runTenantList(rpcCtx, os.Stdout, client, pageSize, "") + }, + } + + fs := cmd.Flags() + fs.StringVar(&credentialsPath, "credentials", "", "path to gRPC credentials file (default: $HOME/.permify/credentials)") + fs.Uint32Var(&pageSize, "page-size", 100, "page size (1–100)") + return cmd +} + +func newTenantDeleteCommand() *cobra.Command { + var credentialsPath, id string + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a tenant (Tenancy.Delete)", + RunE: func(cmd *cobra.Command, _ []string) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("--id is required") + } + + conn, err := DialGRPC(credentialsPath) + if err != nil { + return fmt.Errorf("connect to permify: %w", err) + } + defer func() { _ = conn.Close() }() + + rpcCtx, cancel := newGRPCCallContext(cmd.Context()) + defer cancel() + + client := basev1.NewTenancyClient(conn) + return runTenantDelete(rpcCtx, os.Stdout, client, id) + }, + } + + fs := cmd.Flags() + fs.StringVar(&credentialsPath, "credentials", "", "path to gRPC credentials file (default: $HOME/.permify/credentials)") + fs.StringVar(&id, "id", "", "tenant id to delete (required)") + _ = cmd.MarkFlagRequired("id") + return cmd +} + +type tenancyCreateClient interface { + Create(ctx context.Context, in *basev1.TenantCreateRequest, opts ...grpc.CallOption) (*basev1.TenantCreateResponse, error) +} + +type tenancyListClient interface { + List(ctx context.Context, in *basev1.TenantListRequest, opts ...grpc.CallOption) (*basev1.TenantListResponse, error) +} + +type tenancyDeleteClient interface { + Delete(ctx context.Context, in *basev1.TenantDeleteRequest, opts ...grpc.CallOption) (*basev1.TenantDeleteResponse, error) +} + +func runTenantCreate(ctx context.Context, w io.Writer, client tenancyCreateClient, id, name string) error { + resp, err := client.Create(ctx, &basev1.TenantCreateRequest{ + Id: id, + Name: name, + }) + if err != nil { + return GRPCStatusError(err) + } + t := resp.GetTenant() + if t == nil { + _, _ = fmt.Fprintln(w, "Tenant created.") + return nil + } + _, _ = fmt.Fprintf(w, "Tenant created: %s (%s)\n", t.GetId(), t.GetName()) + if ts := t.GetCreatedAt(); ts != nil { + _, _ = fmt.Fprintf(w, "Created at: %s\n", formatTimestamp(ts)) + } + return nil +} + +func runTenantList(ctx context.Context, w io.Writer, client tenancyListClient, pageSize uint32, continuousToken string) error { + resp, err := client.List(ctx, &basev1.TenantListRequest{ + PageSize: pageSize, + ContinuousToken: continuousToken, + }) + if err != nil { + return GRPCStatusError(err) + } + tenants := resp.GetTenants() + if len(tenants) == 0 { + _, _ = fmt.Fprintln(w, "No tenants.") + return nil + } + _, _ = fmt.Fprintf(w, "Tenants (%d):\n", len(tenants)) + for _, t := range tenants { + line := fmt.Sprintf(" • %s — %s", t.GetId(), t.GetName()) + if ts := t.GetCreatedAt(); ts != nil { + line += fmt.Sprintf(" (created %s)", formatTimestamp(ts)) + } + _, _ = fmt.Fprintln(w, line) + } + if tok := resp.GetContinuousToken(); tok != "" { + _, _ = fmt.Fprintln(w, "Note: more tenants may be available (pagination token returned).") + } + return nil +} + +func runTenantDelete(ctx context.Context, w io.Writer, client tenancyDeleteClient, id string) error { + resp, err := client.Delete(ctx, &basev1.TenantDeleteRequest{Id: id}) + if err != nil { + return GRPCStatusError(err) + } + _, _ = fmt.Fprintf(w, "Deleted tenant: %s\n", resp.GetTenantId()) + return nil +} + +func formatTimestamp(ts *timestamppb.Timestamp) string { + if ts == nil { + return "" + } + t := ts.AsTime() + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} diff --git a/pkg/cmd/tenant_test.go b/pkg/cmd/tenant_test.go new file mode 100644 index 000000000..3588dbe4b --- /dev/null +++ b/pkg/cmd/tenant_test.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + basev1 "github.com/Permify/permify/pkg/pb/base/v1" +) + +func TestRunTenantCreate(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubTenancyClient{ + createFn: func(_ context.Context, in *basev1.TenantCreateRequest, _ ...grpc.CallOption) (*basev1.TenantCreateResponse, error) { + assert.Equal(t, "t1", in.GetId()) + assert.Equal(t, "One", in.GetName()) + return &basev1.TenantCreateResponse{ + Tenant: &basev1.Tenant{Id: "t1", Name: "One", CreatedAt: timestamppb.Now()}, + }, nil + }, + } + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runTenantCreate(rpcCtx, &buf, stub, "t1", "One") + require.NoError(t, err) + assert.Contains(t, buf.String(), "t1") +} + +func TestRunTenantList(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubTenancyClient{ + listFn: func(_ context.Context, in *basev1.TenantListRequest, _ ...grpc.CallOption) (*basev1.TenantListResponse, error) { + assert.EqualValues(t, 50, in.GetPageSize()) + return &basev1.TenantListResponse{ + Tenants: []*basev1.Tenant{{Id: "a", Name: "A"}}, + }, nil + }, + } + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runTenantList(rpcCtx, &buf, stub, 50, "") + require.NoError(t, err) + assert.Contains(t, buf.String(), "a") +} + +func TestRunTenantDelete(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + stub := &stubTenancyClient{ + deleteFn: func(_ context.Context, in *basev1.TenantDeleteRequest, _ ...grpc.CallOption) (*basev1.TenantDeleteResponse, error) { + assert.Equal(t, "t1", in.GetId()) + return &basev1.TenantDeleteResponse{TenantId: "t1"}, nil + }, + } + rpcCtx, cancel := newGRPCCallContext(context.Background()) + defer cancel() + err := runTenantDelete(rpcCtx, &buf, stub, "t1") + require.NoError(t, err) + assert.Contains(t, buf.String(), "t1") +} + +func TestNewTenantCreateCommand_RequiredFlags(t *testing.T) { + t.Parallel() + cmd := newTenantCreateCommand() + cmd.SetArgs([]string{}) + cmd.SetOut(bytes.NewBuffer(nil)) + cmd.SetErr(bytes.NewBuffer(nil)) + require.Error(t, cmd.Execute()) +}