diff --git a/internal/middleware/pertoken/pertoken.go b/internal/middleware/pertoken/pertoken.go index 83e2aaf1b0..3cf57de692 100644 --- a/internal/middleware/pertoken/pertoken.go +++ b/internal/middleware/pertoken/pertoken.go @@ -29,15 +29,17 @@ const ( type MiddlewareForTesting struct { datastoreByToken *sync.Map configFilePaths []string + configContents map[string][]byte caveatTypeSet *caveattypes.TypeSet } // NewMiddleware returns a new per-token datastore middleware that initializes each datastore with the data in the // config files. -func NewMiddleware(configFilePaths []string, caveatTypeSet *caveattypes.TypeSet) *MiddlewareForTesting { +func NewMiddleware(configFilePaths []string, configContents map[string][]byte, caveatTypeSet *caveattypes.TypeSet) *MiddlewareForTesting { return &MiddlewareForTesting{ datastoreByToken: &sync.Map{}, configFilePaths: configFilePaths, + configContents: configContents, caveatTypeSet: caveatTypeSet, } } @@ -66,6 +68,13 @@ func (m *MiddlewareForTesting) getOrCreateDatastore(ctx context.Context) (datast return nil, fmt.Errorf("failed to load config files: %w", err) } + if len(m.configContents) > 0 { + _, _, err = validationfile.PopulateFromFilesContents(ctx, datalayer.NewDataLayer(ds), m.caveatTypeSet, m.configContents) + if err != nil { + return nil, fmt.Errorf("failed to load config contents: %w", err) + } + } + // Squash the revisions so that the caller sees all the populated data. ds.(squashable).SquashRevisionsForTesting() diff --git a/internal/middleware/pertoken/pertoken_test.go b/internal/middleware/pertoken/pertoken_test.go index 4df1855a02..3cfe3973a1 100644 --- a/internal/middleware/pertoken/pertoken_test.go +++ b/internal/middleware/pertoken/pertoken_test.go @@ -143,7 +143,7 @@ validation: null // Create middleware caveatTypeSet := caveattypes.MustNewStandardTypeSet() - middleware := NewMiddleware([]string{configFile}, caveatTypeSet.TypeSet) + middleware := NewMiddleware([]string{configFile}, nil, caveatTypeSet.TypeSet) s := &perTokenMiddlewareTestSuite{ InterceptorTestSuite: &testpb.InterceptorTestSuite{ @@ -160,6 +160,46 @@ validation: null suite.Run(t, s) } +func TestPerTokenMiddlewareWithConfigContents(t *testing.T) { + configContent := `--- +schema: >- + definition example/user {} + + definition example/project { + relation reader: example/user + permission read = reader + } +relationships: >- + example/project:pied_piper#reader@example/user:tarben +assertions: + assertTrue: [] + assertFalse: [] +validation: null +` + + caveatTypeSet := caveattypes.MustNewStandardTypeSet() + middleware := NewMiddleware(nil, map[string][]byte{"generated-demo.yaml": []byte(configContent)}, caveatTypeSet.TypeSet) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "bearer sometoken") + _, err := middleware.getOrCreateDatastore(ctx) + require.NoError(t, err) + + ds, err := middleware.getOrCreateDatastore(ctx) + require.NoError(t, err) + + rev, err := ds.HeadRevision(context.Background()) + require.NoError(t, err) + + it, err := ds.SnapshotReader(rev.Revision).QueryRelationships(context.Background(), datastore.RelationshipsFilter{ + OptionalResourceType: "example/project", + }) + require.NoError(t, err) + + rels, err := datastore.IteratorToSlice(it) + require.NoError(t, err) + require.Len(t, rels, 1) +} + func (s *perTokenMiddlewareTestSuite) TestUnaryInterceptor_WithMissingToken() { resp, err := s.Client.Ping(s.SimpleCtx(), &testpb.PingRequest{Value: "test"}) s.Require().NoError(err) diff --git a/pkg/cmd/demo.go b/pkg/cmd/demo.go new file mode 100644 index 0000000000..963d9d4c14 --- /dev/null +++ b/pkg/cmd/demo.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/authzed/spicedb/pkg/cmd/demo" + "github.com/authzed/spicedb/pkg/cmd/server" + "github.com/authzed/spicedb/pkg/cmd/termination" +) + +func RegisterDemoFlags(cmd *cobra.Command, config *demo.Config) { + RegisterTestingFlags(cmd, config.TestServerConfig) + cmd.Flags().StringVar(&config.DataSet, "dataset", demo.DefaultDataSetName, "demo dataset to preload into each token-scoped datastore") +} + +func NewDemoCommand(programName string, config *demo.Config) *cobra.Command { + return &cobra.Command{ + Use: "demo", + Short: "test server with an in-memory datastore and pre-loaded data", + Long: `An in-memory spicedb server with pre-loaded data which serves completely isolated datastores per client-supplied auth token used. + +Examples: + # Run the demo server with the basic dataset + spicedb demo --dataset basic + + # Run the demo server with a custom dataset loaded from a file + spicedb demo --dataset custom --load-configs /path/to/config.yaml + + # Available demo datasets include the following: + # sample: user, groups, documents with relationships between them + # basic: only relationships, no schema, for testing schema-less operations + # docs: a dataset used for documentation examples, with a variety of schema and relationships + # custom: no dataset provided by default, intended for use with user-provided datasets via --load-configs + # entitlements: a dataset modeling a SaaS application with users, teams, and projects + # github: a dataset modeling GitHub with users, teams, organizations, and repositories + # user-defined: a dataset modeling a user-defined application with users, roles, and projects +`, + PreRunE: server.DefaultPreRunE(programName), + RunE: termination.PublishError(func(cmd *cobra.Command, args []string) error { + bootstrapContents, err := config.BootstrapConfig() + if err != nil { + return err + } + + if len(bootstrapContents) > 0 { + if config.TestServerConfig.LoadConfigsContents == nil { + config.TestServerConfig.LoadConfigsContents = map[string][]byte{} + } + for name, contents := range bootstrapContents { + config.TestServerConfig.LoadConfigsContents[name] = contents + } + } + + srv, err := config.TestServerConfig.Complete(cmd.Context()) + if err != nil { + return err + } + + signalctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + return srv.Run(signalctx) + }), + } +} diff --git a/pkg/cmd/demo/demo.go b/pkg/cmd/demo/demo.go new file mode 100644 index 0000000000..4599458651 --- /dev/null +++ b/pkg/cmd/demo/demo.go @@ -0,0 +1,319 @@ +package demo + +import ( + "fmt" + + "github.com/authzed/spicedb/pkg/cmd/testserver" +) + +const ( + DefaultDataSetName = "sample" + bootstrapFileName = "demo-bootstrap.yaml" +) + +type Config struct { + TestServerConfig *testserver.Config + DataSet string +} + +func (c *Config) BootstrapConfig() (map[string][]byte, error) { + switch c.DataSet { + case "custom": + // custom dataset is expected to be provided by the user via --load-configs, so no bootstrap contents are needed + return nil, nil + case "", DefaultDataSetName: + return map[string][]byte{bootstrapFileName: []byte(sampleDatasetYAML())}, nil + case "basic": + return map[string][]byte{bootstrapFileName: []byte(basicDatasetYAML())}, nil + case "docs": + return map[string][]byte{bootstrapFileName: []byte(docsDatasetYAML())}, nil + case "entitlements": + return map[string][]byte{bootstrapFileName: []byte(entitlementsDatasetYAML())}, nil + case "github": + return map[string][]byte{bootstrapFileName: []byte(githubDatasetYAML())}, nil + case "user-defined": + return map[string][]byte{bootstrapFileName: []byte(userDefinedDatasetYAML())}, nil + default: + return nil, fmt.Errorf("unknown demo dataset %q", c.DataSet) + } +} + +func sampleDatasetYAML() string { + return `--- +schema: | + definition user {} + + definition group { + relation member: user + } + + definition document { + relation reader: user | group#member + relation writer: user + permission view = reader + writer + permission edit = writer + } + +relationships: |- + group:eng#member@user:alice + group:eng#member@user:bob + document:roadmap#reader@group:eng#member + document:roadmap#writer@user:alice + document:launch-plan#reader@user:bob +` +} + +func basicDatasetYAML() string { + return `--- +schema: |- + definition user {} + definition document { + relation writer: user + relation reader: user + permission edit = writer + permission view = reader + edit + } + +relationships: |- + document:firstdoc#writer@user:tom + document:firstdoc#reader@user:fred + document:seconddoc#reader@user:tom` +} + +func docsDatasetYAML() string { + return `--- +schema: |- + definition user {} + + definition group_with_parent { + relation parent: group_with_parent + relation member: user + + permission view = member + parent->member + } + + definition group_with_child { + relation child: user | group_with_child#child + + permission view = child + } + + definition document { + relation viewer: user | group_with_parent#view | group_with_child#view + + permission view = viewer + } + +relationships: |- + group_with_parent:engineering#member@user:engineer + group_with_child:engineering#child@user:engineer + group_with_parent:analysis#member@user:analyst + group_with_child:analysis#child@user:analyst + + group_with_parent:engineering#parent@group_with_parent:company + group_with_child:company#child@group_with_child:engineering#child + group_with_parent:analysis#parent@group_with_parent:company + group_with_child:company#child@group_with_child:analysis#child + + document:shared_with_company#viewer@group_with_child:company#view + document:shared_with_company#viewer@group_with_parent:company#view + document:shared_with_engineering#viewer@group_with_parent:engineering#view + document:shared_with_engineering#viewer@group_with_child:engineering#view + document:shared_with_analysis#viewer@group_with_parent:analysis#view + document:shared_with_analysis#viewer@group_with_child:analysis#view` +} + +func entitlementsDatasetYAML() string { + return `--- +--- +schema: |- + definition user {} + + definition organization { + relation member: user + } + + definition entitlement { + relation org: organization + permission subscribed_member = org->member + } + + definition feature { + relation associated_entitlement: entitlement + permission access = associated_entitlement->subscribed_member + } + +relationships: |- + organization:github#member@user:maria + organization:acme#member@user:frank + + entitlement:pro_plan#org@organization:github + entitlement:free_plan#org@organization:acme + + feature:download_analytics#associated_entitlement@entitlement:pro_plan + feature:view_analytics#associated_entitlement@entitlement:free_plan + feature:view_analytics#associated_entitlement@entitlement:pro_plan` +} + +func githubDatasetYAML() string { + return `--- +schema: |- + definition user {} + + definition team { + relation parent: organization | team + relation maintainer: user + relation direct_member: user + + permission member = maintainer + direct_member + permission change_team_name = maintainer + parent->change_team_name + } + + definition organization { + relation own: user + relation member: user + relation billing_manager: user + relation team_maintainer: user + + permission create_repository = owner + member + + permission manage_billing = owner + billing_manager + permission user_seat = owner + member + team_maintainer + permission owner = own + + permission change_team_name = team_maintainer + owner + } + + definition repository { + relation organization: organization + + relation reader: user | team#member + relation triager: user | team#member + relation writer: user | team#member + relation maintainer: user | team#member + relation admin: user | team#member + + permission clone = reader + triager + push + permission push = writer + maintainer + admin + organization->owner + + permission read = reader + triager + writer + maintainer + admin + organization->owner + permission delete = admin + organization->owner + + permission create_issue = read + permission close_issue = triager + writer + maintainer + admin + organization->owner + + permission create_pull_request = read + permission merge_pull_request = maintainer + organization->owner + permission close_pull_request = triager + writer + maintainer + admin + organization->owner + + permission manage_setting = maintainer + admin + organization->owner + permission manage_sensitive_setting = admin + organization->owner + } +relationships: |- + repository:authzed_go#organization@organization:authzed#... + + repository:authzed_go#reader@user:jake#... + repository:authzed_go#admin@user:jimmy#... + repository:authzed_go#triager@user:jessica#... + + repository:authzed_go#maintainer@team:support_engineers#member + + organization:authzed#own@user:jake#... + organization:authzed#own@user:jimmy#... + + team:support_engineers#maintainer@user:ivan#... + team:support_engineers#direct_member@user:ian#... + team:support_engineers#parent@organization:authzed#... + + team:emea_support_engineers#direct_member@user:iona#... + team:emea_support_engineers#parent@team:support_engineers#...` +} + +func userDefinedDatasetYAML() string { + return `--- +schema: | + definition user {} + + definition project { + relation issue_creator: role#member + relation issue_assigner: role#member + relation any_issue_resolver: role#member + relation assigned_issue_resolver: role#member + relation comment_creator: role#member + relation comment_deleter: role#member + relation role_manager: role#member + + permission create_issue = issue_creator + permission create_role = role_manager + } + + definition role { + relation project: project + relation member: user + relation built_in_role: project + + permission delete = project->role_manager - built_in_role->role_manager + permission add_user = project->role_manager + permission add_permission = project->role_manager - built_in_role->role_manager + permission remove_permission = project->role_manager - built_in_role->role_manager + } + + definition issue { + relation project: project + relation assigned: user + + permission assign = project->issue_assigner + permission resolve = (project->assigned_issue_resolver & assigned) + project->any_issue_resolver + permission create_comment = project->comment_creator + + permission project_comment_deleter = project->comment_deleter + } + + definition comment { + relation issue: issue + permission delete = issue->project_comment_deleter + } +relationships: | + issue:move_the_servers#project@project:pied_piper + issue:move_the_servers#assigned@user:gilfoyle + + issue:too_slow#project@project:pied_piper + comment:try_middle_out#issue@issue:too_slow + + role:admin#project@project:pied_piper + role:admin#built_in_role@project:pied_piper + role:developer#project@project:pied_piper + role:developer#built_in_role@project:pied_piper + role:user#project@project:pied_piper + role:user#built_in_role@project:pied_piper + role:project_manager#project@project:pied_piper + role:legal#project@project:pied_piper + + project:pied_piper#issue_creator@role:admin#member + project:pied_piper#issue_creator@role:developer#member + project:pied_piper#issue_creator@role:user#member + + project:pied_piper#issue_assigner@role:admin#member + project:pied_piper#issue_assigner@role:project_manager#member + + project:pied_piper#any_issue_resolver@role:admin#member + project:pied_piper#any_issue_resolver@role:project_manager#member + + project:pied_piper#assigned_issue_resolver@role:admin#member + project:pied_piper#assigned_issue_resolver@role:developer#member + + project:pied_piper#comment_creator@role:admin#member + project:pied_piper#comment_creator@role:developer#member + project:pied_piper#comment_creator@role:user#member + + project:pied_piper#comment_deleter@role:admin#member + project:pied_piper#comment_deleter@role:legal#member + + project:pied_piper#role_manager@role:admin#member + + role:admin#member@user:richard + role:developer#member@user:gilfoyle + role:user#member@user:monica + role:project_manager#member@user:jared + role:legal#member@user:ron` +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 7c088a9dab..9fc1415a5b 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" log "github.com/authzed/spicedb/internal/logging" + "github.com/authzed/spicedb/pkg/cmd/demo" "github.com/authzed/spicedb/pkg/cmd/server" "github.com/authzed/spicedb/pkg/cmd/testserver" "github.com/authzed/spicedb/pkg/releases" @@ -118,6 +119,14 @@ func BuildRootCommand() (*cobra.Command, error) { RegisterTestingFlags(testingCmd, &testServerConfig) rootCmd.AddCommand(testingCmd) + // Add demo command + demoConfig := demo.Config{ + TestServerConfig: &testserver.Config{}, + } + demoCmd := NewDemoCommand(rootCmd.Use, &demoConfig) + RegisterDemoFlags(demoCmd, &demoConfig) + rootCmd.AddCommand(demoCmd) + rootCmd.AddCommand(&cobra.Command{ Use: "man", Short: "Generate man page", diff --git a/pkg/cmd/server/defaults_test.go b/pkg/cmd/server/defaults_test.go index d4e9d0ce3d..df89e20004 100644 --- a/pkg/cmd/server/defaults_test.go +++ b/pkg/cmd/server/defaults_test.go @@ -82,7 +82,7 @@ func TestWithDatastoreMiddleware(t *testing.T) { nil, } - someMiddleware := pertoken.NewMiddleware(nil, caveattypes.Default.TypeSet) + someMiddleware := pertoken.NewMiddleware(nil, nil, caveattypes.Default.TypeSet) withDS := opts.WithDatastoreMiddleware(someMiddleware) require.NotNil(t, withDS) diff --git a/pkg/cmd/testserver/testserver.go b/pkg/cmd/testserver/testserver.go index 611beb156b..04f976c2a1 100644 --- a/pkg/cmd/testserver/testserver.go +++ b/pkg/cmd/testserver/testserver.go @@ -39,6 +39,7 @@ type Config struct { HTTPGateway util.HTTPServerConfig `debugmap:"visible"` ReadOnlyHTTPGateway util.HTTPServerConfig `debugmap:"visible"` LoadConfigs []string `debugmap:"visible"` + LoadConfigsContents map[string][]byte `debugmap:"hidden"` MaximumUpdatesPerWrite uint16 `debugmap:"visible"` MaximumPreconditionCount uint16 `debugmap:"visible"` MaxCaveatContextSize int `debugmap:"visible"` @@ -88,7 +89,7 @@ func (c *Config) Complete(ctx context.Context) (RunnableTestServer, error) { return nil, fmt.Errorf("failed to create dispatcher: %w", err) } closeables.AddWithError(dispatcher.Close) - datastoreMiddleware := pertoken.NewMiddleware(c.LoadConfigs, cts) + datastoreMiddleware := pertoken.NewMiddleware(c.LoadConfigs, c.LoadConfigsContents, cts) healthManager := health.NewHealthManager(dispatcher, &datastoreReady{}) registerServices := func(srv *grpc.Server) {