diff --git a/.golangci.yml b/.golangci.yml index 0d3764a3..16331cc2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -32,6 +32,7 @@ linters: allow: - $gostd - github.com/google/uuid + - github.com/gopherium/framework/goncierge - github.com/gopherium/framework/gonsole - github.com/gopherium/framework/pluginkit plugins-never-import-internal: diff --git a/cmd/alphone/accounts.go b/cmd/alphone/accounts.go index 4452746b..45ad010b 100644 --- a/cmd/alphone/accounts.go +++ b/cmd/alphone/accounts.go @@ -3,12 +3,11 @@ package main import ( - "context" - "fmt" "time" - "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/goncierge" accounts "github.com/gopherium/framework/gonsole/auth" + accountspg "github.com/gopherium/framework/gonsole/auth/postgres" "github.com/gopherium/alphone/internal/role" ) @@ -20,32 +19,12 @@ const recordTimeout = 5 * time.Second const recordsLimit = 50 // accountConfig returns the configuration of the account commands and of the check on the acting account. -func accountConfig(registry *role.Registry) accounts.Config { +func accountConfig(registry *goncierge.Registry) accounts.Config { return accounts.Config{ - Roles: declaredRoles(registry), - Capability: string(role.ManageUsers), + Roles: accounts.DeclaredRoles(registry, role.ManageUsers), + Capability: role.ManageUsers, RecordTimeout: recordTimeout, RecordsLimit: recordsLimit, - } -} - -// declaredRoles returns the role table of the account commands, the plugins registered first to declare theirs. -func declaredRoles(registry *role.Registry) func(context.Context, gonsole.Call) (accounts.Roles, error) { - return func(ctx context.Context, call gonsole.Call) (accounts.Roles, error) { - loaded, err := call.Plugins(ctx) - if err != nil { - return accounts.Roles{}, err - } - if loaded.Failed != nil { - return accounts.Roles{}, fmt.Errorf("register plugins: %w", loaded.Failed) - } - held := registry.Roles() - known := make([]string, 0, len(held)) - capabilities := make(map[string][]string, len(held)) - for _, name := range held { - known = append(known, name.String()) - capabilities[name.String()] = registry.CapabilitiesOf(name) - } - return accounts.Roles{Known: known, Privileged: registry.Privileged(), Capabilities: capabilities}, nil + Stores: accountspg.Stores, } } diff --git a/cmd/alphone/accounts_test.go b/cmd/alphone/accounts_test.go index 31bebbe0..aaab167d 100644 --- a/cmd/alphone/accounts_test.go +++ b/cmd/alphone/accounts_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/gonsole" accounts "github.com/gopherium/framework/gonsole/auth" "github.com/gopherium/framework/gonsole/testkit" @@ -63,7 +64,7 @@ func roleOf(t *testing.T, databaseURL, email string) string { // stewardDeclaring registers one plugin declaring the steward role. func stewardDeclaring(sdk.Deps) ([]sdk.Plugin, error) { return []sdk.Plugin{rolePlugin{declared: []sdk.RoleDeclaration{ - {Name: "steward", Capabilities: []string{string(role.ManageUsers), string(role.ManageWebhooks)}}, + {Name: "steward", Capabilities: []string{role.ManageUsers, role.ManageWebhooks}}, }}}, nil } @@ -164,7 +165,7 @@ func TestAccountCommandsRefuseARoleAPluginCannotDeclare(t *testing.T) { got := testkit.Run(t, programOver(role.NewRegistry(), getenv, refused), "", "account:grant-role", "-role", "admin", "-as", "admin@example.com") - if got.Code != gonsole.ExitFailed || !strings.Contains(got.Stderr, role.ErrEmptyRole.Error()) { + if got.Code != gonsole.ExitFailed || !strings.Contains(got.Stderr, goncierge.ErrInvalidName.Error()) { t.Errorf("account:grant-role = %d with stderr %q, want 1 and the refused declaration named", got.Code, got.Stderr) } } @@ -319,14 +320,14 @@ func TestAnAdminCannotGiveARoleAboveItsOwn(t *testing.T) { env := accountsDatabase(t) above := func(sdk.Deps) ([]sdk.Plugin, error) { return []sdk.Plugin{rolePlugin{declared: []sdk.RoleDeclaration{ - {Name: "steward", Capabilities: []string{string(role.ManageUsers), "manage_reports"}}, + {Name: "steward", Capabilities: []string{role.ManageUsers, "steward.manage_reports"}}, }}}, nil } got := testkit.Run(t, programOver(role.NewRegistry(), testkit.Getenv(env), above), "", "account:role", "maria.perez@example.com", "steward", "-yes", "-as", "admin@example.com") - want := "alphone: the role steward carries manage_reports, which the account admin@example.com lacks\n" + want := "alphone: the role steward carries steward.manage_reports, which the account admin@example.com lacks\n" if got.Code != gonsole.ExitFailed || got.Stdout != "" || got.Stderr != want { t.Errorf("account:role -role steward = %d, stdout %q, stderr %q, want 1 and %q", got.Code, got.Stdout, got.Stderr, want) @@ -367,6 +368,26 @@ func TestAnActingAccountCannotChangeItsOwnRoleOrDisableItself(t *testing.T) { } } +func TestAccountEnableRefusesAnAccountHoldingNoRole(t *testing.T) { + t.Parallel() + + env := accountsDatabase(t) + storeRoleless(t, env["ALPHONE_DATABASE_URL"], "none@example.com") + + got := testkit.Run(t, bareProgram(env), "", "account:enable", "none@example.com", "-yes", "-as", "admin@example.com") + + want := testkit.Result{ + Code: gonsole.ExitFailed, + Stderr: "alphone: the account none@example.com holds no role, give it a declared role before enabling it\n", + } + if got != want { + t.Errorf("account:enable = %+v, want %+v", got, want) + } + if records := recordsOf(t, env); len(records) != 0 { + t.Errorf("records = %q, want none", records) + } +} + func TestPaddedRecordSettingsReadLikePlainOnes(t *testing.T) { t.Parallel() @@ -416,7 +437,7 @@ func TestTheRoleTableCountsEveryRoleThatManagesUsersAsPrivileged(t *testing.T) { Name: "roles", Summary: "read the role table of the account commands", Run: func(ctx context.Context, call gonsole.Call) (err error) { - held, err = declaredRoles(registry)(ctx, call) + held, err = accountConfig(registry).Roles(ctx, call) return err }, }}, @@ -428,7 +449,7 @@ func TestTheRoleTableCountsEveryRoleThatManagesUsersAsPrivileged(t *testing.T) { if want := []string{role.Admin.String(), "steward"}; !slices.Equal(held.Privileged, want) { t.Errorf("privileged roles = %v, want %v, every role carrying manage_users", held.Privileged, want) } - want := []string{string(role.ManageUsers), string(role.ManageWebhooks)} + want := []string{role.ManageUsers, role.ManageWebhooks} if !slices.Equal(held.Capabilities["steward"], want) { t.Errorf("the steward role carries %v, want %v", held.Capabilities["steward"], want) } diff --git a/cmd/alphone/compose.go b/cmd/alphone/compose.go index fe4f5ea4..9cd7477b 100644 --- a/cmd/alphone/compose.go +++ b/cmd/alphone/compose.go @@ -10,8 +10,9 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/gonsole" - accounts "github.com/gopherium/framework/gonsole/auth" + accountspg "github.com/gopherium/framework/gonsole/auth/postgres" "github.com/gopherium/framework/pluginkit" authkitpg "github.com/gopherium/gouncer/authkit/postgres" @@ -19,7 +20,6 @@ import ( "github.com/gopherium/alphone/internal/event" "github.com/gopherium/alphone/internal/graphres" "github.com/gopherium/alphone/internal/postgres" - "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/webhook" "github.com/gopherium/alphone/sdk" ) @@ -29,7 +29,7 @@ type composeConfig struct { composeSettings databaseURL string getenv func(string) string - roles *role.Registry + roles *goncierge.Registry logger *slog.Logger } @@ -47,6 +47,7 @@ type composed struct { hub *event.Hub events nudgingPublisher mailer graphres.Mailer + roles *goncierge.Registry registered []sdk.Plugin failed error } @@ -70,6 +71,7 @@ func compose(ctx context.Context, cfg composeConfig, plugins func(sdk.Deps) ([]s tenants: postgres.NewTenantStore(pool), worker: webhook.NewWorker(webhooks, cfg.logger, guard), hub: event.NewHub(), + roles: cfg.roles, } built.events = nudgingPublisher{ dispatcher: webhook.NewDispatcher(webhooks, cfg.logger), worker: built.worker, hub: built.hub, @@ -117,7 +119,7 @@ func abandon(ctx context.Context, built composed, grace time.Duration) error { // migrations returns the schema steps every database takes, in the order they apply. func migrations() []gonsole.Step { - return []gonsole.Step{accounts.Migration(), accounts.RecordMigration(), {Name: "core", Run: postgres.Migrate}} + return []gonsole.Step{accountspg.Migration(), accountspg.RecordMigration(), {Name: "core", Run: postgres.Migrate}} } // migrate applies every schema step to the database at databaseURL. diff --git a/cmd/alphone/compose_test.go b/cmd/alphone/compose_test.go index cbab08b0..f8180dd0 100644 --- a/cmd/alphone/compose_test.go +++ b/cmd/alphone/compose_test.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/gonsole/testkit" "github.com/gopherium/alphone/internal/role" @@ -306,7 +307,7 @@ func TestRunNamesTheRegistrationFailureBesideARefusedRole(t *testing.T) { err := run(t.Context(), getenv, io.Discard, partlyRegisteredWithARefusedRole) - if !errors.Is(err, errRegistration) || !errors.Is(err, role.ErrEmptyRole) { + if !errors.Is(err, errRegistration) || !errors.Is(err, goncierge.ErrInvalidName) { t.Errorf("run() error = %v, want the registration failure and the refused role both named", err) } } diff --git a/cmd/alphone/features_test.go b/cmd/alphone/features_test.go index 946e44ba..d67a38c3 100644 --- a/cmd/alphone/features_test.go +++ b/cmd/alphone/features_test.go @@ -193,7 +193,7 @@ func (s *operatorScenario) holdMember(email string) error { // declareRoleBeyond adds a plugin declaring the role called name with a capability the role held lacks. func (s *operatorScenario) declareRoleBeyond(name, held string) { s.plugins = besideTheCompiledPlugins(rolePlugin{declared: []sdk.RoleDeclaration{ - {Name: name, Capabilities: []string{"reach_beyond_" + held}}, + {Name: name, Capabilities: []string{"steward.reach_beyond_" + held}}, }}) } diff --git a/cmd/alphone/loadplugins_test.go b/cmd/alphone/loadplugins_test.go index 9fd7c513..24e4de3b 100644 --- a/cmd/alphone/loadplugins_test.go +++ b/cmd/alphone/loadplugins_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/gonsole/testkit" @@ -150,12 +151,12 @@ func TestLoadPluginsStopsThePluginsWhenARoleIsRefused(t *testing.T) { _, err := loadPlugins(role.NewRegistry(), func(sdk.Deps) ([]sdk.Plugin, error) { return []sdk.Plugin{ stoppingPlugin{stopped: &stopped}, - rolePlugin{declared: []sdk.RoleDeclaration{{Name: "", Capabilities: []string{"manage_reports"}}}}, + rolePlugin{declared: []sdk.RoleDeclaration{{Name: "", Capabilities: []string{"steward.manage_reports"}}}}, }, nil })(t.Context(), call) - if !errors.Is(err, role.ErrEmptyRole) { - t.Fatalf("loadPlugins() error = %v, want %v in its chain", err, role.ErrEmptyRole) + if !errors.Is(err, goncierge.ErrInvalidName) { + t.Fatalf("loadPlugins() error = %v, want %v in its chain", err, goncierge.ErrInvalidName) } stoppedUnderTheStopGrace(t, err, &stopped) } @@ -230,7 +231,7 @@ func TestLoadPluginsNamesTheRegistrationFailureBesideARefusedRole(t *testing.T) _, err := loadPlugins(role.NewRegistry(), partlyRegisteredWithARefusedRole)( t.Context(), describingCall(nil, io.Discard)) - if !errors.Is(err, errRegistration) || !errors.Is(err, role.ErrEmptyRole) { + if !errors.Is(err, errRegistration) || !errors.Is(err, goncierge.ErrInvalidName) { t.Errorf("loadPlugins() error = %v, want the registration failure and the refused role both named", err) } } diff --git a/cmd/alphone/plugins.go b/cmd/alphone/plugins.go index 5c665727..1087624a 100644 --- a/cmd/alphone/plugins.go +++ b/cmd/alphone/plugins.go @@ -11,18 +11,18 @@ import ( "github.com/google/uuid" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/pluginkit" "github.com/gopherium/alphone/internal/contact" "github.com/gopherium/alphone/internal/postgres" - "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/sdk" ) // loadPlugins returns the registration the command line runs the compiled plugins through, starting none. func loadPlugins( - registry *role.Registry, plugins func(sdk.Deps) ([]sdk.Plugin, error), + registry *goncierge.Registry, plugins func(sdk.Deps) ([]sdk.Plugin, error), ) func(context.Context, gonsole.Call) (gonsole.Loaded, error) { return func(ctx context.Context, call gonsole.Call) (gonsole.Loaded, error) { grace, err := call.Env.Duration("SHUTDOWN_STOP_GRACE", servingDefaults.StopGrace) diff --git a/cmd/alphone/privileged_test.go b/cmd/alphone/privileged_test.go index 482a5521..df58ad3d 100644 --- a/cmd/alphone/privileged_test.go +++ b/cmd/alphone/privileged_test.go @@ -12,24 +12,37 @@ import ( func TestTheAdminSeamCarriesThePrivilegedCover(t *testing.T) { t.Parallel() - held := adminConfig(nil) + registry := role.NewRegistry() - if !slices.Equal(held.Privileged, role.Privileged()) { + held := adminConfig(composed{roles: registry}) + + if want := registry.HoldersOf(role.ManageUsers); !slices.Equal(held.Privileged, want) { t.Errorf("Privileged = %v, want %v, an empty cover admits every role at the brick's guard", - held.Privileged, role.Privileged()) + held.Privileged, want) } if len(held.Privileged) == 0 { t.Error("the cover is empty, want the roles that administer accounts") } + if held.Roles != registry { + t.Errorf("Roles = %v, want the registry handed in", held.Roles) + } } func TestTheLoginSeamCarriesThePrivilegedCover(t *testing.T) { t.Parallel() - held := authConfig(nil) + registry := role.NewRegistry() - if !slices.Equal(held.Privileged, role.Privileged()) { - t.Errorf("Privileged = %v, want %v", held.Privileged, role.Privileged()) + held := authConfig(composed{roles: registry}) + + if want := registry.HoldersOf(role.ManageUsers); !slices.Equal(held.Privileged, want) { + t.Errorf("Privileged = %v, want %v", held.Privileged, want) + } + if len(held.Privileged) == 0 { + t.Error("the cover is empty, want the roles that administer accounts") + } + if held.Capabilities != registry { + t.Errorf("Capabilities = %v, want the registry handed in", held.Capabilities) } if held.CookieName == "" { t.Error("the login seam names no cookie, want the session cookie the server reads") diff --git a/cmd/alphone/program.go b/cmd/alphone/program.go index 91fbf161..5fbe5066 100644 --- a/cmd/alphone/program.go +++ b/cmd/alphone/program.go @@ -6,6 +6,7 @@ import ( "context" "slices" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/gonsole" accounts "github.com/gopherium/framework/gonsole/auth" @@ -16,12 +17,12 @@ import ( // program returns the alphone command line over getenv and the compiled plugins. func program(getenv func(string) string, plugins func(sdk.Deps) ([]sdk.Plugin, error)) gonsole.Program { - return programOver(role.Default, getenv, plugins) + return programOver(role.NewRegistry(), getenv, plugins) } -// programOver returns the alphone command line declaring plugin roles into registry, serve into role.Default. +// programOver returns the alphone command line declaring plugin roles into registry, serve into a registry of its own. func programOver( - registry *role.Registry, getenv func(string) string, plugins func(sdk.Deps) ([]sdk.Plugin, error), + registry *goncierge.Registry, getenv func(string) string, plugins func(sdk.Deps) ([]sdk.Plugin, error), ) gonsole.Program { accountCommands := accountConfig(registry) return gonsole.Program{ diff --git a/cmd/alphone/roles_test.go b/cmd/alphone/roles_test.go index 6023ea52..dab66190 100644 --- a/cmd/alphone/roles_test.go +++ b/cmd/alphone/roles_test.go @@ -12,6 +12,7 @@ import ( "sync/atomic" "testing" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/gonsole/testkit" "github.com/gopherium/alphone/internal/role" @@ -44,6 +45,18 @@ func (p rolePlugin) Roles() []sdk.RoleDeclaration { return p.declared } +// corePlugin is a role plugin under the id the core grants its roles under. +type corePlugin struct{ rolePlugin } + +// ID names the plugin after the core source. +func (corePlugin) ID() string { return role.Source } + +// silentCorePlugin is a plugin declaring no roles under the id the core grants its roles under. +type silentCorePlugin struct{ silentPlugin } + +// ID names the plugin after the core source. +func (silentCorePlugin) ID() string { return role.Source } + // errStopFailed is the failure the stopping plugin's stop answers. var errStopFailed = errors.New("stop failed") @@ -99,14 +112,14 @@ func TestRunStopsThePluginsWhenARoleIsRefused(t *testing.T) { refused := func(sdk.Deps) ([]sdk.Plugin, error) { return []sdk.Plugin{ stoppingPlugin{stopped: &stopped}, - rolePlugin{declared: []sdk.RoleDeclaration{{Name: "", Capabilities: []string{"manage_reports"}}}}, + rolePlugin{declared: []sdk.RoleDeclaration{{Name: "", Capabilities: []string{"steward.manage_reports"}}}}, }, nil } err := run(t.Context(), stopGraceEnv(t), io.Discard, refused) - if !errors.Is(err, role.ErrEmptyRole) { - t.Fatalf("run() error = %v, want %v in its chain", err, role.ErrEmptyRole) + if !errors.Is(err, goncierge.ErrInvalidName) { + t.Fatalf("run() error = %v, want %v in its chain", err, goncierge.ErrInvalidName) } stoppedUnderTheStopGrace(t, err, &stopped) } @@ -143,8 +156,8 @@ func TestDeclareRolesGrantsEveryDeclarationAPluginMakes(t *testing.T) { registered := []sdk.Plugin{ silentPlugin{}, rolePlugin{declared: []sdk.RoleDeclaration{ - {Name: "steward", Capabilities: []string{"manage_users", "manage_reports"}}, - {Name: "admin", Capabilities: []string{"manage_reports"}}, + {Name: "steward", Capabilities: []string{role.ManageUsers, "steward.manage_reports"}}, + {Name: role.Admin.String(), Capabilities: []string{"steward.manage_reports"}}, }}, } @@ -152,14 +165,14 @@ func TestDeclareRolesGrantsEveryDeclarationAPluginMakes(t *testing.T) { t.Fatalf("declareRoles() error = %v, want nil", err) } - if !registry.Can("steward", "manage_reports") { - t.Error("Can(steward, manage_reports) = false, want the declared role held") + if !registry.Can("steward", "steward.manage_reports") { + t.Error("Can(steward, steward.manage_reports) = false, want the declared role held") } - if !registry.Can(role.Admin, "manage_reports") { - t.Error("Can(admin, manage_reports) = false, want the core role widened") + if !registry.Can(role.Admin.String(), "steward.manage_reports") { + t.Error("Can(admin, steward.manage_reports) = false, want the core role widened") } - if got := registry.Privileged(); !slices.Equal(got, []string{"admin", "steward"}) { - t.Errorf("Privileged() = %v, want the declared role counted as cover", got) + if got := registry.HoldersOf(role.ManageUsers); !slices.Equal(got, []string{"admin", "steward"}) { + t.Errorf("HoldersOf(manage_users) = %v, want the declared role counted as cover", got) } } @@ -168,12 +181,60 @@ func TestDeclareRolesRefusesADeclarationWithNoName(t *testing.T) { registry := role.NewRegistry() registered := []sdk.Plugin{ - rolePlugin{declared: []sdk.RoleDeclaration{{Name: "", Capabilities: []string{"manage_reports"}}}}, + rolePlugin{declared: []sdk.RoleDeclaration{{Name: "", Capabilities: []string{"steward.manage_reports"}}}}, } err := declareRoles(registry, registered) - if !errors.Is(err, role.ErrEmptyRole) { - t.Errorf("declareRoles() error = %v, want ErrEmptyRole surfaced at wiring", err) + if !errors.Is(err, goncierge.ErrInvalidName) { + t.Errorf("declareRoles() error = %v, want ErrInvalidName surfaced at wiring", err) + } +} + +func TestDeclareRolesRefusesANewCapabilityOutsideThePluginPrefix(t *testing.T) { + t.Parallel() + + registry := role.NewRegistry() + registered := []sdk.Plugin{ + rolePlugin{declared: []sdk.RoleDeclaration{{Name: "steward", Capabilities: []string{"manage_reports"}}}}, + } + + err := declareRoles(registry, registered) + + if !errors.Is(err, goncierge.ErrOutsidePrefix) || !strings.Contains(err.Error(), "plugin steward") { + t.Errorf("declareRoles() error = %v, want ErrOutsidePrefix naming plugin steward", err) + } + if registry.Known("steward") { + t.Error("the registry knows steward, want the refused declaration left out") + } +} + +func TestDeclareRolesRefusesAPluginUnderTheCoreID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + plugin sdk.Plugin + }{ + {"one declaring roles", corePlugin{rolePlugin{declared: []sdk.RoleDeclaration{ + {Name: role.Admin.String(), Capabilities: []string{role.ManageUsers}}, + }}}}, + {"one declaring no roles", silentCorePlugin{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + registry := role.NewRegistry() + + err := declareRoles(registry, []sdk.Plugin{tt.plugin}) + + if !errors.Is(err, errReservedID) || !strings.Contains(err.Error(), "plugin core") { + t.Errorf("declareRoles() error = %v, want %v naming plugin core", err, errReservedID) + } + if !registry.Can(role.Admin.String(), role.ManageWebhooks) { + t.Error("Can(admin, manage_webhooks) = false, want the core roles kept whole") + } + }) } } diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index 7db29762..6bf3c72b 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -14,10 +14,10 @@ import ( "strings" "time" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/pluginkit" "github.com/gopherium/gouncer/authkit" - authkitpg "github.com/gopherium/gouncer/authkit/postgres" "github.com/gopherium/gouncer/authkit/ratelimit" "github.com/gopherium/alphone/graph" @@ -50,7 +50,7 @@ func run( composeSettings: settings.composeSettings, databaseURL: settings.databaseURL, getenv: getenv, - roles: role.Default, + roles: role.NewRegistry(), logger: logger, }, plugins) if built.failed != nil { @@ -75,7 +75,7 @@ func run( return fmt.Errorf("start plugins: %w", err) } - auth := authkit.New(authConfig(built.users)) + auth := authkit.New(authConfig(built)) graphRoot, err := graphroot.FromPlugins(graphResolver(settings, built, auth, logger), built.registered) if err != nil { return errors.Join( @@ -87,19 +87,20 @@ func run( return gonsole.Serve(ctx, httpServer, settings.serving, host.Stop, logger) } -// inviteConfigOf returns the invitation and reset lifetimes the settings name over the account store. -func inviteConfigOf(settings runConfig, store *authkitpg.UserStore) authkit.InvitesConfig { +// inviteConfigOf returns the invitation and reset lifetimes the settings name over the store and roles compose built. +func inviteConfigOf(settings runConfig, built composed) authkit.InvitesConfig { return authkit.InvitesConfig{ - Store: store, + Store: built.users, InviteTTL: settings.inviteTTL, ResetTTL: settings.reset.ttl, ResetTokensLive: settings.reset.links, + Roles: built.roles, } } // graphResolver returns the core graph resolver over what compose built, the login handlers and the settings. func graphResolver(settings runConfig, built composed, auth *authkit.Handlers, logger *slog.Logger) *graphres.Resolver { - invites := inviteConfigOf(settings, built.users) + invites := inviteConfigOf(settings, built) return &graphres.Resolver{ Version: version.Version(), Contacts: built.contacts, @@ -111,7 +112,7 @@ func graphResolver(settings runConfig, built composed, auth *authkit.Handlers, l Events: built.events, Live: built.hub, Auth: auth, - Admin: authkit.NewAdmin(adminConfig(built.users)), + Admin: authkit.NewAdmin(adminConfig(built)), Invites: authkit.NewInvites(invites), Onboarding: postgres.NewOnboarding(built.pool, invites), Accounts: built.users, @@ -125,6 +126,7 @@ func graphResolver(settings runConfig, built composed, auth *authkit.Handlers, l Logger: logger, Paging: settings.paging, Screens: settings.screens, + Roles: built.roles, } } @@ -144,6 +146,7 @@ func serverConfigOf( cfg.PluginPublicPaths = host.PublicPaths() cfg.PluginAreas = pluginAreas(built.registered) cfg.FieldSources = fieldSources(built.registered) + cfg.Roles = built.roles cfg.Logger = logger if settings.webDir != "" { cfg.Web = os.DirFS(settings.webDir) @@ -162,35 +165,40 @@ func pluginAreas(registered []sdk.Plugin) map[string]string { return areas } -// authConfig returns the login configuration the server serves sessions under. -func authConfig(store *authkitpg.UserStore) authkit.Config { +// authConfig returns the login configuration the server serves sessions under, over the store and roles compose built. +func authConfig(built composed) authkit.Config { return authkit.Config{ - Store: store, - CookieName: server.SessionCookieName, - Privileged: role.Privileged(), + Store: built.users, + CookieName: server.SessionCookieName, + Privileged: built.roles.HoldersOf(role.ManageUsers), + Capabilities: built.roles, } } -// adminConfig returns the administration configuration guarding the privileged cover. -func adminConfig(store *authkitpg.UserStore) authkit.AdminConfig { - return authkit.AdminConfig{Store: store, Privileged: role.Privileged()} +// adminConfig returns the administration configuration guarding the privileged cover over the roles compose built. +func adminConfig(built composed) authkit.AdminConfig { + return authkit.AdminConfig{ + Store: built.users, + Privileged: built.roles.HoldersOf(role.ManageUsers), + Roles: built.roles, + } } -// declareRoles grants the registry every role a registered plugin declares. -func declareRoles(registry *role.Registry, registered []sdk.Plugin) error { +// errReservedID reports a plugin under the id the core grants its roles under. +var errReservedID = errors.New("the id is reserved for the core roles") + +// declareRoles declares every role a registered plugin declares, refusing a plugin under the core id. +func declareRoles(registry *goncierge.Registry, registered []sdk.Plugin) error { for _, plugin := range registered { + if plugin.ID() == role.Source { + return fmt.Errorf("plugin %s: %w", plugin.ID(), errReservedID) + } provider, ok := plugin.(sdk.RoleProvider) if !ok { continue } - for _, declared := range provider.Roles() { - capabilities := make([]role.Capability, 0, len(declared.Capabilities)) - for _, capability := range declared.Capabilities { - capabilities = append(capabilities, role.Capability(capability)) - } - if err := registry.Grant(role.Role(declared.Name), capabilities...); err != nil { - return fmt.Errorf("declare role %q for %s: %w", declared.Name, plugin.ID(), err) - } + if err := role.Declare(registry, plugin.ID(), provider.Roles()); err != nil { + return fmt.Errorf("plugin %s: %w", plugin.ID(), err) } } return nil diff --git a/cmd/alphone/seed_test.go b/cmd/alphone/seed_test.go index b2b74aaa..9b18661f 100644 --- a/cmd/alphone/seed_test.go +++ b/cmd/alphone/seed_test.go @@ -215,8 +215,8 @@ func TestSeedStandsAMemberBesideTheAdmin(t *testing.T) { if err != nil { t.Fatalf("UserByEmail() error = %v, want the seeded member", err) } - if tier := role.Of(member.Role); tier != role.Member { - t.Errorf("the seeded colleague stands in %v, want %v", tier, role.Member) + if member.Role != role.Member.String() { + t.Errorf("the seeded colleague stands in %q, want %q", member.Role, role.Member.String()) } if !strings.Contains(stdout.String(), seedMemberEmail) { t.Errorf("output = %q, want it to name the member the demo can sign in as", stdout.String()) diff --git a/cmd/alphone/wiring_guard_test.go b/cmd/alphone/wiring_guard_test.go index ed5cd7b0..40a5594b 100644 --- a/cmd/alphone/wiring_guard_test.go +++ b/cmd/alphone/wiring_guard_test.go @@ -3,16 +3,20 @@ package main import ( + "errors" "reflect" "slices" "testing" + "github.com/google/uuid" + "github.com/gopherium/framework/gonsole/testkit" "github.com/gopherium/framework/pluginkit" "github.com/gopherium/gouncer/authkit" authkitpg "github.com/gopherium/gouncer/authkit/postgres" "github.com/gopherium/alphone/internal/graphroot" + "github.com/gopherium/alphone/internal/role" ) // unwiredResolverFields names the graph resolver fields the server leaves to their defaults on purpose. @@ -67,7 +71,7 @@ func TestGraphRootCarriesEveryComposedValue(t *testing.T) { settings, built := composedOverEverySetting(t) - resolver := graphResolver(settings, built, authkit.New(authConfig(built.users)), discardLogger()) + resolver := graphResolver(settings, built, authkit.New(authConfig(built)), discardLogger()) if zero := zeroFields(resolver, unwiredResolverFields); len(zero) > 0 { t.Errorf("the graph resolver leaves %v zero, want each one wired or named among %v on purpose", @@ -84,7 +88,7 @@ func TestGraphResolverCarriesEveryListSetting(t *testing.T) { t.Fatalf("loadRunConfig() over the address alone error = %v, want nil", err) } - resolver := graphResolver(settings, built, authkit.New(authConfig(built.users)), discardLogger()) + resolver := graphResolver(settings, built, authkit.New(authConfig(built)), discardLogger()) if unnamed := unnamedFields(resolver.Paging, fallback.paging); len(unnamed) > 0 { t.Errorf("Paging leaves %v zero or at the default, want the values the settings name", unnamed) @@ -98,7 +102,7 @@ func TestServerConfigCarriesEveryComposedValue(t *testing.T) { t.Parallel() settings, built := composedOverEverySetting(t) - auth := authkit.New(authConfig(built.users)) + auth := authkit.New(authConfig(built)) graphRoot, err := graphroot.FromPlugins(graphResolver(settings, built, auth, discardLogger()), built.registered) if err != nil { t.Fatalf("FromPlugins() error = %v, want nil", err) @@ -112,6 +116,39 @@ func TestServerConfigCarriesEveryComposedValue(t *testing.T) { } } +func TestThePluginRolesReachTheGraphAndTheServer(t *testing.T) { + t.Parallel() + + settings, err := loadRunConfig(testkit.Getenv(filledSettings())) + if err != nil { + t.Fatalf("loadRunConfig() error = %v, want nil", err) + } + built, err := compose(t.Context(), composeConfigOf(t, filledSettings()), stewardDeclaring) + if err != nil || built.failed != nil { + t.Fatalf("compose() = %v with %v failed, want the steward plugin registered", err, built.failed) + } + abandonAtEnd(t, built) + auth := authkit.New(authConfig(built)) + + resolver := graphResolver(settings, built, auth, discardLogger()) + cfg := serverConfigOf(settings, built, pluginkit.NewHost(built.registered...), auth, nil, discardLogger()) + + if !resolver.Roles.Known("steward") { + t.Error("the graph resolver does not know steward, want the registry the plugins declared into") + } + if !cfg.Roles.Known("steward") { + t.Error("the server settings do not know steward, want the registry the plugins declared into") + } + err = resolver.Admin.SetAccountRole(t.Context(), uuid.New(), uuid.New(), "steward") + if errors.Is(err, authkit.ErrUnknownRole) { + t.Errorf("SetAccountRole(steward) error = %v, want the role the plugin declares known", err) + } + _, err = resolver.Invites.Invite(t.Context(), "maria.perez@example.com", "Maria Perez", "steward") + if errors.Is(err, authkit.ErrUnknownRole) { + t.Errorf("Invite(steward) error = %v, want the role the plugin declares known", err) + } +} + func TestServerConfigCarriesEveryGraphBound(t *testing.T) { t.Parallel() @@ -147,8 +184,10 @@ func TestInviteConfigCarriesEverySetting(t *testing.T) { t.Fatalf("loadRunConfig() over the address alone error = %v, want nil", err) } - held := reflect.ValueOf(inviteConfigOf(named, authkitpg.NewUserStore(nil))) - standing := reflect.ValueOf(inviteConfigOf(fallback, authkitpg.NewUserStore(nil))) + newComposed := func() composed { return composed{users: authkitpg.NewUserStore(nil), roles: role.NewRegistry()} } + + held := reflect.ValueOf(inviteConfigOf(named, newComposed())) + standing := reflect.ValueOf(inviteConfigOf(fallback, newComposed())) for i := range held.NumField() { if held.Field(i).IsZero() || held.Field(i).Interface() == standing.Field(i).Interface() { diff --git a/cmd/pluginwire/main.go b/cmd/pluginwire/main.go index 98e75f5b..1bc3a4bb 100644 --- a/cmd/pluginwire/main.go +++ b/cmd/pluginwire/main.go @@ -11,6 +11,8 @@ import ( "github.com/gopherium/framework/gonsole" "github.com/gopherium/framework/pluginkit/graphwire" "github.com/gopherium/framework/pluginkit/wire" + + "github.com/gopherium/alphone/internal/role" ) // roots lists the plugin root directories scanned in order. @@ -25,7 +27,7 @@ var config = wire.Config{ License: "Elastic-2.0", TSLicense: "AGPL-3.0-or-later", Roots: roots, - Reserved: append(gonsole.BaseCommands(), "account", "token"), + Reserved: append(gonsole.BaseCommands(), "account", "token", role.Source), GoRegistryPath: "internal/graphroot/registry_gen.go", GoRegistryPackage: "graphroot", diff --git a/cmd/pluginwire/wiring_test.go b/cmd/pluginwire/wiring_test.go index a1d1c702..04ba4541 100644 --- a/cmd/pluginwire/wiring_test.go +++ b/cmd/pluginwire/wiring_test.go @@ -34,6 +34,21 @@ func TestConfigRefusesAPluginIDTheCommandLineKeeps(t *testing.T) { } } +func TestConfigRefusesThePluginIDTheCoreRolesUse(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeTree(t, root) + writePluginIn(t, root, "plugins", "core", + `{"id": "core", "name": "Core", "backend": "github.com/gopherium/alphone/plugins/core"}`) + + err := wire.Run(root, config) + + if err == nil || !strings.Contains(err.Error(), `id "core" is reserved`) { + t.Errorf("wire.Run() error = %v, want the id core refused as reserved", err) + } +} + func TestRepositoryWiringIsUpToDate(t *testing.T) { t.Parallel() diff --git a/docs/src/content/docs/extending/commands.md b/docs/src/content/docs/extending/commands.md index 28007949..7100137b 100644 --- a/docs/src/content/docs/extending/commands.md +++ b/docs/src/content/docs/extending/commands.md @@ -117,7 +117,9 @@ exits 2 and prints its help page. Any other error exits 1. A plugin id may not be the name of a base command or of a core namespace: `help`, `list`, `version`, `check`, `serve`, `migrate`, -`seed`, `account` and `token`. `make generate` refuses such an id. +`seed`, `account` and `token`. Nor may it be `core`, the id the core +roles are granted under. `make generate` refuses such an id, and a +plugin whose code answers `core` stops the app at start. ## Flags a run must set @@ -231,9 +233,15 @@ password, only on `call.Stdin`. The roles AlphOne ships carry two capabilities, `manage_users` and `manage_webhooks`, both held by admin. A plugin that implements -`sdk.RoleProvider` can give a role a capability of its own. Name one no -role carries, and every account is refused. AlphOne sets `Authorize` -and `Record` for every plugin, so yours writes neither. +`sdk.RoleProvider` can give its roles those two, or a capability of its +own. A new capability starts with the plugin id and a dot, so the +archive plugin names one `archive.purge`. Admin does not get it unless +the plugin lists admin among its roles with that capability. A plugin +that declares a new capability without the prefix stops `serve`, +`migrate`, `seed -yes`, `account:create-admin` and the account commands +that take `-as`, and `alphone check` names it. Name a capability no role +carries in `Capability`, and every account is refused. AlphOne sets +`Authorize` and `Record` for every plugin, so yours writes neither. [Writing commands](https://docs.gopherium.org/command-line/writing-commands/#an-acting-account) shows both from the program's side. diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index ba30f6fe..652ae03b 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -548,7 +548,7 @@ The reasons the core answers with: | `self_role_refused` | | nobody changes its own role | | `last_privileged_refused` | | the last account able to manage users stays | | `role_beyond_reach` | | the role holds more than the caller does | -| `role_unknown` | | the role is not one the deployment names | +| `role_unknown` | `field` | the role is not one the deployment names | The stock plugins add their own: diff --git a/docs/src/content/docs/self-hosting/commands.md b/docs/src/content/docs/self-hosting/commands.md index f3ddba5a..7c2c64a8 100644 --- a/docs/src/content/docs/self-hosting/commands.md +++ b/docs/src/content/docs/self-hosting/commands.md @@ -344,10 +344,26 @@ capability admin carries, which are `manage_users` and `manage_webhooks`. A plugin role carrying `manage_users` alone can change members but not admins. +`account:enable` refuses an account holding no role, or a role that +nothing declares any more, such as one a removed plugin declared. It +exits 1, on a dry run too: + +| Error | Why | +| --- | --- | +| `the account maria@example.com holds no role, give it a declared role before enabling it` | It holds no role at all. | +| `the account maria@example.com holds the undeclared role steward, give it a declared role before enabling it` | Neither AlphOne nor a plugin declares its role. | + +Give the account a role with `account:role` first, then enable it. + The last enabled admin always stays. When two changes race, the one that would leave no enabled admin is refused with `
is the last enabled privileged account`. +`account:role`, `account:disable` and `account:enable` read the +account's role before they write. When someone changes that role in +between, the command changes nothing and stops with +`the role of changed after the check, run the command again`. + `-as` asks for no password. It names who answers for the change. Anyone who can run commands against the database can name any account, so keep access to the server as tight as before. diff --git a/go.mod b/go.mod index 0a2c3f2c..3d45a6c3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/gopherium/alphone -go 1.27.2 +go 1.27.3 require ( github.com/99designs/gqlgen v0.17.95 @@ -9,15 +9,17 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/gopherium/framework/dbkit v0.2.0 - github.com/gopherium/framework/dbkit/postgres v0.2.0 + github.com/gopherium/framework/dbkit/postgres v0.3.0 + github.com/gopherium/framework/goncierge v0.1.0 github.com/gopherium/framework/gonsole v0.7.0 - github.com/gopherium/framework/gonsole/auth v0.4.1 + github.com/gopherium/framework/gonsole/auth v0.9.0 + github.com/gopherium/framework/gonsole/auth/postgres v0.2.0 github.com/gopherium/framework/mailkit v0.1.0 github.com/gopherium/framework/pluginkit v0.6.0 github.com/gopherium/framework/pluginkit/graphwire v0.4.0 github.com/gopherium/gouncer v0.4.0 - github.com/gopherium/gouncer/authkit v0.16.0 - github.com/gopherium/gouncer/authkit/postgres v0.11.2 + github.com/gopherium/gouncer/authkit v0.23.0 + github.com/gopherium/gouncer/authkit/postgres v0.15.0 github.com/gopherium/gouncer/authkit/ratelimit v0.3.1 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jackc/pgx/v5 v5.11.0 diff --git a/go.sum b/go.sum index 5b744bb7..164d8d56 100644 --- a/go.sum +++ b/go.sum @@ -158,12 +158,16 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherium/framework/dbkit v0.2.0 h1:V+Di+B8oGvdmQKPx5q/a/PbgfHOvRAHhCFfQ5g8DojM= github.com/gopherium/framework/dbkit v0.2.0/go.mod h1:sGsNkpbHe1LRW7k1qEjTzqbeHVYOnoCI5e1l/81TAvQ= -github.com/gopherium/framework/dbkit/postgres v0.2.0 h1:me2xl+MdtitkwCUxVxrnXH0WJbGvi8tWHv4R0+PeESs= -github.com/gopherium/framework/dbkit/postgres v0.2.0/go.mod h1:ZUsvp2lOkhX+08sstBErt+jN6FM5HkhxV7hXgToRd+A= +github.com/gopherium/framework/dbkit/postgres v0.3.0 h1:W9SoXXu9Y3YRum5vIG10iBHlMMIhmbuUmOPLmZEnW3U= +github.com/gopherium/framework/dbkit/postgres v0.3.0/go.mod h1:ZUsvp2lOkhX+08sstBErt+jN6FM5HkhxV7hXgToRd+A= +github.com/gopherium/framework/goncierge v0.1.0 h1:9Zn2zxAuWTrhKeMEHNSncAb1jmjFrkEpVYXGdZrxTG8= +github.com/gopherium/framework/goncierge v0.1.0/go.mod h1:ekTJKyCbU0s659tjHvH/sjEZfPp2S4iuOGaLRRULZuA= github.com/gopherium/framework/gonsole v0.7.0 h1:JluS72WcE6LXcBihJB+5zdhX7IUiLZxv0g059z8T3/U= github.com/gopherium/framework/gonsole v0.7.0/go.mod h1:6ZLlrT2q5W8Pk2BrD7WgorL6b68nx2n2OX1Rg8FKwAo= -github.com/gopherium/framework/gonsole/auth v0.4.1 h1:0bzXtMIUv15FK3KmLIEPlriNbZKPVZgFpcx9v4QKrDU= -github.com/gopherium/framework/gonsole/auth v0.4.1/go.mod h1:yJki2uBbVE7DPEv5azg358Cfi2vP4I/MLfVZj/iYkXQ= +github.com/gopherium/framework/gonsole/auth v0.9.0 h1:k8mP0Uzbqio2KS4jw4n3+VHcaCde7q2fJdw/aCywHKo= +github.com/gopherium/framework/gonsole/auth v0.9.0/go.mod h1:Lms8wUhRpxvQd3M8kv1tpMS9Pox0Qh/M+1j4HjLq8PE= +github.com/gopherium/framework/gonsole/auth/postgres v0.2.0 h1:cGYiPUUfa0arwnvLdnWJshgLU/oXF+gYRAhZUvqc8nM= +github.com/gopherium/framework/gonsole/auth/postgres v0.2.0/go.mod h1:zNqYCZOIJ+j2RfncueejQ8Tklhvy4xQdWTrF9S8X+GQ= github.com/gopherium/framework/mailkit v0.1.0 h1:A1xm46dpo/zYrzV+Owa2W4Y0QJ1TBDNLkz8qwHF6238= github.com/gopherium/framework/mailkit v0.1.0/go.mod h1:iI4n+UOwJPLzczDBhJVmkd69euIIhxeVMhHJi+nRShg= github.com/gopherium/framework/pluginkit v0.6.0 h1:hE/w3Tg6Y0SyhEuBZXvkqGo4AvrgU0Lr3RZ7xsjHfAI= @@ -172,10 +176,10 @@ github.com/gopherium/framework/pluginkit/graphwire v0.4.0 h1:W+pXZA2i3OuHlyVrulG github.com/gopherium/framework/pluginkit/graphwire v0.4.0/go.mod h1:GQCjzF+zzmUsfRsKPQOlEqWahNJJ3x4BcPCMo0h4Ao8= github.com/gopherium/gouncer v0.4.0 h1:dkbCD5oeYuuzR961UKGsAvnTvF8vTstlFbN94rP4TvI= github.com/gopherium/gouncer v0.4.0/go.mod h1:WwPOu8LyRikot0EODySSu46zaHcWTPbl5nOes4I3H2U= -github.com/gopherium/gouncer/authkit v0.16.0 h1:UExNLvPz2baly9qGpCpPqkzwodd0lUISMHalPi006HY= -github.com/gopherium/gouncer/authkit v0.16.0/go.mod h1:HD9ZkKl1Inm8Zfkayp8VMogXvQ6qkWx9OuAigcgptg8= -github.com/gopherium/gouncer/authkit/postgres v0.11.2 h1:ZwGY20+/qfdL8irzNwXMOQC2f5QWq6KFajNWIaGU3qE= -github.com/gopherium/gouncer/authkit/postgres v0.11.2/go.mod h1:60KQIrXfnltT+92/8wLEdlU5Mfe8FPxPJ5NFj73vxL8= +github.com/gopherium/gouncer/authkit v0.23.0 h1:NgcBeNr2kwbsp3HoH8aKCEIxbAH6o4FCDmJ51pLODLI= +github.com/gopherium/gouncer/authkit v0.23.0/go.mod h1:oJP/pk0xnXA4HhOlFoqm4lh9ulScCUbgGeLFrquZ6g8= +github.com/gopherium/gouncer/authkit/postgres v0.15.0 h1:sGd549R/tEVotCAYEh7tdcAYoU3XYusFM/1JZWynQLs= +github.com/gopherium/gouncer/authkit/postgres v0.15.0/go.mod h1:4mayLFD2XPhLfgTNI2qk3Fyu8Cekyne4RQcq3g/dujI= github.com/gopherium/gouncer/authkit/ratelimit v0.3.1 h1:l+YGVBqG1FDxIl/G1WaoIPS/jbsUicQRg0p1NYWxk9s= github.com/gopherium/gouncer/authkit/ratelimit v0.3.1/go.mod h1:BXea+ZZJUypviIcWmb788rQSBpqW5VcGvovlJPOblqw= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= diff --git a/graph/scope_test.go b/graph/scope_test.go index 502d895d..cf2c84ee 100644 --- a/graph/scope_test.go +++ b/graph/scope_test.go @@ -4,6 +4,7 @@ package graph_test import ( "fmt" + "maps" "os" "path/filepath" "slices" @@ -114,7 +115,7 @@ func capabilityProblems( if ownsCapabilities { return nil } - if !slices.Contains(role.Capabilities(), role.Capability(needed.Value.Raw)) { + if !slices.Contains(role.NewRegistry().Capabilities(), needed.Value.Raw) { return []string{fmt.Sprintf("%s.%s needs capability %q, which the role table does not know", operation, field.Name, needed.Value.Raw)} } @@ -186,9 +187,20 @@ extend type Mutation { two: String! @scope(area: "users", write: true, admin: fa } // fieldsNeeding names every core root field whose scope declares capability. -func fieldsNeeding(t *testing.T, capability role.Capability) map[string]bool { +func fieldsNeeding(t *testing.T, capability string) map[string]bool { t.Helper() reserved := map[string]bool{} + for field, needed := range coreGatedFields(t) { + if needed == capability { + reserved[field] = true + } + } + return reserved +} + +// coreGatedFields answers the capability each gated core root field needs, keyed by the field. +func coreGatedFields(t *testing.T) map[string]string { + t.Helper() files, err := filepath.Glob(filepath.Join("schema", "*.graphqls")) if err != nil { t.Fatalf("globbing the core schema: %v", err) @@ -196,16 +208,15 @@ func fieldsNeeding(t *testing.T, capability role.Capability) map[string]bool { if len(files) == 0 { t.Fatal("globbed 0 core schema files, the glob is broken") } + gated := map[string]string{} for _, path := range files { raw, err := os.ReadFile(path) if err != nil { t.Fatalf("reading %s: %v", path, err) } - for _, field := range gatedFieldsIn(t, path, string(raw), capability) { - reserved[field] = true - } + maps.Copy(gated, gatedFieldsIn(t, path, string(raw))) } - return reserved + return gated } func TestOnlyWebhookCreationNeedsTheManageWebhooksCapability(t *testing.T) { @@ -242,14 +253,14 @@ func TestOnlyUserManagementNeedsTheManageUsersCapability(t *testing.T) { } } -// gatedFieldsIn names the root fields of one SDL source needing capability. -func gatedFieldsIn(t *testing.T, name, source string, capability role.Capability) []string { +// gatedFieldsIn answers the capability each gated root field of one SDL source needs, keyed by the field. +func gatedFieldsIn(t *testing.T, name, source string) map[string]string { t.Helper() doc, err := parser.ParseSchema(&ast.Source{Name: name, Input: source}) if err != nil { t.Fatalf("parsing %s: %v", name, err) } - var reserved []string + gated := map[string]string{} for _, def := range append(doc.Definitions, doc.Extensions...) { if !rootOperations[def.Name] { continue @@ -259,13 +270,12 @@ func gatedFieldsIn(t *testing.T, name, source string, capability role.Capability if declared == nil { continue } - needed := declared.Arguments.ForName("capability") - if needed != nil && needed.Value.Raw == string(capability) { - reserved = append(reserved, field.Name) + if needed := declared.Arguments.ForName("capability"); needed != nil { + gated[field.Name] = needed.Value.Raw } } } - return reserved + return gated } func TestScopeCheckingFlagsACapabilityTheTableDoesNotKnow(t *testing.T) { diff --git a/internal/graphres/auth.go b/internal/graphres/auth.go index 9eb09a1f..e414e33b 100644 --- a/internal/graphres/auth.go +++ b/internal/graphres/auth.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/gouncer/authkit" "github.com/gopherium/alphone/graph/model" @@ -31,20 +32,15 @@ func (e rateLimitedError) Error() string { return "too many login attempts, try again later" } -// toAuthIdentity maps an authkit identity onto its graph model. -func toAuthIdentity(identity authkit.Identity, tier role.Role) *model.Identity { - grantable := role.Grantable(tier) - named := make([]string, 0, len(grantable)) - for _, held := range grantable { - named = append(named, held.String()) - } +// toAuthIdentity maps an authkit identity onto its graph model, with what the registry gives its role. +func toAuthIdentity(identity authkit.Identity, roles *goncierge.Registry) *model.Identity { return &model.Identity{ ID: identity.ID, Email: identity.Email, Name: identity.Name, - Role: tier.String(), - Capabilities: role.CapabilitiesOf(tier), - Grantable: named, + Role: identity.Role, + Capabilities: roles.CapabilitiesOf(identity.Role), + Grantable: roles.Grantable(identity.Role), } } @@ -63,8 +59,7 @@ func toUser(account authkit.Account, tier role.Role) *model.User { // Me reports the calling identity. func (q QueryResolvers) Me(ctx context.Context) (*model.Identity, error) { - identity := authkit.IdentityFromContext(ctx) - return toAuthIdentity(identity, role.Role(identity.Role)), nil + return toAuthIdentity(authkit.IdentityFromContext(ctx), q.root.Roles), nil } // Users lists the accounts standing in the caller's tenant. @@ -141,7 +136,7 @@ func (m MutationResolvers) Login(ctx context.Context, email, password string) (* if err := setResponseCookie(ctx, cookie); err != nil { return nil, err } - return &model.LoginPayload{Me: toAuthIdentity(identity, role.Role(identity.Role))}, nil + return &model.LoginPayload{Me: toAuthIdentity(identity, m.root.Roles)}, nil } // refuseDeactivatedTenant refuses a caller whose standing tenant is deactivated. @@ -183,32 +178,20 @@ func (m MutationResolvers) SetUserDisabled(ctx context.Context, id uuid.UUID, di if disabled && actor.ID == id { return false, authkit.ErrSelfDisable } - if err := m.root.outranking(ctx, actor, id); err != nil { - return false, err - } - if err := m.root.Admin.SetAccountDisabled(ctx, actor.ID, id, disabled); err != nil { - return false, err - } - return true, nil + return m.root.writeWithinReach(ctx, actor, id, func() error { + return m.root.Admin.SetAccountDisabled(ctx, actor.ID, id, disabled) + }) } // SetUserRole stands one user in the tier it names. func (m MutationResolvers) SetUserRole(ctx context.Context, id uuid.UUID, tier string) (bool, error) { - stood, err := role.Parse(tier) - if err != nil { - return false, err - } actor := authkit.IdentityFromContext(ctx) - if !role.Outranks(role.Role(actor.Role), stood) { - return false, role.ErrBeyondReach - } - if err := m.root.outranking(ctx, actor, id); err != nil { + if err := m.root.refuseGrant(actor.Role, tier); err != nil { return false, err } - if err := m.root.Admin.SetAccountRole(ctx, actor.ID, id, stood.String()); err != nil { - return false, err - } - return true, nil + return m.root.writeWithinReach(ctx, actor, id, func() error { + return m.root.Admin.SetAccountRole(ctx, actor.ID, id, tier) + }) } // setResponseCookie writes cookie onto the serving response. diff --git a/internal/graphres/auth_test.go b/internal/graphres/auth_test.go index c4a753cb..63ef15b2 100644 --- a/internal/graphres/auth_test.go +++ b/internal/graphres/auth_test.go @@ -35,6 +35,7 @@ func newAuthResolver(store *testkit.Store) *graphres.Resolver { Accounts: store, LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{Limit: 2, Window: time.Minute}), TokenLimiter: ratelimit.NewLimiter(ratelimit.Config{Limit: 8, Window: time.Minute}), + Roles: stewardRoles(), } } diff --git a/internal/graphres/branches_test.go b/internal/graphres/branches_test.go index 51a5776c..963af0cc 100644 --- a/internal/graphres/branches_test.go +++ b/internal/graphres/branches_test.go @@ -66,7 +66,7 @@ func scopedContext() context.Context { // newStubResolver returns a resolver over the given stub stores without events. func newStubResolver(contacts *stubContactStore, tasks *stubTaskStore) *graphres.Resolver { - return &graphres.Resolver{Version: "9.9.9", Contacts: contacts, Tasks: tasks} + return &graphres.Resolver{Version: "9.9.9", Contacts: contacts, Tasks: tasks, Roles: stewardRoles()} } func TestContactsQueryValidatesArguments(t *testing.T) { diff --git a/internal/graphres/errors.go b/internal/graphres/errors.go index 302ec2ff..3e0a9ced 100644 --- a/internal/graphres/errors.go +++ b/internal/graphres/errors.go @@ -18,7 +18,6 @@ import ( "github.com/gopherium/alphone/internal/cursor" "github.com/gopherium/alphone/internal/event" "github.com/gopherium/alphone/internal/locale" - "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/task" "github.com/gopherium/alphone/internal/webhook" "github.com/gopherium/alphone/sdk" @@ -45,9 +44,7 @@ var validationErrors = []error{ authkit.ErrSelfDisable, authkit.ErrSelfRole, gouncer.ErrLastPrivileged, - role.ErrLastAdmin, - role.ErrBeyondReach, - role.ErrUnknownTier, + authkit.ErrBeyondReach, apitoken.ErrEmptyName, apitoken.ErrMalformedScope, apitoken.ErrNoScopes, @@ -91,9 +88,7 @@ var reasonsFor = []struct { {authkit.ErrSelfDisable, "self_disable_refused", nil}, {authkit.ErrSelfRole, "self_role_refused", nil}, {gouncer.ErrLastPrivileged, "last_privileged_refused", nil}, - {role.ErrLastAdmin, "last_privileged_refused", nil}, - {role.ErrBeyondReach, "role_beyond_reach", nil}, - {role.ErrUnknownTier, "role_unknown", nil}, + {authkit.ErrBeyondReach, "role_beyond_reach", nil}, {apitoken.ErrEmptyName, "token_name_required", nil}, {apitoken.ErrMalformedScope, "scope_malformed", nil}, {apitoken.ErrNoScopes, "scopes_required", nil}, @@ -136,8 +131,8 @@ var spokenAs = []struct { }{ {authkit.ErrSelfRole, "you cannot change your own role"}, {authkit.ErrSelfDisable, "you cannot disable your own account"}, - {gouncer.ErrLastPrivileged, role.ErrLastAdmin.Error()}, - {role.ErrBeyondReach, "that role is beyond your own"}, + {gouncer.ErrLastPrivileged, "the last admin cannot be unseated"}, + {authkit.ErrBeyondReach, "that role is beyond your own"}, } // speak rewrites a brick error so no package name reaches a caller. diff --git a/internal/graphres/errors_internal_test.go b/internal/graphres/errors_internal_test.go index 5b8ba1af..0c5839d3 100644 --- a/internal/graphres/errors_internal_test.go +++ b/internal/graphres/errors_internal_test.go @@ -17,7 +17,6 @@ import ( "github.com/gopherium/gouncer/authkit" "github.com/gopherium/alphone/internal/contact" - "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/sdk" ) @@ -49,18 +48,6 @@ func TestEveryCoreReasonHasAFrontendMessage(t *testing.T) { } } -func TestBothLastPrivilegedSentinelsShareOneReason(t *testing.T) { - t.Parallel() - - core := PresentError(context.Background(), fmt.Errorf("wrap: %w", role.ErrLastAdmin)) - brick := PresentError(context.Background(), fmt.Errorf("wrap: %w", gouncer.ErrLastPrivileged)) - - if core.Extensions["reason"] != brick.Extensions["reason"] { - t.Errorf("reasons %v and %v differ, want one condition named once", - core.Extensions["reason"], brick.Extensions["reason"]) - } -} - func TestEverySpecialPathNamesItsReason(t *testing.T) { t.Parallel() diff --git a/internal/graphres/errors_test.go b/internal/graphres/errors_test.go index cd25ff01..d8855ba6 100644 --- a/internal/graphres/errors_test.go +++ b/internal/graphres/errors_test.go @@ -19,7 +19,6 @@ import ( "github.com/gopherium/alphone/internal/contact" "github.com/gopherium/alphone/internal/event" "github.com/gopherium/alphone/internal/graphres" - "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/task" "github.com/gopherium/alphone/internal/webhook" "github.com/gopherium/alphone/sdk" @@ -80,7 +79,7 @@ func TestPresentErrorSpeaksTheBrickErrorsInItsOwnVoice(t *testing.T) { {"own role", authkit.ErrSelfRole, "you cannot change your own role"}, {"own account", authkit.ErrSelfDisable, "you cannot disable your own account"}, {"last privileged", gouncer.ErrLastPrivileged, "the last admin cannot be unseated"}, - {"beyond reach", role.ErrBeyondReach, "that role is beyond your own"}, + {"beyond reach", authkit.ErrBeyondReach, "that role is beyond your own"}, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { @@ -107,7 +106,7 @@ func TestEverySpokenErrorKeepsItsMessage(t *testing.T) { authkit.ErrSelfRole, authkit.ErrSelfDisable, gouncer.ErrLastPrivileged, - role.ErrBeyondReach, + authkit.ErrBeyondReach, } { presented := graphres.PresentError(context.Background(), spoken) diff --git a/internal/graphres/graphres.go b/internal/graphres/graphres.go index 6bb26bcf..c1f99efe 100644 --- a/internal/graphres/graphres.go +++ b/internal/graphres/graphres.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/gouncer" "github.com/gopherium/gouncer/authkit" @@ -18,7 +19,6 @@ import ( "github.com/gopherium/alphone/internal/apitoken" "github.com/gopherium/alphone/internal/contact" "github.com/gopherium/alphone/internal/event" - "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/task" "github.com/gopherium/alphone/internal/tenant" "github.com/gopherium/alphone/internal/webhook" @@ -73,7 +73,7 @@ type WebhookStore interface { DeleteWorkspaceSubscription(ctx context.Context, id uuid.UUID) error } -// outranking refuses an actor writing an account of another tenant or holding a capability it lacks. +// outranking refuses an actor writing an account of another tenant or one whose declared role carries more. func (r *Resolver) outranking(ctx context.Context, actor authkit.Identity, target uuid.UUID) error { held, err := r.Admin.ListAccounts(ctx) if err != nil { @@ -90,14 +90,38 @@ func (r *Resolver) outranking(ctx context.Context, actor authkit.Identity, targe if placedIn(placed, actor.ID) != placedIn(placed, account.ID) { return gouncer.ErrUserNotFound } - if !role.Outranks(role.Role(actor.Role), role.Role(account.Role)) { - return role.ErrBeyondReach + if r.Roles.Known(account.Role) && !r.Roles.Outranks(actor.Role, account.Role) { + return authkit.ErrBeyondReach } return nil } return gouncer.ErrUserNotFound } +// writeWithinReach runs the write once the actor reaches the account, answering whether it applied. +func (r *Resolver) writeWithinReach( + ctx context.Context, actor authkit.Identity, target uuid.UUID, write func() error, +) (bool, error) { + if err := r.outranking(ctx, actor, target); err != nil { + return false, err + } + if err := write(); err != nil { + return false, err + } + return true, nil +} + +// refuseGrant refuses an actor granting a role the registry does not declare or one carrying more than its own. +func (r *Resolver) refuseGrant(actor, tier string) error { + if !r.Roles.Known(tier) { + return authkit.ErrUnknownRole + } + if !r.Roles.Outranks(actor, tier) { + return authkit.ErrBeyondReach + } + return nil +} + // sharesTenant reports whether the actor and the target stand in one tenant. func (r *Resolver) sharesTenant(ctx context.Context, actor, target uuid.UUID) (bool, error) { placed, err := r.tenantsOf(ctx, []uuid.UUID{actor, target}) @@ -189,6 +213,8 @@ type Resolver struct { Auth *authkit.Handlers // Admin serves user administration through the authkit seams. Admin *authkit.AdminHandlers + // Roles answers which roles the deployment declares and the capabilities each carries. + Roles *goncierge.Registry // Settings reads and writes the caller's stored preferences. Settings SettingStore // LoginLimiter counts failed logins per client IP. diff --git a/internal/graphres/invite.go b/internal/graphres/invite.go index 9a14be6f..ca19744e 100644 --- a/internal/graphres/invite.go +++ b/internal/graphres/invite.go @@ -29,19 +29,14 @@ func deliveredPayload() *model.InvitePayload { func (m MutationResolvers) Invite( ctx context.Context, email, name string, named *string, ) (*model.InvitePayload, error) { - stood := role.Member + stood := role.Member.String() if named != nil { - parsed, err := role.Parse(*named) - if err != nil { - return nil, err - } - stood = parsed + stood = *named } - actor := authkit.IdentityFromContext(ctx) - if !role.Outranks(role.Role(actor.Role), stood) { - return nil, role.ErrBeyondReach + if err := m.root.refuseGrant(authkit.IdentityFromContext(ctx).Role, stood); err != nil { + return nil, err } - token, err := m.root.Onboarding.InviteInto(ctx, sdk.TenantOrDefault(ctx), email, name, stood.String()) + token, err := m.root.Onboarding.InviteInto(ctx, sdk.TenantOrDefault(ctx), email, name, stood) if errors.Is(err, gouncer.ErrEmailTaken) { return m.resend(ctx, email) } @@ -127,7 +122,7 @@ func (m MutationResolvers) AcceptInvite( if err != nil { return nil, err } - return &model.LoginPayload{Me: toAuthIdentity(identity, role.Role(identity.Role))}, nil + return &model.LoginPayload{Me: toAuthIdentity(identity, m.root.Roles)}, nil } // RequestPasswordReset asks for a reset link, answering nothing about the address. diff --git a/internal/graphres/invite_test.go b/internal/graphres/invite_test.go index ee970028..834f7911 100644 --- a/internal/graphres/invite_test.go +++ b/internal/graphres/invite_test.go @@ -102,6 +102,7 @@ func newInviteResolver(store *testkit.Store, mailer graphres.Mailer) *graphres.R LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{Limit: 2, Window: time.Minute}), TokenLimiter: ratelimit.NewLimiter(ratelimit.Config{Limit: 2, Window: time.Minute}), ResetLimiter: ratelimit.NewLimiter(ratelimit.Config{Limit: 2, Window: time.Minute}), + Roles: stewardRoles(), } if mailer != nil { held.Mailer = mailer diff --git a/internal/graphres/inviteplacement_test.go b/internal/graphres/inviteplacement_test.go index 56516a98..910f7bdb 100644 --- a/internal/graphres/inviteplacement_test.go +++ b/internal/graphres/inviteplacement_test.go @@ -32,6 +32,7 @@ func newPlacingResolver(pool *pgxpool.Pool) *graphres.Resolver { Accounts: users, Tenants: postgres.NewTenantStore(pool), PublicURL: "https://crm.example.com", + Roles: stewardRoles(), } } diff --git a/internal/graphres/mutations_test.go b/internal/graphres/mutations_test.go index 107363b6..1688c025 100644 --- a/internal/graphres/mutations_test.go +++ b/internal/graphres/mutations_test.go @@ -77,6 +77,7 @@ func newMutationHarness(t *testing.T) mutationHarness { Tasks: postgres.NewTaskStore(pool), Webhooks: postgres.NewWebhookStore(pool), Events: events, + Roles: stewardRoles(), } assignee := uuid.Must(uuid.NewV7()) return mutationHarness{ diff --git a/internal/graphres/roles_test.go b/internal/graphres/roles_test.go index d9c1cb84..26c2dace 100644 --- a/internal/graphres/roles_test.go +++ b/internal/graphres/roles_test.go @@ -4,6 +4,7 @@ package graphres_test import ( "context" + "encoding/json" "errors" "fmt" "slices" @@ -12,6 +13,7 @@ import ( gqlclient "github.com/99designs/gqlgen/client" "github.com/google/uuid" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/gouncer" "github.com/gopherium/gouncer/authkit" "github.com/gopherium/gouncer/authkit/testkit" @@ -23,17 +25,23 @@ import ( // stewardRole is the plugin declared role standing above admin in these tests. const stewardRole role.Role = "steward" +// retiredRole is a role an account still holds after every source stopped declaring it. +const retiredRole = "auditor" + // errListing reports a store that cannot list the accounts. var errListing = errors.New("accounts unavailable") // errWriting reports a store that cannot store a role. var errWriting = errors.New("role unwritable") -// init declares the plugin role once, since the registry a resolver reads is the deployment's. -func init() { - if err := role.Grant(stewardRole, role.ManageUsers, role.ManageWebhooks, "manage_reports"); err != nil { +// stewardRoles returns the core registry beside the steward role a plugin grants above admin. +func stewardRoles() *goncierge.Registry { + roles := role.NewRegistry() + if err := roles.Grant("steward", stewardRole.String(), + role.ManageUsers, role.ManageWebhooks, "manage_reports"); err != nil { panic(err) } + return roles } // roledStore returns a store holding one account under the given tier. @@ -71,6 +79,28 @@ func settingDisabled(userID uuid.UUID, disabled bool) string { return fmt.Sprintf(`mutation { setUserDisabled(id: %q, disabled: %t) }`, userID, disabled) } +// wantUnknownRole checks the first error answers an undeclared role with authkit's message, reason and field. +func wantUnknownRole(t *testing.T, raw json.RawMessage) { + t.Helper() + var refused []struct { + Message string `json:"message"` + Extensions map[string]any `json:"extensions"` + } + if err := json.Unmarshal(raw, &refused); err != nil || len(refused) == 0 { + t.Fatalf("errors = %s, want the undeclared role refused", raw) + } + if got := refused[0].Message; got != "unknown role" { + t.Errorf("message = %q, want unknown role", got) + } + if got := refused[0].Extensions["reason"]; got != "role_unknown" { + t.Errorf("reason = %v, want role_unknown", got) + } + meta, _ := refused[0].Extensions["meta"].(map[string]any) + if meta["field"] != "role" { + t.Errorf("meta = %v, want the field the undeclared role came in named as data", meta) + } +} + func TestUsersCarriesEachAccountsTier(t *testing.T) { t.Parallel() @@ -205,6 +235,7 @@ func TestInviteRefusesARoleTheRegistryDoesNotKnow(t *testing.T) { if got := firstErrorCode(t, answered.Errors); got != "VALIDATION" { t.Errorf("code = %q, want VALIDATION", got) } + wantUnknownRole(t, answered.Errors) } func TestInviteRefusesARoleBeyondTheCallersReach(t *testing.T) { @@ -241,7 +272,7 @@ func TestMeAnswersTheCapabilitiesTheRoleHolds(t *testing.T) { } client.MustPost(`{ me { capabilities grantable } }`, &answered) - want := []string{string(role.ManageUsers), string(role.ManageWebhooks)} + want := []string{role.ManageUsers, role.ManageWebhooks} if !slices.Equal(answered.Me.Capabilities, want) { t.Errorf("capabilities = %v, want the admin's %v", answered.Me.Capabilities, want) } @@ -334,6 +365,7 @@ func TestSetUserRoleRefusesATierNoDeploymentKnows(t *testing.T) { if got := firstErrorCode(t, answered.Errors); got != "VALIDATION" { t.Errorf("code = %q, want VALIDATION", got) } + wantUnknownRole(t, answered.Errors) if got := store.Users[held.ID].Role; got != role.Member.String() { t.Errorf("stored role = %q, want the refused write to store nothing", got) } @@ -381,6 +413,46 @@ func TestSetUserRoleRefusesTouchingAnAccountBeyondTheCallersReach(t *testing.T) } } +func TestAnAdminReachesAnAccountWhoseRoleNobodyDeclares(t *testing.T) { + t.Parallel() + + movingToMember := func(id uuid.UUID) string { return settingRole(id, role.Member.String()) } + holdsMember := func(account gouncer.User) bool { return account.Role == role.Member.String() } + barring := func(id uuid.UUID) string { return settingDisabled(id, true) } + barred := func(account gouncer.User) bool { return account.Disabled } + cases := []struct { + name string + stored role.Role + mutation func(uuid.UUID) string + applied func(gouncer.User) bool + }{ + {"setUserRole on an account holding no role", "", movingToMember, holdsMember}, + {"setUserRole on an account holding a retired role", retiredRole, movingToMember, holdsMember}, + {"setUserDisabled on an account holding a retired role", retiredRole, barring, barred}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store, held := roledStore(t, tc.stored) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, newAuthResolver(store), actor) + + answered, err := client.RawPost(tc.mutation(held.ID)) + + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) + } + if len(answered.Errors) != 0 { + t.Errorf("errors = %s, want an admin able to write the account", answered.Errors) + } + if got := store.Users[held.ID]; !tc.applied(got) { + t.Errorf("stored role = %q, disabled = %t, want the write applied", got.Role, got.Disabled) + } + }) + } +} + func TestSetUserDisabledRefusesBarringItself(t *testing.T) { t.Parallel() diff --git a/internal/graphres/scope.go b/internal/graphres/scope.go index 75f7fbde..c537c7bb 100644 --- a/internal/graphres/scope.go +++ b/internal/graphres/scope.go @@ -9,6 +9,7 @@ import ( "github.com/vektah/gqlparser/v2/ast" "github.com/vektah/gqlparser/v2/gqlerror" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/gouncer/authkit" "github.com/gopherium/alphone/internal/apitoken" @@ -45,7 +46,7 @@ type fieldScope struct { area string write bool admin bool - capability role.Capability + capability string } // ScopeMap answers what every root field of a schema needs of its caller. @@ -86,7 +87,7 @@ func scopeOf(declared *ast.Directive) fieldScope { } if needed := declared.Arguments.ForName(capabilityArgument); needed != nil && needed.Value.Kind != ast.NullValue { - scope.capability = role.Capability(needed.Value.Raw) + scope.capability = needed.Value.Raw } return scope } @@ -103,7 +104,7 @@ func (m ScopeMap) AdminOnly(operation ast.Operation, field string) bool { } // Capability returns the capability one root field needs, none when it declares no gate. -func (m ScopeMap) Capability(operation ast.Operation, field string) role.Capability { +func (m ScopeMap) Capability(operation ast.Operation, field string) string { held := m[scopeKey{operation, field}] if held.capability != "" { return held.capability @@ -160,17 +161,16 @@ func TenantDeactivated(ctx context.Context) bool { } // ScopeGate returns the operation gate refusing every root field the caller's role and token do not reach. -func ScopeGate(scopes ScopeMap) graphql.OperationMiddleware { +func ScopeGate(scopes ScopeMap, roles *goncierge.Registry) graphql.OperationMiddleware { return func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler { token, carried := credential.TokenOf(ctx) - tier := role.Role(authkit.IdentityFromContext(ctx).Role) operation := graphql.GetOperationContext(ctx) if operation.Operation == nil { return scopeError("the operation") } kind := operation.Operation.Operation for _, selected := range graphql.CollectFields(operation, operation.Operation.SelectionSet, nil) { - if refused := fieldRefusal(ctx, scopes, kind, selected.Name, token, carried, tier); refused != nil { + if refused := fieldRefusal(ctx, scopes, roles, kind, selected.Name, token, carried); refused != nil { return refused } } @@ -180,14 +180,15 @@ func ScopeGate(scopes ScopeMap) graphql.OperationMiddleware { // fieldRefusal returns the answer refusing one selected root field, nil when the caller reaches it. func fieldRefusal( - ctx context.Context, scopes ScopeMap, kind ast.Operation, name string, - token credential.Token, carried bool, tier role.Role, + ctx context.Context, scopes ScopeMap, roles *goncierge.Registry, kind ast.Operation, name string, + token credential.Token, carried bool, ) graphql.ResponseHandler { if carried && !scopes.Allows(kind, name, token.Scopes) { return scopeError(scopes.Needed(kind, name)) } - if needed := scopes.Capability(kind, name); needed != "" && !role.Can(tier, needed) { - return capabilityError(scopes.Needed(kind, name), needed) + needed := scopes.Capability(kind, name) + if needed != "" && !roles.Can(authkit.IdentityFromContext(ctx).Role, needed) { + return capabilityError(scopes, kind, name) } if TenantDeactivated(ctx) && scopes.WritesBeyondAuth(kind, name) { return deactivatedError() @@ -211,16 +212,17 @@ func scopeError(needed string) graphql.ResponseHandler { return refusedWith("scope required: "+needed, needed) } -// capabilityError answers one operation naming the scope and the capability the caller's role lacked. -func capabilityError(needed string, lacked role.Capability) graphql.ResponseHandler { +// capabilityError answers one operation naming the scope and the capability of one field the caller's role lacked. +func capabilityError(scopes ScopeMap, kind ast.Operation, name string) graphql.ResponseHandler { + needed, lacked := scopes.Needed(kind, name), scopes.Capability(kind, name) return graphql.OneShot(&graphql.Response{Errors: gqlerror.List{&gqlerror.Error{ Message: "admin required", Extensions: map[string]any{ "code": "UNAUTHORIZED", "scope": needed, - "capability": string(lacked), + "capability": lacked, "reason": "capability_missing", - "meta": map[string]any{"scope": needed, "capability": string(lacked)}, + "meta": map[string]any{"scope": needed, "capability": lacked}, }, }}}) } diff --git a/internal/graphres/scopegate_test.go b/internal/graphres/scopegate_test.go index 30665134..e5c744bf 100644 --- a/internal/graphres/scopegate_test.go +++ b/internal/graphres/scopegate_test.go @@ -72,7 +72,7 @@ func gatedWith(t *testing.T, query string, ctx context.Context) *graphql.Respons ctx = graphql.WithOperationContext(ctx, &graphql.OperationContext{ Doc: doc, Operation: doc.Operations[0], Variables: map[string]any{}, }) - return graphres.ScopeGate(graphres.NewScopeMap(schema))(ctx, passed)(ctx) + return graphres.ScopeGate(graphres.NewScopeMap(schema), stewardRoles())(ctx, passed)(ctx) } // errorOf returns the single error message of a refused answer. @@ -464,7 +464,7 @@ func TestScopeGateRefusesAnOperationItCannotRead(t *testing.T) { ctx := credential.WithToken(t.Context(), credential.Token{Name: "probe", Scopes: apitoken.Full()}) ctx = graphql.WithOperationContext(ctx, &graphql.OperationContext{}) - answered := graphres.ScopeGate(graphres.NewScopeMap(loadScopedSchema(t)))(ctx, passed)(ctx) + answered := graphres.ScopeGate(graphres.NewScopeMap(loadScopedSchema(t)), stewardRoles())(ctx, passed)(ctx) if len(answered.Errors) != 1 { t.Errorf("errors = %v, want an error when there is no operation to read", answered.Errors) diff --git a/internal/graphres/webhookholders_test.go b/internal/graphres/webhookholders_test.go index ea62d59e..46ce1852 100644 --- a/internal/graphres/webhookholders_test.go +++ b/internal/graphres/webhookholders_test.go @@ -65,7 +65,7 @@ func TestAWebhookHolderListsEveryWebhookOfTheWorkspaceWithItsOwner(t *testing.T) store := postgres.NewWebhookStore(pool) member := webhookOwner(t, pool, "member@example.com") ownedWebhook(t, store, member, "https://example.com/member") - resolver := &graphres.Resolver{Version: "9.9.9", Webhooks: store} + resolver := &graphres.Resolver{Version: "9.9.9", Webhooks: store, Roles: stewardRoles()} admin := newActingClient(t, resolver, authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()}) var listed holderListing @@ -83,7 +83,9 @@ func TestAWebhookHolderListsEveryWebhookOfTheWorkspaceWithItsOwner(t *testing.T) func TestCreatingAWebhookAnswersItsOwner(t *testing.T) { t.Parallel() - resolver := &graphres.Resolver{Version: "9.9.9", Webhooks: postgres.NewWebhookStore(newTestPool(t))} + resolver := &graphres.Resolver{ + Version: "9.9.9", Webhooks: postgres.NewWebhookStore(newTestPool(t)), Roles: stewardRoles(), + } caller := authkit.Identity{ ID: uuid.Must(uuid.NewV7()), Name: "Maria Perez", Email: "admin@example.com", Role: role.Admin.String(), } @@ -116,7 +118,7 @@ func TestAWebhookHolderDeletesAnotherAccountsWebhook(t *testing.T) { store := postgres.NewWebhookStore(pool) member := webhookOwner(t, pool, "member@example.com") id := ownedWebhook(t, store, member, "https://example.com/member") - resolver := &graphres.Resolver{Version: "9.9.9", Webhooks: store} + resolver := &graphres.Resolver{Version: "9.9.9", Webhooks: store, Roles: stewardRoles()} admin := newActingClient(t, resolver, authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()}) var deleted struct { @@ -142,7 +144,7 @@ func TestAnAccountWithoutTheCapabilityManagesOnlyItsOwnWebhooks(t *testing.T) { other := webhookOwner(t, pool, "other@example.com") ownedWebhook(t, store, member, "https://example.com/member") foreign := ownedWebhook(t, store, other, "https://example.com/other") - resolver := &graphres.Resolver{Version: "9.9.9", Webhooks: store} + resolver := &graphres.Resolver{Version: "9.9.9", Webhooks: store, Roles: stewardRoles()} client := newActingClient(t, resolver, authkit.Identity{ID: member, Role: role.Member.String()}) var listed holderListing diff --git a/internal/graphres/webhooks.go b/internal/graphres/webhooks.go index 63ee6df5..d97fa042 100644 --- a/internal/graphres/webhooks.go +++ b/internal/graphres/webhooks.go @@ -34,8 +34,8 @@ func toListedWebhook(listed webhook.Listed) *model.Webhook { } // managesWebhooks reports whether identity may manage every webhook of its workspace. -func managesWebhooks(identity authkit.Identity) bool { - return role.Can(role.Role(identity.Role), role.ManageWebhooks) +func (r *Resolver) managesWebhooks(identity authkit.Identity) bool { + return r.Roles.Can(identity.Role, role.ManageWebhooks) } // Webhooks lists every webhook of the workspace to a holder of manage_webhooks, and the caller's own to anyone else. @@ -45,7 +45,7 @@ func (q QueryResolvers) Webhooks(ctx context.Context) ([]*model.Webhook, error) subs []webhook.Listed err error ) - if managesWebhooks(identity) { + if q.root.managesWebhooks(identity) { subs, err = q.root.Webhooks.ListWorkspaceSubscriptions(ctx) } else { subs, err = q.root.Webhooks.ListSubscriptionsForUser(ctx, identity.ID) @@ -100,7 +100,7 @@ func (m MutationResolvers) CreateWebhook( func (m MutationResolvers) DeleteWebhook(ctx context.Context, id uuid.UUID) (bool, error) { identity := authkit.IdentityFromContext(ctx) var err error - if managesWebhooks(identity) { + if m.root.managesWebhooks(identity) { err = m.root.Webhooks.DeleteWorkspaceSubscription(ctx, id) } else { err = m.root.Webhooks.DeleteSubscription(ctx, identity.ID, id) diff --git a/internal/role/capability_test.go b/internal/role/capability_test.go deleted file mode 100644 index d6df2af1..00000000 --- a/internal/role/capability_test.go +++ /dev/null @@ -1,261 +0,0 @@ -// SPDX-License-Identifier: Elastic-2.0 - -package role_test - -import ( - "errors" - "slices" - "testing" - - "github.com/gopherium/alphone/internal/role" -) - -func TestAnAdminManagesUsers(t *testing.T) { - t.Parallel() - - if !role.Can(role.Admin, role.ManageUsers) { - t.Error("Can(admin, manage_users) = false, want true") - } -} - -func TestAnAdminManagesWebhooks(t *testing.T) { - t.Parallel() - - if !role.Can(role.Admin, role.ManageWebhooks) { - t.Error("Can(admin, manage_webhooks) = false, want true") - } - if got := role.CapabilitiesOf(role.Admin); !slices.Equal(got, []string{"manage_users", "manage_webhooks"}) { - t.Errorf("CapabilitiesOf(admin) = %v, want manage_users then manage_webhooks", got) - } -} - -func TestAMemberHoldsNoCapability(t *testing.T) { - t.Parallel() - - if role.Can(role.Member, role.ManageUsers) { - t.Error("Can(member, manage_users) = true, want false") - } - if role.Can(role.Member, role.ManageWebhooks) { - t.Error("Can(member, manage_webhooks) = true, want false") - } - if got := role.CapabilitiesOf(role.Member); got == nil || len(got) != 0 { - t.Errorf("CapabilitiesOf(member) = %v, want an empty list a plugin can range over", got) - } -} - -func TestARoleTheRegistryDoesNotKnowHoldsNothing(t *testing.T) { - t.Parallel() - - for _, unknown := range []role.Role{"", "root", "ADMIN", " admin"} { - if role.Can(unknown, role.ManageUsers) { - t.Errorf("Can(%q, manage_users) = true, want false", unknown) - } - if got := role.CapabilitiesOf(unknown); got == nil || len(got) != 0 { - t.Errorf("CapabilitiesOf(%q) = %v, want an empty list", unknown, got) - } - } -} - -func TestTheCoreKnowsOnlyAdminAndMember(t *testing.T) { - t.Parallel() - - if got := role.Tiers(); !slices.Equal(got, []string{"admin", "member"}) { - t.Errorf("Tiers() = %v, want admin and member alone, a plugin declares the rest", got) - } - if got := role.Privileged(); !slices.Equal(got, []string{"admin"}) { - t.Errorf("Privileged() = %v, want admin alone", got) - } -} - -func TestCapabilitiesNamesEveryCapabilityAnyRoleHolds(t *testing.T) { - t.Parallel() - - registry := role.NewRegistry() - if err := registry.Grant("steward", "manage_reports", role.ManageUsers); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - want := []role.Capability{"manage_reports", role.ManageUsers, role.ManageWebhooks} - if got := registry.Capabilities(); !slices.Equal(got, want) { - t.Errorf("Capabilities() = %v, want each capability once, in name order", got) - } - if got := role.Capabilities(); !slices.Equal(got, []role.Capability{role.ManageUsers, role.ManageWebhooks}) { - t.Errorf("Capabilities() = %v, want the core capabilities alone", got) - } -} - -func TestTheDefaultRegistryAnswersThePackageFunctions(t *testing.T) { - t.Parallel() - - if !role.Outranks(role.Admin, role.Member) { - t.Error("Outranks(admin, member) = false, want true") - } - if got := role.Grantable(role.Admin); !slices.Equal(got, []role.Role{role.Admin, role.Member}) { - t.Errorf("Grantable(admin) = %v, want admin then member", got) - } - if err := role.Grant("", role.ManageUsers); !errors.Is(err, role.ErrEmptyRole) { - t.Errorf("Grant(\"\") error = %v, want ErrEmptyRole", err) - } -} - -func TestAPluginDeclaresARoleWithItsCapabilities(t *testing.T) { - t.Parallel() - - registry := role.NewRegistry() - - if err := registry.Grant("steward", role.ManageUsers, "manage_reports"); err != nil { - t.Fatalf("Grant(steward) error = %v, want nil", err) - } - - if !registry.Can("steward", "manage_reports") { - t.Error("Can(steward, manage_reports) = false, want the declared capability held") - } - if got := registry.Privileged(); !slices.Equal(got, []string{"admin", "steward"}) { - t.Errorf("Privileged() = %v, want every role managing users, in stored order", got) - } - if got := registry.Roles(); !slices.Equal(got, []role.Role{"admin", "member", "steward"}) { - t.Errorf("Roles() = %v, want the declared role beside the core ones", got) - } - if parsed, err := registry.Parse("steward"); err != nil || parsed != "steward" { - t.Errorf("Parse(steward) = %q, %v, want the declared role accepted", parsed, err) - } -} - -func TestAPluginWidensACoreRole(t *testing.T) { - t.Parallel() - - registry := role.NewRegistry() - - if err := registry.Grant(role.Admin, "manage_reports"); err != nil { - t.Fatalf("Grant(admin) error = %v, want nil", err) - } - - if !registry.Can(role.Admin, "manage_reports") { - t.Error("Can(admin, manage_reports) = false, want the added capability held") - } - if !registry.Can(role.Admin, role.ManageUsers) { - t.Error("Can(admin, manage_users) = false, want the core capability kept") - } - want := []string{"manage_users", "manage_webhooks", "manage_reports"} - if got := registry.CapabilitiesOf(role.Admin); !slices.Equal(got, want) { - t.Errorf("CapabilitiesOf(admin) = %v, want the core capabilities then the added one", got) - } -} - -func TestGrantingTwiceHoldsEachCapabilityOnce(t *testing.T) { - t.Parallel() - - registry := role.NewRegistry() - - if err := registry.Grant("steward", "manage_reports"); err != nil { - t.Fatalf("first Grant() error = %v, want nil", err) - } - if err := registry.Grant("steward", "manage_reports", "manage_users"); err != nil { - t.Fatalf("second Grant() error = %v, want nil", err) - } - - if got := registry.CapabilitiesOf("steward"); !slices.Equal(got, []string{"manage_reports", "manage_users"}) { - t.Errorf("CapabilitiesOf(steward) = %v, want each capability once in the order granted", got) - } -} - -func TestGrantRefusesARoleWithNoName(t *testing.T) { - t.Parallel() - - registry := role.NewRegistry() - - err := registry.Grant("", role.ManageUsers) - - if !errors.Is(err, role.ErrEmptyRole) { - t.Errorf("Grant(\"\") error = %v, want ErrEmptyRole", err) - } - if got := registry.Roles(); !slices.Equal(got, []role.Role{"admin", "member"}) { - t.Errorf("Roles() = %v, want the refused grant to declare nothing", got) - } -} - -func TestARoleManagingOnlyUsersNoLongerOutranksAnAdmin(t *testing.T) { - t.Parallel() - - registry := role.NewRegistry() - if err := registry.Grant("keeper", role.ManageUsers); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - if registry.Outranks("keeper", role.Admin) { - t.Error("Outranks(keeper, admin) = true, want a role lacking manage_webhooks below an admin") - } - if !registry.Outranks(role.Admin, "keeper") { - t.Error("Outranks(admin, keeper) = false, want an admin to reach a role holding less") - } -} - -func TestOutranksHoldsEveryCapabilityOfTheTarget(t *testing.T) { - t.Parallel() - - registry := role.NewRegistry() - if err := registry.Grant("steward", role.ManageUsers, role.ManageWebhooks, "manage_reports"); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - for _, held := range []struct { - caller, target role.Role - want bool - }{ - {"steward", "steward", true}, - {"steward", role.Admin, true}, - {"steward", role.Member, true}, - {role.Admin, "steward", false}, - {role.Admin, role.Admin, true}, - {role.Admin, role.Member, true}, - {role.Member, role.Admin, false}, - {role.Member, role.Member, true}, - {"root", role.Member, true}, - {role.Member, "root", true}, - } { - if got := registry.Outranks(held.caller, held.target); got != held.want { - t.Errorf("Outranks(%q, %q) = %v, want %v", held.caller, held.target, got, held.want) - } - } -} - -func TestGrantableListsTheRolesTheCallerOutranksWidestFirst(t *testing.T) { - t.Parallel() - - registry := role.NewRegistry() - if err := registry.Grant("steward", role.ManageUsers, role.ManageWebhooks, "manage_reports"); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - for _, held := range []struct { - caller role.Role - want []role.Role - }{ - {"steward", []role.Role{"steward", role.Admin, role.Member}}, - {role.Admin, []role.Role{role.Admin, role.Member}}, - {role.Member, []role.Role{role.Member}}, - {"root", []role.Role{role.Member}}, - } { - if got := registry.Grantable(held.caller); !slices.Equal(got, held.want) { - t.Errorf("Grantable(%q) = %v, want %v", held.caller, got, held.want) - } - } -} - -func TestRolesHoldingTheSameCountOrderByName(t *testing.T) { - t.Parallel() - - registry := role.NewRegistry() - if err := registry.Grant("auditor", "read_reports"); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - got := registry.Grantable(role.Admin) - - if !slices.Equal(got, []role.Role{role.Admin, role.Member}) { - t.Errorf("Grantable(admin) = %v, want an admin unable to grant a capability it lacks", got) - } - if got := registry.Grantable("auditor"); !slices.Equal(got, []role.Role{"auditor", role.Member}) { - t.Errorf("Grantable(auditor) = %v, want its own role before the one holding less", got) - } -} diff --git a/internal/role/role.go b/internal/role/role.go index 20542ccc..28a6cb64 100644 --- a/internal/role/role.go +++ b/internal/role/role.go @@ -1,28 +1,12 @@ // SPDX-License-Identifier: Elastic-2.0 -// Package role narrows what a user may do, where a scope narrows what a token carries. +// Package role holds the roles the core grants and the capabilities they carry, kept in a goncierge registry. package role -import ( - "cmp" - "errors" - "fmt" - "slices" - "strings" - "sync" -) - -// ErrLastAdmin reports a change that would leave the deployment with no enabled admin. -var ErrLastAdmin = errors.New("the last admin cannot be unseated") - -// ErrUnknownTier reports a tier no deployment knows. -var ErrUnknownTier = errors.New("unknown tier") - -// ErrEmptyRole reports a role declared with no name. -var ErrEmptyRole = errors.New("empty role") +import "github.com/gopherium/framework/goncierge" -// ErrBeyondReach reports a write touching a role holding a capability the caller lacks. -var ErrBeyondReach = errors.New("role beyond reach") +// Source is the name the core grants its roles under. +const Source = "core" // Role is the tier a user stands in. type Role string @@ -35,206 +19,38 @@ const ( Member Role = "member" ) -// Capability is a named permission a decision point asks for. -type Capability string - // ManageUsers is the capability administering accounts. -const ManageUsers Capability = "manage_users" +const ManageUsers = "manage_users" // ManageWebhooks is the capability creating webhooks and managing every webhook of a workspace. -const ManageWebhooks Capability = "manage_webhooks" +const ManageWebhooks = "manage_webhooks" -// Registry holds every role a deployment knows and the capabilities each carries. -type Registry struct { - mu sync.RWMutex - carried map[Role][]Capability +// coreRoles is every role the core grants, with the capabilities each carries. +var coreRoles = []goncierge.Role{ + {Name: Admin.String(), Capabilities: []string{ManageUsers, ManageWebhooks}}, + {Name: Member.String()}, } // NewRegistry returns a registry holding the core roles and nothing a plugin declares. -func NewRegistry() *Registry { - return &Registry{carried: map[Role][]Capability{Admin: {ManageUsers, ManageWebhooks}, Member: {}}} -} - -// Default is the registry the deployment reads, which the host fills at wiring. -var Default = NewRegistry() - -// Grant gives a role the capabilities, declaring the role when the registry does not hold it. -func (r *Registry) Grant(held Role, capabilities ...Capability) error { - if held == "" { - return ErrEmptyRole - } - r.mu.Lock() - defer r.mu.Unlock() - carried := r.carried[held] - for _, capability := range capabilities { - if !slices.Contains(carried, capability) { - carried = append(carried, capability) - } - } - r.carried[held] = carried - return nil +func NewRegistry() *goncierge.Registry { + return mustRegistry(coreRoles) } -// Can reports whether a role holds the capability, an unknown role holding none. -func (r *Registry) Can(held Role, capability Capability) bool { - r.mu.RLock() - defer r.mu.RUnlock() - return slices.Contains(r.carried[held], capability) -} - -// CapabilitiesOf returns the capabilities a role carries, named for a caller outside this package. -func (r *Registry) CapabilitiesOf(held Role) []string { - r.mu.RLock() - defer r.mu.RUnlock() - named := make([]string, 0, len(r.carried[held])) - for _, capability := range r.carried[held] { - named = append(named, string(capability)) +// mustRegistry returns a registry holding the roles under the core source, panicking when the name rule refuses one. +func mustRegistry(roles []goncierge.Role) *goncierge.Registry { + registry := goncierge.New() + if err := registry.Replace(Source, roles); err != nil { + panic(err) } - return named + return registry } -// Roles returns every role the registry holds, in stored order. -func (r *Registry) Roles() []Role { - r.mu.RLock() - defer r.mu.RUnlock() - held := make([]Role, 0, len(r.carried)) - for name := range r.carried { - held = append(held, name) - } - slices.Sort(held) - return held -} - -// Capabilities returns every capability any role in the registry holds, in name order. -func (r *Registry) Capabilities() []Capability { - r.mu.RLock() - defer r.mu.RUnlock() - var held []Capability - for _, carried := range r.carried { - for _, capability := range carried { - if !slices.Contains(held, capability) { - held = append(held, capability) - } - } - } - slices.Sort(held) - return held -} - -// Parse returns the role the text names, refusing any role the registry does not hold. -func (r *Registry) Parse(text string) (Role, error) { - roles := r.Roles() - if slices.Contains(roles, Role(text)) { - return Role(text), nil - } - named := make([]string, 0, len(roles)) - for _, held := range roles { - named = append(named, string(held)) - } - return "", fmt.Errorf("%w: %q, want one of %s", ErrUnknownTier, text, strings.Join(named, " or ")) -} - -// Privileged returns the stored form of every role that administers accounts. -func (r *Registry) Privileged() []string { - var named []string - for _, held := range r.Roles() { - if r.Can(held, ManageUsers) { - named = append(named, string(held)) - } - } - return named -} - -// Outranks reports whether the caller holds every capability the target holds. -func (r *Registry) Outranks(caller, target Role) bool { - r.mu.RLock() - defer r.mu.RUnlock() - for _, capability := range r.carried[target] { - if !slices.Contains(r.carried[caller], capability) { - return false - } - } - return true -} - -// Grantable returns the roles the caller outranks, the one holding most first. -func (r *Registry) Grantable(caller Role) []Role { - var granted []Role - for _, held := range r.Roles() { - if r.Outranks(caller, held) { - granted = append(granted, held) - } - } - slices.SortStableFunc(granted, func(a, b Role) int { - return cmp.Compare(len(r.CapabilitiesOf(b)), len(r.CapabilitiesOf(a))) - }) - return granted -} - -// Grant gives a role the capabilities in the default registry, declaring it when absent. -func Grant(held Role, capabilities ...Capability) error { - return Default.Grant(held, capabilities...) -} - -// Can reports whether a role holds the capability in the default registry. -func Can(held Role, capability Capability) bool { - return Default.Can(held, capability) -} - -// CapabilitiesOf returns the capabilities a role carries in the default registry. -func CapabilitiesOf(held Role) []string { - return Default.CapabilitiesOf(held) -} - -// Privileged returns every role administering accounts in the default registry. -func Privileged() []string { - return Default.Privileged() -} - -// Capabilities returns every capability any role in the default registry holds. -func Capabilities() []Capability { - return Default.Capabilities() -} - -// Outranks reports whether the caller holds every capability the target holds in the default registry. -func Outranks(caller, target Role) bool { - return Default.Outranks(caller, target) -} - -// Grantable returns the roles the caller outranks in the default registry. -func Grantable(caller Role) []Role { - return Default.Grantable(caller) -} - -// Of returns the tier the stored text names, member for anything it cannot read. -func Of(stored string) Role { - if Role(stored) == Admin { - return Admin - } - return Member -} - -// Tiers returns every tier the default registry holds in its stored form. -func Tiers() []string { - roles := Default.Roles() - named := make([]string, 0, len(roles)) - for _, held := range roles { - named = append(named, string(held)) - } - return named -} - -// Parse returns the tier the text names, refusing any tier the default registry does not hold. -func Parse(text string) (Role, error) { - return Default.Parse(text) +// Declare replaces the roles a source declares in the registry, keeping its new capabilities off the admin role. +func Declare(registry *goncierge.Registry, source string, roles []goncierge.Role) error { + return goncierge.Declare(registry, goncierge.Rules{}, source, roles) } // String returns the stored form of the tier. func (r Role) String() string { return string(r) } - -// Allows reports whether the tier reaches a field, given whether that field is admin only. -func (r Role) Allows(adminOnly bool) bool { - return !adminOnly || r == Admin -} diff --git a/internal/role/role_internal_test.go b/internal/role/role_internal_test.go new file mode 100644 index 00000000..e53e643b --- /dev/null +++ b/internal/role/role_internal_test.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package role + +import ( + "errors" + "testing" + + "github.com/gopherium/framework/goncierge" +) + +func TestMustRegistryPanicsOnARoleNameTheRuleRefuses(t *testing.T) { + t.Parallel() + + defer func() { + recovered, _ := recover().(error) + if !errors.Is(recovered, goncierge.ErrInvalidName) { + t.Fatalf("mustRegistry() recovered %v, want a panic with %v", recovered, goncierge.ErrInvalidName) + } + }() + + mustRegistry([]goncierge.Role{{Name: "Admin"}}) +} diff --git a/internal/role/role_test.go b/internal/role/role_test.go index a1405712..f0a4c269 100644 --- a/internal/role/role_test.go +++ b/internal/role/role_test.go @@ -4,105 +4,97 @@ package role_test import ( "errors" + "slices" "testing" + "github.com/gopherium/framework/goncierge" + "github.com/gopherium/alphone/internal/role" ) -func TestAdminReachesEveryField(t *testing.T) { +func TestTheCoreKnowsOnlyAdminAndMember(t *testing.T) { t.Parallel() - if !role.Admin.Allows(true) { - t.Error("Admin.Allows(admin only) = false, want true") - } - if !role.Admin.Allows(false) { - t.Error("Admin.Allows(open) = false, want true") + if got := role.NewRegistry().Roles(); !slices.Equal(got, []string{"admin", "member"}) { + t.Errorf("Roles() = %v, want admin and member alone, a plugin declares the rest", got) } } -func TestMemberIsRefusedAnAdminField(t *testing.T) { +func TestOnlyTheAdminManagesUsers(t *testing.T) { t.Parallel() - if role.Member.Allows(true) { - t.Error("Member.Allows(admin only) = true, want false") - } - if !role.Member.Allows(false) { - t.Error("Member.Allows(open) = false, want true") + if got := role.NewRegistry().HoldersOf(role.ManageUsers); !slices.Equal(got, []string{"admin"}) { + t.Errorf("HoldersOf(manage_users) = %v, want admin alone", got) } } -func TestOfReadsTheStoredTier(t *testing.T) { +func TestAnAdminManagesUsersAndWebhooks(t *testing.T) { t.Parallel() - if got := role.Of("admin"); got != role.Admin { - t.Errorf("Of(admin) = %v, want %v", got, role.Admin) - } - if got := role.Of("member"); got != role.Member { - t.Errorf("Of(member) = %v, want %v", got, role.Member) + got := role.NewRegistry().CapabilitiesOf(role.Admin.String()) + + if !slices.Equal(got, []string{"manage_users", "manage_webhooks"}) { + t.Errorf("CapabilitiesOf(admin) = %v, want manage_users then manage_webhooks", got) } } -func TestOfDemotesAnythingItCannotRead(t *testing.T) { +func TestAMemberHoldsNoCapability(t *testing.T) { t.Parallel() - for _, stored := range []string{"", "root", "ADMIN", " admin"} { - if got := role.Of(stored); got != role.Member { - t.Errorf("Of(%q) = %v, want %v, an unreadable tier demotes", stored, got, role.Member) - } + if got := role.NewRegistry().CapabilitiesOf(role.Member.String()); len(got) != 0 { + t.Errorf("CapabilitiesOf(member) = %v, want an empty list", got) } } -func TestStringRoundTripsTheStoredForm(t *testing.T) { +func TestTheCoreGrantsUnderItsOwnSource(t *testing.T) { t.Parallel() - for _, tier := range []role.Role{role.Admin, role.Member} { - if got := role.Of(tier.String()); got != tier { - t.Errorf("Of(%q) = %v, want %v", tier.String(), got, tier) - } + registry := role.NewRegistry() + registry.Withdraw(role.Source) + + if got := registry.Roles(); len(got) != 0 { + t.Errorf("Roles() after Withdraw(%q) = %v, want none, the core grants every core role", role.Source, got) } } -func TestTiersNamesEveryStorableTier(t *testing.T) { +func TestDeclareKeepsAPluginCapabilityOffTheAdminRole(t *testing.T) { t.Parallel() - tiers := role.Tiers() - for _, tier := range tiers { - parsed, err := role.Parse(tier) - if err != nil { - t.Errorf("Parse(%q) error = %v, want nil, every named tier reads back", tier, err) - } - if parsed.String() != tier { - t.Errorf("Parse(%q) = %q, want %q", tier, parsed, tier) - } - } + registry := role.NewRegistry() + declared := []goncierge.Role{{Name: "steward", Capabilities: []string{"steward.manage_reports"}}} - if len(tiers) != 2 { - t.Fatalf("Tiers() = %v, want two tiers", tiers) + if err := role.Declare(registry, "steward", declared); err != nil { + t.Fatalf("Declare() error = %v, want nil", err) + } + if !registry.Can("steward", "steward.manage_reports") { + t.Error("Can(steward, steward.manage_reports) = false, want the declared capability held") } - for _, tier := range tiers { - if role.Of(tier) == role.Member && tier != role.Member.String() { - t.Errorf("Tiers() names %q, which does not read back", tier) - } + if registry.Can(role.Admin.String(), "steward.manage_reports") { + t.Error("Can(admin, steward.manage_reports) = true, want a plugin capability kept off the admin role") } } -func TestParseRefusesATierNoDeploymentKnows(t *testing.T) { +func TestDeclareRefusesANewCapabilityOutsideThePluginPrefix(t *testing.T) { t.Parallel() - parsed, err := role.Parse("root") + registry := role.NewRegistry() + declared := []goncierge.Role{{Name: "steward", Capabilities: []string{"manage_reports"}}} - if !errors.Is(err, role.ErrUnknownTier) { - t.Errorf("Parse() error = %v, want %v", err, role.ErrUnknownTier) - } - if parsed != "" { - t.Errorf("Parse() = %q, want no tier, an unknown name never stands anybody up", parsed) + if err := role.Declare(registry, "steward", declared); !errors.Is(err, goncierge.ErrOutsidePrefix) { + t.Errorf("Declare() error = %v, want %v", err, goncierge.ErrOutsidePrefix) } } -func TestParseRefusesTheEmptyTier(t *testing.T) { +func TestDeclareLetsAPluginGrantACoreCapability(t *testing.T) { t.Parallel() - if _, err := role.Parse(""); !errors.Is(err, role.ErrUnknownTier) { - t.Errorf("Parse(\"\") error = %v, want %v", err, role.ErrUnknownTier) + registry := role.NewRegistry() + declared := []goncierge.Role{{Name: "steward", Capabilities: []string{role.ManageUsers}}} + + if err := role.Declare(registry, "steward", declared); err != nil { + t.Fatalf("Declare() error = %v, want nil", err) + } + if got := registry.HoldersOf(role.ManageUsers); !slices.Equal(got, []string{"admin", "steward"}) { + t.Errorf("HoldersOf(manage_users) = %v, want admin and steward", got) } } diff --git a/internal/server/graphql.go b/internal/server/graphql.go index 0906fc67..6e4594eb 100644 --- a/internal/server/graphql.go +++ b/internal/server/graphql.go @@ -17,10 +17,10 @@ import ( "github.com/google/uuid" "github.com/vektah/gqlparser/v2/ast" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/gouncer/authkit" "github.com/gopherium/gouncer/authkit/ratelimit" - "github.com/gopherium/alphone/graph" "github.com/gopherium/alphone/internal/dyngraph" "github.com/gopherium/alphone/internal/graphres" "github.com/gopherium/alphone/sdk" @@ -101,7 +101,9 @@ const ( ) // graphServer returns the gqlgen server answering over one executable schema with every graph guard. -func graphServer(schema graphql.ExecutableSchema, scopes graphres.ScopeMap, bounds GraphBounds) *handler.Server { +func graphServer( + schema graphql.ExecutableSchema, scopes graphres.ScopeMap, roles *goncierge.Registry, bounds GraphBounds, +) *handler.Server { srv := handler.New(schema) srv.AddTransport(subscriptionSSE{}) srv.AddTransport(transport.POST{}) @@ -109,21 +111,20 @@ func graphServer(schema graphql.ExecutableSchema, scopes graphres.ScopeMap, boun srv.Use(extension.Introspection{}) srv.Use(extension.FixedComplexityLimit(graphres.ComplexityLimit)) srv.AroundOperations(graphres.AnonymousGate) - srv.AroundOperations(graphres.ScopeGate(scopes)) + srv.AroundOperations(graphres.ScopeGate(scopes, roles)) srv.SetErrorPresenter(graphres.PresentError) return srv } -// tenantGraphs returns the graph servers answering each tenant over its own widened schema. -func tenantGraphs( - root graph.ResolverRoot, sources []sdk.FieldSource, held int, bounds GraphBounds, -) *dyngraph.Graphs[*handler.Server] { +// tenantGraphs returns the graph servers answering each tenant over its own widened schema of the configured root. +func tenantGraphs(cfg Config, bounds GraphBounds) *dyngraph.Graphs[*handler.Server] { + root := cfg.GraphRoot scopes := graphres.NewScopeMap(graphres.ExecutableSchema(root).Schema()) return dyngraph.New(func(widened *ast.Schema) graphql.ExecutableSchema { return graphres.ExecutableSchemaOver(root, widened) }, func(schema graphql.ExecutableSchema) *handler.Server { - return graphServer(schema, scopes, bounds) - }, held, sources...) + return graphServer(schema, scopes, cfg.Roles, bounds) + }, cfg.TenantsHeld, cfg.FieldSources...) } // graphPolicy is the budget one kind of graph request is held to. @@ -149,24 +150,17 @@ func retryAfterSeconds(hint time.Duration) int { return seconds } -// newGraphQLHandler serves the guarded GraphQL endpoint over the composed resolver root, each tenant its own fields. -func newGraphQLHandler( - root graph.ResolverRoot, - tenants TenantStore, - bounds GraphBounds, - streamLifetime time.Duration, - maxStreams int, - sources []sdk.FieldSource, - held int, -) http.Handler { - graphs := tenantGraphs(root, sources, held, bounds) +// newGraphQLHandler serves the guarded GraphQL endpoint over the configured resolver root, each tenant its own fields. +func newGraphQLHandler(cfg Config, streamLifetime time.Duration, maxStreams int) http.Handler { + bounds := cfg.Graph.withDefaults() + graphs := tenantGraphs(cfg, bounds) loaded := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := graphres.WithHTTP(r.Context(), w, r) ctx = graphres.WithClientIP(ctx, ratelimit.ClientIP(r)) ctx = sdk.WithRequestScope(ctx, sdk.NewRequestScope()) answer := graphs.Plain() if user := authkit.IdentityFromContext(r.Context()); user.ID != uuid.Nil { - standing, err := withStandingTenant(sdk.WithUser(ctx, user.ID), tenants, user.ID) + standing, err := withStandingTenant(sdk.WithUser(ctx, user.ID), cfg.Tenants, user.ID) if err != nil { http.Error(w, "no tenant resolved", http.StatusInternalServerError) return diff --git a/internal/server/graphql_auth_test.go b/internal/server/graphql_auth_test.go index 7de87783..bc553c3b 100644 --- a/internal/server/graphql_auth_test.go +++ b/internal/server/graphql_auth_test.go @@ -10,9 +10,14 @@ import ( "strings" "testing" + "github.com/google/uuid" + "github.com/gopherium/alphone/internal/role" ) +// stewardRole is a role only the registry a test hands the server declares. +const stewardRole = "steward" + // graphResponse is a GraphQL response body with raw data and typed errors. type graphResponse struct { Data json.RawMessage `json:"data"` @@ -302,6 +307,39 @@ func TestGraphRefusesUserManagementToAMemberSession(t *testing.T) { } } +func TestARoleOnlyTheHandedRegistryDeclaresReachesUserManagement(t *testing.T) { + t.Parallel() + + roles := role.NewRegistry() + if err := roles.Grant("steward", stewardRole, role.ManageUsers); err != nil { + t.Fatalf("Grant() error = %v, want nil", err) + } + users := newFakeUserStore() + held := users.AddUser(t, "maria@example.com", "Maria Perez", testPassword) + held.Role = stewardRole + users.Users[held.ID] = held + srv := newGraphServer(t, graphConfig{ + Contacts: newFakeContactStore(), + Tasks: newFakeTaskStore(), + Users: users, + Version: "9.9.9", + Roles: roles, + }) + cookie := graphSessionCookie(t, loginMutation(t, srv, "maria@example.com", testPassword)) + + recorder := postGraphQL(t, srv, graphBody(t, fmt.Sprintf( + `mutation { setUserRole(id: %q, role: %q) }`, uuid.Must(uuid.NewV7()), role.Member.String(), + )), cookie) + + body := decodeBody[graphResponse](t, recorder) + if len(body.Errors) != 1 { + t.Fatalf("errors = %v, want one error naming the account nobody holds", body.Errors) + } + if got := body.Errors[0].Extensions["reason"]; got != "user_not_found" { + t.Errorf("reason = %v, want user_not_found, the handed registry gives steward manage_users", got) + } +} + func TestGraphLetsAMemberSessionListTheUsers(t *testing.T) { t.Parallel() diff --git a/internal/server/graphql_test.go b/internal/server/graphql_test.go index fe019fd0..8b899495 100644 --- a/internal/server/graphql_test.go +++ b/internal/server/graphql_test.go @@ -16,6 +16,7 @@ import ( "github.com/google/uuid" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/gouncer" "github.com/gopherium/gouncer/authkit" "github.com/gopherium/gouncer/authkit/ratelimit" @@ -60,6 +61,7 @@ type graphConfig struct { TrustedProxies []string Logger *slog.Logger Web fs.FS + Roles *goncierge.Registry } // newGraphServer returns a server whose graph root composes cfg's stores with @@ -69,15 +71,21 @@ func newGraphServer(t *testing.T, cfg graphConfig) http.Handler { return newSubscribingGraphServer(t, cfg, nil) } -// newSubscribingGraphServer returns a graph server whose subscriptions read hub. +// newSubscribingGraphServer returns a graph server whose subscriptions read hub, over the core roles by default. func newSubscribingGraphServer(t *testing.T, cfg graphConfig, hub *event.Hub) http.Handler { t.Helper() + roles := cfg.Roles + if roles == nil { + roles = role.NewRegistry() + } + privileged := roles.HoldersOf(role.ManageUsers) auth := authkit.New(authkit.Config{ - Store: cfg.Users, - CookieName: server.SessionCookieName, - Privileged: role.Privileged(), + Store: cfg.Users, + CookieName: server.SessionCookieName, + Privileged: privileged, + Capabilities: roles, }) - admin := authkit.NewAdmin(authkit.AdminConfig{Store: cfg.Users, Privileged: role.Privileged()}) + admin := authkit.NewAdmin(authkit.AdminConfig{Store: cfg.Users, Privileged: privileged, Roles: roles}) plugins, err := graphroot.All(sdk.Deps{DatabaseURL: "postgres://graph:graph@localhost:1/graph"}) if err != nil { t.Fatalf("graphroot.All() error = %v, want nil", err) @@ -93,9 +101,10 @@ func newSubscribingGraphServer(t *testing.T, cfg graphConfig, hub *event.Hub) ht Admin: admin, LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}), TokenLimiter: ratelimit.NewLimiter(ratelimit.Config{}), + Roles: roles, } if store, ok := cfg.Users.(authkit.InviteStore); ok { - invites := authkit.NewInvites(authkit.InvitesConfig{Store: store}) + invites := authkit.NewInvites(authkit.InvitesConfig{Store: store, Roles: roles}) resolver.Invites = invites resolver.Onboarding = unplacedOnboarding{invites: invites} resolver.Accounts = store @@ -125,6 +134,7 @@ func newSubscribingGraphServer(t *testing.T, cfg graphConfig, hub *event.Hub) ht TrustedProxies: cfg.TrustedProxies, Logger: cfg.Logger, Web: cfg.Web, + Roles: roles, }) } diff --git a/internal/server/server.go b/internal/server/server.go index e89b65ca..50be0680 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,6 +15,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/pluginkit" "github.com/gopherium/gouncer/authkit" "github.com/gopherium/gouncer/authkit/ratelimit" @@ -41,6 +42,8 @@ type Config struct { // GraphRoot is the composed resolver root served at /api/graphql. Nil // leaves the graph endpoint unmounted. GraphRoot graph.ResolverRoot + // Roles answers the capabilities each declared role carries, which the graph's capability gate asks. + Roles *goncierge.Registry // Tokens resolves API tokens presented as bearer credentials. Nil // leaves the session cookie as the only accepted credential. Tokens TokenStore @@ -99,8 +102,7 @@ func NewServer(cfg Config) http.Handler { router := chi.NewRouter() router.Use(authkit.CrossOriginGuard(cfg.Logger)) if cfg.GraphRoot != nil { - graph := newGraphQLHandler(cfg.GraphRoot, cfg.Tenants, cfg.Graph.withDefaults(), - maxStreamLifetime, maxStreamsPerUser, cfg.FieldSources, cfg.TenantsHeld) + graph := newGraphQLHandler(cfg, maxStreamLifetime, maxStreamsPerUser) router.Group(func(graphed chi.Router) { graphed.Use(ratelimit.ResolveClientIP(cfg.TrustedProxies)) graphed.Use(s.identifyIdentity) diff --git a/sdk/deps_test.go b/sdk/deps_test.go index f26e719c..ac091634 100644 --- a/sdk/deps_test.go +++ b/sdk/deps_test.go @@ -19,6 +19,7 @@ func TestContractKeepsItsDependenciesMinimal(t *testing.T) { allowedThirdParty := []string{ "github.com/google/uuid", + "github.com/gopherium/framework/goncierge", "github.com/gopherium/framework/gonsole", "github.com/gopherium/framework/pluginkit", } diff --git a/sdk/sdk.go b/sdk/sdk.go index 0dc318f3..82eaa5b8 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -14,6 +14,7 @@ import ( "time" "github.com/google/uuid" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/pluginkit" ) @@ -83,16 +84,11 @@ type AreaProvider interface { } // RoleDeclaration is one role a plugin declares, or capabilities it adds to a role the host knows. -type RoleDeclaration struct { - // Name is the role in its stored form. - Name string - // Capabilities names what the role holds, added to whatever it already carries. - Capabilities []string -} +type RoleDeclaration = goncierge.Role // RoleProvider is implemented by plugins declaring roles or widening the ones the host knows. type RoleProvider interface { - // Roles returns every role the plugin declares and every capability it adds to one. + // Roles returns every role the plugin declares or widens, a new capability starting with the plugin id and a dot. Roles() []RoleDeclaration } diff --git a/sdk/sdk_test.go b/sdk/sdk_test.go index 3308c3b6..92add6db 100644 --- a/sdk/sdk_test.go +++ b/sdk/sdk_test.go @@ -6,6 +6,7 @@ import ( "reflect" "testing" + "github.com/gopherium/framework/goncierge" "github.com/gopherium/framework/pluginkit" ) @@ -18,3 +19,13 @@ func TestSeederIsTheHostSeederContract(t *testing.T) { t.Errorf("Seeder = %v, want the host's own %v", named, hosted) } } + +func TestRoleDeclarationIsTheRegistrysRole(t *testing.T) { + t.Parallel() + + named, registered := reflect.TypeFor[RoleDeclaration](), reflect.TypeFor[goncierge.Role]() + + if named != registered { + t.Errorf("RoleDeclaration = %v, want the registry's own %v", named, registered) + } +} diff --git a/test/features/features/operator-commands.feature b/test/features/features/operator-commands.feature index ce8e1692..a5679b59 100644 --- a/test/features/features/operator-commands.feature +++ b/test/features/features/operator-commands.feature @@ -224,6 +224,6 @@ Feature: Operators run AlphOne from one command line And the member "maria.perez@example.com" When the operator gives "maria.perez@example.com" the role "steward" acting as "admin@example.com" Then the command exits with code 1 - And the error says "the role steward carries reach_beyond_admin, which the account admin@example.com lacks" + And the error says "the role steward carries steward.reach_beyond_admin, which the account admin@example.com lacks" And the account "maria.perez@example.com" still holds the role "member" And no account change is on record diff --git a/test/features/steps_roles_test.go b/test/features/steps_roles_test.go index 3eb318a4..82fa89a9 100644 --- a/test/features/steps_roles_test.go +++ b/test/features/steps_roles_test.go @@ -232,9 +232,8 @@ func (w *world) standsAsAdmin(ctx context.Context, userID uuid.UUID) error { if err != nil { return err } - tier := role.Of(held.Role) - if tier != role.Admin { - return fmt.Errorf("the user stands in %v, want %v", tier, role.Admin) + if held.Role != role.Admin.String() { + return fmt.Errorf("the user stands in %q, want %q", held.Role, role.Admin.String()) } return nil } diff --git a/test/features/world_test.go b/test/features/world_test.go index aed04275..4a4d1335 100644 --- a/test/features/world_test.go +++ b/test/features/world_test.go @@ -123,7 +123,11 @@ func bootWorld(t *testing.T, liveImports bool) *world { tokens := postgres.NewTokenStore(pool) webhooks := postgres.NewWebhookStore(pool) hub := event.NewHub() - auth := authkit.New(authkit.Config{Store: users, CookieName: server.SessionCookieName}) + roles := role.NewRegistry() + privileged := roles.HoldersOf(role.ManageUsers) + auth := authkit.New(authkit.Config{ + Store: users, CookieName: server.SessionCookieName, Privileged: privileged, Capabilities: roles, + }) resolver := contact.NewResolver(contacts) fieldsPlugin := livePlugin(t, databaseURL) @@ -138,7 +142,7 @@ func bootWorld(t *testing.T, liveImports bool) *world { if err != nil { t.Fatalf("building the scenario mailer: %v", err) } - inviteConfig := authkit.InvitesConfig{Store: users} + inviteConfig := authkit.InvitesConfig{Store: users, Roles: roles} quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) graph := &graphres.Resolver{ Version: "test", @@ -150,7 +154,7 @@ func bootWorld(t *testing.T, liveImports bool) *world { Settings: postgres.NewUserSettingStore(pool), Live: hub, Auth: auth, - Admin: authkit.NewAdmin(authkit.AdminConfig{Store: users, Privileged: role.Privileged()}), + Admin: authkit.NewAdmin(authkit.AdminConfig{Store: users, Privileged: privileged, Roles: roles}), Invites: authkit.NewInvites(inviteConfig), Onboarding: postgres.NewOnboarding(pool, inviteConfig), Accounts: users, @@ -160,27 +164,13 @@ func bootWorld(t *testing.T, liveImports bool) *world { TokenLimiter: ratelimit.NewLimiter(ratelimit.Config{}), ResetLimiter: ratelimit.NewLimiter(ratelimit.Config{}), Logger: quiet, + Roles: roles, } root, err := graphroot.FromPlugins(graph, registered) if err != nil { t.Fatalf("composing the graph root: %v", err) } - if _, err := authkit.EnsureAdmin( - context.Background(), users, ownerEmail, ownerName, ownerPassword, role.Admin.String(), - ); err != nil { - t.Fatalf("seeding the owner: %v", err) - } - owner, err := users.UserByEmail(context.Background(), ownerEmail) - if err != nil { - t.Fatalf("reading the owner: %v", err) - } - minted, err := apitoken.Mint(owner.ID, "mcp scenario", apitoken.Full(), apitoken.Never) - if err != nil { - t.Fatalf("minting the token: %v", err) - } - if err := tokens.Create(context.Background(), minted.Token); err != nil { - t.Fatalf("storing the token: %v", err) - } + ownerID, minted := seededOwner(t, users, tokens) srv := httptest.NewServer(server.NewServer(server.Config{ Users: users, @@ -191,6 +181,7 @@ func bootWorld(t *testing.T, liveImports bool) *world { FieldSources: []sdk.FieldSource{fieldsPlugin}, Version: "test", Logger: quiet, + Roles: roles, })) t.Cleanup(srv.Close) @@ -203,7 +194,7 @@ func bootWorld(t *testing.T, liveImports bool) *world { resolver: resolver, users: users, server: srv, - ownerID: owner.ID, + ownerID: ownerID, secret: minted.Secret, tokenID: minted.Token.ID, relay: relay, @@ -211,6 +202,28 @@ func bootWorld(t *testing.T, liveImports bool) *world { } } +// seededOwner seeds the scenario owner and returns its id and the full token stored for it. +func seededOwner(t *testing.T, users *authkitpg.UserStore, tokens *postgres.TokenStore) (uuid.UUID, apitoken.Minted) { + t.Helper() + if _, err := authkit.EnsureAdmin( + context.Background(), users, ownerEmail, ownerName, ownerPassword, role.Admin.String(), + ); err != nil { + t.Fatalf("seeding the owner: %v", err) + } + owner, err := users.UserByEmail(context.Background(), ownerEmail) + if err != nil { + t.Fatalf("reading the owner: %v", err) + } + minted, err := apitoken.Mint(owner.ID, "mcp scenario", apitoken.Full(), apitoken.Never) + if err != nil { + t.Fatalf("minting the token: %v", err) + } + if err := tokens.Create(context.Background(), minted.Token); err != nil { + t.Fatalf("storing the token: %v", err) + } + return owner.ID, minted +} + // scenarioPublicURL is the address the mailed links of a scenario lead back to. const scenarioPublicURL = "https://crm.example.com"