From fbd06b6cf7a568f0c2c819409e9f4b39600081a2 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 21:13:03 +0200 Subject: [PATCH 01/47] feat(role): let plugins declare the roles a deployment knows --- cmd/alphone/roles_test.go | 100 +++++++++++++++ cmd/alphone/run.go | 24 ++++ internal/role/capability_test.go | 213 +++++++++++++++++++++++++++++++ internal/role/role.go | 173 +++++++++++++++++++++++-- sdk/sdk.go | 13 ++ 5 files changed, 513 insertions(+), 10 deletions(-) create mode 100644 cmd/alphone/roles_test.go create mode 100644 internal/role/capability_test.go diff --git a/cmd/alphone/roles_test.go b/cmd/alphone/roles_test.go new file mode 100644 index 00000000..de9ba76a --- /dev/null +++ b/cmd/alphone/roles_test.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package main + +import ( + "context" + "errors" + "io" + "slices" + "testing" + + "github.com/gopherium/alphone/internal/role" + "github.com/gopherium/alphone/sdk" +) + +// silentPlugin is a plugin declaring no roles. +type silentPlugin struct{} + +// ID names the plugin. +func (silentPlugin) ID() string { return "silent" } + +// Start does nothing. +func (silentPlugin) Start(context.Context) error { return nil } + +// Stop does nothing. +func (silentPlugin) Stop(context.Context) error { return nil } + +// rolePlugin is a plugin declaring roles, standing in for one the host wires. +type rolePlugin struct { + silentPlugin + declared []sdk.RoleDeclaration +} + +// ID names the plugin. +func (rolePlugin) ID() string { return "steward" } + +// Roles returns the roles the plugin declares. +func (p rolePlugin) Roles() []sdk.RoleDeclaration { + return p.declared +} + +func TestDeclareRolesGrantsEveryDeclarationAPluginMakes(t *testing.T) { + t.Parallel() + + registry := role.NewRegistry() + registered := []sdk.Plugin{ + silentPlugin{}, + rolePlugin{declared: []sdk.RoleDeclaration{ + {Name: "steward", Capabilities: []string{"manage_users", "manage_reports"}}, + {Name: "admin", Capabilities: []string{"manage_reports"}}, + }}, + } + + if err := declareRoles(registry, registered); err != nil { + 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(role.Admin, "manage_reports") { + t.Error("Can(admin, 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) + } +} + +func TestRunRefusesAPluginDeclaringANamelessRole(t *testing.T) { + t.Parallel() + + nameless := func(sdk.Deps) ([]sdk.Plugin, error) { + return []sdk.Plugin{ + rolePlugin{declared: []sdk.RoleDeclaration{{Name: "", Capabilities: []string{"manage_reports"}}}}, + }, nil + } + + err := run(t.Context(), testGetenv(map[string]string{ + "ALPHONE_DATABASE_URL": testDatabaseURL(t), + }), io.Discard, nameless) + + if !errors.Is(err, role.ErrEmptyRole) { + t.Fatalf("run() error = %v, want ErrEmptyRole refusing the wiring", err) + } +} + +func TestDeclareRolesRefusesADeclarationWithNoName(t *testing.T) { + t.Parallel() + + registry := role.NewRegistry() + registered := []sdk.Plugin{ + rolePlugin{declared: []sdk.RoleDeclaration{{Name: "", Capabilities: []string{"manage_reports"}}}}, + } + + err := declareRoles(registry, registered) + + if !errors.Is(err, role.ErrEmptyRole) { + t.Errorf("declareRoles() error = %v, want ErrEmptyRole surfaced at wiring", err) + } +} diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index 972937ab..92e76703 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -25,6 +25,7 @@ import ( "github.com/gopherium/alphone/internal/graphres" "github.com/gopherium/alphone/internal/graphroot" "github.com/gopherium/alphone/internal/postgres" + "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/server" "github.com/gopherium/alphone/internal/version" "github.com/gopherium/alphone/internal/webhook" @@ -82,6 +83,9 @@ func run( if err != nil { return fmt.Errorf("register plugins: %w", err) } + if err := declareRoles(role.Default, registered); err != nil { + return fmt.Errorf("declare plugin roles: %w", err) + } wireFieldProviders(registered) host := pluginkit.NewHost(registered...) @@ -148,6 +152,26 @@ func pluginAreas(registered []sdk.Plugin) map[string]string { return areas } +// declareRoles grants the registry every role a registered plugin declares. +func declareRoles(registry *role.Registry, registered []sdk.Plugin) error { + for _, plugin := range registered { + 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) + } + } + } + return nil +} + // fieldSources returns every registered plugin serving runtime defined fields. func fieldSources(registered []sdk.Plugin) []sdk.FieldSource { var sources []sdk.FieldSource diff --git a/internal/role/capability_test.go b/internal/role/capability_test.go new file mode 100644 index 00000000..239fe2d1 --- /dev/null +++ b/internal/role/capability_test.go @@ -0,0 +1,213 @@ +// 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 TestAMemberHoldsNoCapability(t *testing.T) { + t.Parallel() + + if role.Can(role.Member, role.ManageUsers) { + t.Error("Can(member, manage_users) = 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 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") + } + if got := registry.CapabilitiesOf(role.Admin); !slices.Equal(got, []string{"manage_users", "manage_reports"}) { + t.Errorf("CapabilitiesOf(admin) = %v, want the core capability 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 TestOutranksHoldsEveryCapabilityOfTheTarget(t *testing.T) { + t.Parallel() + + registry := role.NewRegistry() + if err := registry.Grant("steward", role.ManageUsers, "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, "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 e1955431..15749e13 100644 --- a/internal/role/role.go +++ b/internal/role/role.go @@ -4,9 +4,12 @@ package role import ( + "cmp" "errors" "fmt" + "slices" "strings" + "sync" ) // ErrLastAdmin reports a change that would leave the deployment with no enabled admin. @@ -15,10 +18,13 @@ 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") + // Role is the tier a user stands in. type Role string -// The tiers a user may stand in. +// The tiers the core declares. const ( // Admin manages users beside everything a member reaches. Admin Role = "admin" @@ -26,6 +32,153 @@ 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" + +// Registry holds every role a deployment knows and the capabilities each carries. +type Registry struct { + mu sync.RWMutex + carried map[Role][]Capability +} + +// NewRegistry returns a registry holding the core roles and nothing a plugin declares. +func NewRegistry() *Registry { + return &Registry{carried: map[Role][]Capability{Admin: {ManageUsers}, 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 +} + +// 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)) + } + return named +} + +// 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 +} + +// 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() +} + +// 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 { @@ -34,19 +187,19 @@ func Of(stored string) Role { return Member } -// Tiers returns every tier in its stored form. +// Tiers returns every tier the default registry holds in its stored form. func Tiers() []string { - return []string{string(Admin), string(Member)} + 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 no deployment knows. +// Parse returns the tier the text names, refusing any tier the default registry does not hold. func Parse(text string) (Role, error) { - for _, tier := range Tiers() { - if text == tier { - return Role(tier), nil - } - } - return "", fmt.Errorf("%w: %q, want one of %s", ErrUnknownTier, text, strings.Join(Tiers(), " or ")) + return Default.Parse(text) } // String returns the stored form of the tier. diff --git a/sdk/sdk.go b/sdk/sdk.go index 33fe1695..bb0466da 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -61,6 +61,19 @@ type AreaProvider interface { Area() string } +// 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 +} + +// RoleProvider is implemented by plugins declaring roles or widening the ones the host knows. +type RoleProvider interface { + Roles() []RoleDeclaration +} + // GraphField is one runtime defined field a plugin serves over the graph. type GraphField struct { // Entity is the GraphQL type the field hangs on, such as Contact. From b71dff2b63b4cf20159555a3c7ddd52e59deffbd Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 22:41:03 +0200 Subject: [PATCH 02/47] chore(deps): absorb the gouncer releases that name roles --- frontend/package.json | 2 +- go.mod | 6 +++--- go.sum | 12 ++++++------ pnpm-lock.yaml | 14 +++++++------- pnpm-workspace.yaml | 2 +- sdk/frontend/package.json | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index aba180b1..e55018d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,7 @@ "@alphone/plugin-importer": "workspace:*", "@alphone/plugin-whatsapp": "workspace:*", "@gopherium/godmin": "0.7.0", - "@gopherium/react-auth": "0.4.0", + "@gopherium/react-auth": "0.6.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.23", "@wordpress/theme": "1.1.0", diff --git a/go.mod b/go.mod index f9d8c7b3..5df5ccb2 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/go-chi/chi/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/gopherium/gouncer/authkit v0.5.0 - github.com/gopherium/gouncer/authkit/postgres v0.3.0 + github.com/gopherium/gouncer/authkit v0.9.0 + github.com/gopherium/gouncer/authkit/postgres v0.7.0 github.com/gopherium/gouncer/authkit/ratelimit v0.3.0 github.com/gopherium/pluginkit v0.5.0 github.com/gopherium/pluginkit/graphwire v0.3.0 @@ -88,7 +88,7 @@ require ( github.com/fatih/structtag v1.2.0 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/google/cel-go v0.28.0 // indirect - github.com/gopherium/gouncer v0.1.0 + github.com/gopherium/gouncer v0.3.0 github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index db88049c..975b91d3 100644 --- a/go.sum +++ b/go.sum @@ -160,12 +160,12 @@ github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 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/gouncer v0.1.0 h1:R5y0kmal9+dLmG+tv3PLF2Ya8LFmWzQqUsiOl7bVERU= -github.com/gopherium/gouncer v0.1.0/go.mod h1:EZhUDgECayxwut/wq0ITyPTH4bYe7SB8EZMW3s9hpCM= -github.com/gopherium/gouncer/authkit v0.5.0 h1:F/5FE/WyFBB7AX2va1UZLzkWOAQo/fDelh1dVQZYow0= -github.com/gopherium/gouncer/authkit v0.5.0/go.mod h1:UlshoX6DGkXf5hdlUmHNg5rtCmO9VYqCo5kt+Iy8R6Y= -github.com/gopherium/gouncer/authkit/postgres v0.3.0 h1:4PURjXtqCLj819AiHljt1E8q+JAfDoUC0ohm7hgRdk4= -github.com/gopherium/gouncer/authkit/postgres v0.3.0/go.mod h1:c2pqWGX6L0hjIjq7+1NrGzXI7yE/AP07TYU9QcQlrmY= +github.com/gopherium/gouncer v0.3.0 h1:57DDOJ/GYYCEtcSScPOBYZ06TabGR164Cw7edhT3ME8= +github.com/gopherium/gouncer v0.3.0/go.mod h1:WwPOu8LyRikot0EODySSu46zaHcWTPbl5nOes4I3H2U= +github.com/gopherium/gouncer/authkit v0.9.0 h1:/HnNLb4cVoQWZCSCDyOn0HWgWHKglS/GScvy3eqa3ME= +github.com/gopherium/gouncer/authkit v0.9.0/go.mod h1:aQtcQewsZa/DJK7HBa3Jh0lCRLKOo8JMFtJAeC2Rzh4= +github.com/gopherium/gouncer/authkit/postgres v0.7.0 h1:FiraBa4X5bfNPpKpGa/CTAXRaXn02+Qd7qOeiWnMgMI= +github.com/gopherium/gouncer/authkit/postgres v0.7.0/go.mod h1:ceZbaPTkuMQ1aYEQT5LtgsiXnl+3ku20+drL9XEnDWk= github.com/gopherium/gouncer/authkit/ratelimit v0.3.0 h1:K4e8kwzO9XRJB95TErMVV4CuskTtG2c2CwL7nLAI/Ko= github.com/gopherium/gouncer/authkit/ratelimit v0.3.0/go.mod h1:Nezx00JuDgpuM62iF09HIfYD6nE4X22VvciY8wu3W3E= github.com/gopherium/pluginkit v0.5.0 h1:vJPqQXnC+Hxzi5iGqmbxNYtnSk9s5794PY7H80KcSAo= diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f020712c..b2c490dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,8 +56,8 @@ importers: specifier: 0.7.0 version: 0.7.0(@tanstack/react-router@1.170.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/style-runtime@0.8.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vitest@4.1.10) '@gopherium/react-auth': - specifier: 0.4.0 - version: 0.4.0(@tanstack/react-query@5.101.4(react@19.2.8))(@testing-library/jest-dom@6.9.1)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/i18n@6.26.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(react@19.2.8)(vitest@4.1.10) + specifier: 0.6.0 + version: 0.6.0(@tanstack/react-query@5.101.4(react@19.2.8))(@testing-library/jest-dom@6.9.1)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/i18n@6.26.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(react@19.2.8)(vitest@4.1.10) '@tanstack/react-query': specifier: ^5.101.4 version: 5.101.4(react@19.2.8) @@ -240,8 +240,8 @@ importers: specifier: 0.7.0 version: 0.7.0(@tanstack/react-router@1.170.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/style-runtime@0.8.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vitest@4.1.10) '@gopherium/react-auth': - specifier: 0.4.0 - version: 0.4.0(@tanstack/react-query@5.101.4(react@19.2.8))(@testing-library/jest-dom@6.9.1)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/i18n@6.26.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(react@19.2.8)(vitest@4.1.10) + specifier: 0.6.0 + version: 0.6.0(@tanstack/react-query@5.101.4(react@19.2.8))(@testing-library/jest-dom@6.9.1)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/i18n@6.26.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(react@19.2.8)(vitest@4.1.10) '@tanstack/react-query': specifier: ^5.101.4 version: 5.101.4(react@19.2.8) @@ -910,8 +910,8 @@ packages: vitest: optional: true - '@gopherium/react-auth@0.4.0': - resolution: {integrity: sha512-0NOEoxC5/Ea5bSiZazHfIv6v86RR+bM4HGPy/eZ7deXGaDmwjBNhaw+EaPlRWni9+aPHGI7lfwTiRyQbsqrasg==} + '@gopherium/react-auth@0.6.0': + resolution: {integrity: sha512-uAf9cVpjviuEFRr8/Oo3Ju+WO3CKlDzjjRcR1rXj4PSX2a/InJ6Q321IGnNE9L2mfxRMUgwQGaGMBQ/vaIEStA==} peerDependencies: '@tanstack/react-query': ^5.101.2 '@testing-library/jest-dom': ^6.9.1 @@ -5375,7 +5375,7 @@ snapshots: stylelint: 17.14.1(typescript@6.0.3) vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - '@gopherium/react-auth@0.4.0(@tanstack/react-query@5.101.4(react@19.2.8))(@testing-library/jest-dom@6.9.1)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/i18n@6.26.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(react@19.2.8)(vitest@4.1.10)': + '@gopherium/react-auth@0.6.0(@tanstack/react-query@5.101.4(react@19.2.8))(@testing-library/jest-dom@6.9.1)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@wordpress/i18n@6.26.0)(@wordpress/theme@1.1.0(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@wordpress/ui@0.19.0(@date-fns/tz@1.5.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(date-fns@4.4.0)(esbuild@0.28.1)(postcss@8.5.26)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(stylelint@17.14.1(typescript@6.0.3))(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(msw@2.15.0(@types/node@26.1.2)(typescript@6.0.3))(react@19.2.8)(vitest@4.1.10)': dependencies: '@tanstack/react-query': 5.101.4(react@19.2.8) '@wordpress/i18n': 6.26.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c3f45b35..09b426eb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,7 @@ packages: minimumReleaseAgeExclude: - '@gopherium/godmin' - - '@gopherium/react-auth@0.4.0' + - '@gopherium/react-auth@0.6.0' overrides: react: ^19.2.8 diff --git a/sdk/frontend/package.json b/sdk/frontend/package.json index 291c2429..1f65c532 100644 --- a/sdk/frontend/package.json +++ b/sdk/frontend/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@gopherium/godmin": "0.7.0", - "@gopherium/react-auth": "0.4.0", + "@gopherium/react-auth": "0.6.0", "@tanstack/react-query": "^5.101.4", "@urql/exchange-graphcache": "9.0.1", "@urql/exchange-retry": "2.0.0", From 13a0aad07a9aecbea88b5ed2d9537b61ddfde70e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 22:49:37 +0200 Subject: [PATCH 03/47] feat(cmd): take the role createadmin starts an account under --- cmd/alphone/createadmin.go | 21 +++++++++++++---- cmd/alphone/createadmin_test.go | 42 +++++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/cmd/alphone/createadmin.go b/cmd/alphone/createadmin.go index c536116b..5c9119c3 100644 --- a/cmd/alphone/createadmin.go +++ b/cmd/alphone/createadmin.go @@ -32,6 +32,7 @@ func createAdmin( flags.SetOutput(stdout) email := flags.String("email", "", "email address of the new user") name := flags.String("name", "", "display name of the new user") + named := flags.String("role", "", "role the new user starts under") if err := flags.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return nil @@ -43,6 +44,10 @@ func createAdmin( if databaseURL == "" { return errors.New("ALPHONE_DATABASE_URL is required") } + held, err := role.Parse(*named) + if err != nil { + return err + } pool, err := pgxpool.New(ctx, databaseURL) if err != nil { return fmt.Errorf("parse database url: %w", err) @@ -53,17 +58,23 @@ func createAdmin( } users := authkitpg.NewUserStore(pool) - if err := authkit.CreateAdmin(ctx, users, *email, *name, stdin, stdout); err != nil { + if err := authkit.CreateAdmin(ctx, users, *email, *name, held.String(), stdin, stdout); err != nil { return err } - return grantAdmin(ctx, pool, users, *email) + return grantRole(ctx, pool, users, *email, held) } -// grantAdmin puts the named user in the admin tier. -func grantAdmin(ctx context.Context, pool *pgxpool.Pool, users *authkitpg.UserStore, email string) error { +// grantRole puts the named user in the given tier. +func grantRole( + ctx context.Context, + pool *pgxpool.Pool, + users *authkitpg.UserStore, + email string, + held role.Role, +) error { owner, err := users.UserByEmail(ctx, strings.ToLower(strings.TrimSpace(email))) if err != nil { return err } - return postgres.NewRoleStore(pool).Grant(ctx, owner.ID, role.Admin) + return postgres.NewRoleStore(pool).Grant(ctx, owner.ID, held) } diff --git a/cmd/alphone/createadmin_test.go b/cmd/alphone/createadmin_test.go index 1f103ca0..c4ef0029 100644 --- a/cmd/alphone/createadmin_test.go +++ b/cmd/alphone/createadmin_test.go @@ -12,6 +12,8 @@ import ( "github.com/gopherium/gouncer" authkitpg "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/alphone/internal/role" ) const unreachableDatabaseURL = "postgres://postgres:alphone@localhost:9/postgres?sslmode=disable&connect_timeout=1" @@ -26,7 +28,7 @@ func TestCreateAdminProvisionsAUser(t *testing.T) { err := createAdmin( t.Context(), getenv, - []string{"-email", " Admin@Example.com ", "-name", "Admin"}, + []string{"-email", " Admin@Example.com ", "-name", "Admin", "-role", "admin"}, strings.NewReader("correct horse battery\n"), &stdout, ) @@ -57,7 +59,7 @@ func TestCreateAdminRejectsDuplicateEmail(t *testing.T) { databaseURL := testDatabaseURL(t) getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": databaseURL}) - args := []string{"-email", "admin@example.com", "-name", "Admin"} + args := []string{"-email", "admin@example.com", "-name", "Admin", "-role", "admin"} if err := createAdmin( t.Context(), getenv, args, strings.NewReader("correct horse battery\n"), io.Discard, @@ -84,7 +86,7 @@ func TestCreateAdminValidatesItsInput(t *testing.T) { }{ "missing database url": { env: nil, - args: []string{"-email", "admin@example.com", "-name", "Admin"}, + args: []string{"-email", "admin@example.com", "-name", "Admin", "-role", "admin"}, stdin: strings.NewReader("correct horse battery\n"), }, "unknown flag": { @@ -94,12 +96,12 @@ func TestCreateAdminValidatesItsInput(t *testing.T) { }, "malformed database url": { env: map[string]string{"ALPHONE_DATABASE_URL": "not a url \x00"}, - args: []string{"-email", "admin@example.com", "-name", "Admin"}, + args: []string{"-email", "admin@example.com", "-name", "Admin", "-role", "admin"}, stdin: strings.NewReader("correct horse battery\n"), }, "unreachable database": { env: map[string]string{"ALPHONE_DATABASE_URL": unreachableDatabaseURL}, - args: []string{"-email", "admin@example.com", "-name", "Admin"}, + args: []string{"-email", "admin@example.com", "-name", "Admin", "-role", "admin"}, stdin: strings.NewReader("correct horse battery\n"), }, } @@ -117,6 +119,36 @@ func TestCreateAdminValidatesItsInput(t *testing.T) { } } +func TestCreateAdminRefusesARoleTheRegistryDoesNotKnow(t *testing.T) { + t.Parallel() + + getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": testDatabaseURL(t)}) + + err := createAdmin( + t.Context(), + getenv, + []string{"-email", "admin@example.com", "-name", "Admin", "-role", "superadmin"}, + strings.NewReader("correct horse battery\n"), + io.Discard, + ) + + if !errors.Is(err, role.ErrUnknownTier) { + t.Errorf("createAdmin() error = %v, want a role no plugin declared refused", err) + } +} + +func TestCreateAdminNamesTheMissingDatabaseBeforeTheRole(t *testing.T) { + t.Parallel() + + err := createAdmin( + t.Context(), testGetenv(nil), nil, strings.NewReader(""), io.Discard, + ) + + if err == nil || errors.Is(err, role.ErrUnknownTier) { + t.Errorf("createAdmin() error = %v, want the database url named first", err) + } +} + func TestCreateAdminPrintsItsFlags(t *testing.T) { t.Parallel() From f3de4b4a1941f98ba0e953f6f98424362292f60e Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 22:49:49 +0200 Subject: [PATCH 04/47] feat(cmd): hand the brick the roles that administer accounts --- cmd/alphone/run.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index 92e76703..4bc5ce80 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -93,8 +93,12 @@ func run( return fmt.Errorf("start plugins: %w", err) } - auth := authkit.New(authkit.Config{Store: userStore, CookieName: server.SessionCookieName}) - admin := authkit.NewAdmin(userStore) + auth := authkit.New(authkit.Config{ + Store: userStore, + CookieName: server.SessionCookieName, + Privileged: role.Privileged(), + }) + admin := authkit.NewAdmin(authkit.AdminConfig{Store: userStore, Privileged: role.Privileged()}) graphRoot, err := graphroot.FromPlugins(&graphres.Resolver{ Version: version.Version(), Contacts: contacts, From a013c987a2e007130cac79b2d0e00e2abb674e08 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 22:49:58 +0200 Subject: [PATCH 05/47] feat(cmd): seed each demo account under the role it holds --- cmd/alphone/seed.go | 15 +++++++++------ cmd/alphone/seed_test.go | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd/alphone/seed.go b/cmd/alphone/seed.go index 3361bfb0..2f50e4c4 100644 --- a/cmd/alphone/seed.go +++ b/cmd/alphone/seed.go @@ -19,6 +19,7 @@ import ( "github.com/gopherium/alphone/internal/contact" "github.com/gopherium/alphone/internal/postgres" + "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/task" "github.com/gopherium/alphone/sdk" ) @@ -72,14 +73,14 @@ func seed(ctx context.Context, getenv func(string) string, stdout io.Writer) err type demoLogin struct { email string name string - tier string + tier role.Role } // demoLogins names every account the seeder ensures, in banner order. func demoLogins() []demoLogin { return []demoLogin{ - {email: seedAdminEmail, name: seedAdminName, tier: "admin"}, - {email: seedMemberEmail, name: seedMemberName, tier: "member"}, + {email: seedAdminEmail, name: seedAdminName, tier: role.Admin}, + {email: seedMemberEmail, name: seedMemberName, tier: role.Member}, } } @@ -88,13 +89,15 @@ func seedUsers(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) users := authkitpg.NewUserStore(pool) created := map[string]bool{} for _, login := range demoLogins() { - made, err := authkit.EnsureAdmin(ctx, users, login.email, login.name, seedAdminPassword) + made, err := authkit.EnsureAdmin( + ctx, users, login.email, login.name, seedAdminPassword, login.tier.String(), + ) if err != nil { return nil, err } created[login.email] = made } - if err := grantAdmin(ctx, pool, users, seedAdminEmail); err != nil { + if err := grantRole(ctx, pool, users, seedAdminEmail, role.Admin); err != nil { return nil, err } return created, nil @@ -105,7 +108,7 @@ func reportLogins(stdout io.Writer, created map[string]bool) { for _, login := range demoLogins() { if created[login.email] { _, _ = fmt.Fprintln(stdout, - "login: "+login.email+" / "+seedAdminPassword+" ("+login.tier+")") + "login: "+login.email+" / "+seedAdminPassword+" ("+login.tier.String()+")") continue } _, _ = fmt.Fprintln(stdout, login.email+" already exists, its password is unchanged") diff --git a/cmd/alphone/seed_test.go b/cmd/alphone/seed_test.go index 6916f2b0..8e0b535e 100644 --- a/cmd/alphone/seed_test.go +++ b/cmd/alphone/seed_test.go @@ -154,7 +154,7 @@ func TestSeedNamesEveryLoginItCreates(t *testing.T) { getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": databaseURL}) pool := testPool(t, databaseURL) if _, err := authkit.EnsureAdmin(t.Context(), authkitpg.NewUserStore(pool), - seedAdminEmail, seedAdminName, seedAdminPassword); err != nil { + seedAdminEmail, seedAdminName, seedAdminPassword, role.Admin.String()); err != nil { t.Fatalf("seeding the admin ahead of the run: %v", err) } var stdout strings.Builder @@ -462,7 +462,7 @@ func TestSeedReportsTheColleagueItCannotStore(t *testing.T) { getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": databaseURL}) pool := testPool(t, databaseURL) if _, err := authkit.EnsureAdmin(t.Context(), authkitpg.NewUserStore(pool), - seedAdminEmail, seedAdminName, seedAdminPassword); err != nil { + seedAdminEmail, seedAdminName, seedAdminPassword, role.Admin.String()); err != nil { t.Fatalf("seeding the admin: %v", err) } if _, err := pool.Exec(t.Context(), From 4019c7052afabab37869739d2d0188e9ee0e62b1 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 22:50:06 +0200 Subject: [PATCH 06/47] test(cmd): name the role every bootstrap invocation passes --- Makefile | 2 +- cmd/alphone/main_exec_test.go | 8 ++++---- cmd/alphone/pluginarea_exec_test.go | 2 +- cmd/alphone/roles_exec_test.go | 6 +++--- cmd/alphone/token_test.go | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 64c46a63..d777f494 100644 --- a/Makefile +++ b/Makefile @@ -144,7 +144,7 @@ e2e-db-reset: db-up e2e-seed: db-up e2e-build printf '%s\n' "$(E2E_PASSWORD)" | \ ALPHONE_DATABASE_URL="$(E2E_DATABASE_URL)" ./alphone createadmin \ - -email "$(E2E_EMAIL)" -name "$(E2E_NAME)" + -email "$(E2E_EMAIL)" -name "$(E2E_NAME)" -role admin e2e-reset: e2e-db-reset e2e-seed diff --git a/cmd/alphone/main_exec_test.go b/cmd/alphone/main_exec_test.go index 6a887e82..c54a13fd 100644 --- a/cmd/alphone/main_exec_test.go +++ b/cmd/alphone/main_exec_test.go @@ -128,7 +128,7 @@ func TestMainBinaryCreateAdminCreatesUser(t *testing.T) { binary, env := coverBinary(t) var stdout, stderr bytes.Buffer - cmd := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin") + cmd := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin", "-role", "admin") cmd.Dir = t.TempDir() cmd.Env = append(env, "ALPHONE_DATABASE_URL="+testDatabaseURL(t)) cmd.Stdin = strings.NewReader("correct horse battery\n") @@ -423,7 +423,7 @@ func postForm(t *testing.T, addr, secret, contentType string, body io.Reader) gr func servedBinary(t *testing.T, databaseURL string) (string, string) { t.Helper() binary, env := coverBinary(t) - createUser := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin") + createUser := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin", "-role", "admin") createUser.Dir = t.TempDir() createUser.Env = append(env, "ALPHONE_DATABASE_URL="+databaseURL) createUser.Stdin = strings.NewReader("correct horse battery\n") @@ -568,7 +568,7 @@ func TestMainBinaryAdvertisesTheBuildVersionOverMCP(t *testing.T) { binary, env := coverBinary(t) databaseURL := testDatabaseURL(t) - createUser := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin") + createUser := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin", "-role", "admin") createUser.Dir = t.TempDir() createUser.Env = append(env, "ALPHONE_DATABASE_URL="+databaseURL) createUser.Stdin = strings.NewReader("correct horse battery\n") @@ -640,7 +640,7 @@ func TestMainBinaryTokenCreatesAToken(t *testing.T) { binary, env := coverBinary(t) databaseURL := testDatabaseURL(t) - createUser := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin") + createUser := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin", "-role", "admin") createUser.Dir = t.TempDir() createUser.Env = append(env, "ALPHONE_DATABASE_URL="+databaseURL) createUser.Stdin = strings.NewReader("correct horse battery\n") diff --git a/cmd/alphone/pluginarea_exec_test.go b/cmd/alphone/pluginarea_exec_test.go index 494b6986..f600de24 100644 --- a/cmd/alphone/pluginarea_exec_test.go +++ b/cmd/alphone/pluginarea_exec_test.go @@ -48,7 +48,7 @@ func TestMainBinaryHoldsAPluginRouteToItsDeclaredArea(t *testing.T) { databaseURL := testDatabaseURL(t) binary, env := coverBinary(t) - createUser := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin") + createUser := exec.Command(binary, "createadmin", "-email", "admin@example.com", "-name", "Admin", "-role", "admin") createUser.Dir = t.TempDir() createUser.Env = append(env, "ALPHONE_DATABASE_URL="+databaseURL) createUser.Stdin = strings.NewReader("correct horse battery\n") diff --git a/cmd/alphone/roles_exec_test.go b/cmd/alphone/roles_exec_test.go index b0490341..c226a709 100644 --- a/cmd/alphone/roles_exec_test.go +++ b/cmd/alphone/roles_exec_test.go @@ -34,10 +34,10 @@ func TestGrantAdminReportsAnUnknownUser(t *testing.T) { } t.Cleanup(pool.Close) - err = grantAdmin(t.Context(), pool, authkitpg.NewUserStore(pool), "nobody@example.com") + err = grantRole(t.Context(), pool, authkitpg.NewUserStore(pool), "nobody@example.com", role.Admin) if err == nil { - t.Error("grantAdmin() error = nil, want a refusal for a user that does not exist") + t.Error("grantRole() error = nil, want a refusal for a user that does not exist") } } @@ -48,7 +48,7 @@ func TestCreateAdminProvisionsAnAdminOnABareDatabase(t *testing.T) { getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": databaseURL}) err := createAdmin(t.Context(), getenv, - []string{"-email", "admin@example.com", "-name", "Admin"}, + []string{"-email", "admin@example.com", "-name", "Admin", "-role", "admin"}, strings.NewReader("correct horse battery\n"), &strings.Builder{}) if err != nil { diff --git a/cmd/alphone/token_test.go b/cmd/alphone/token_test.go index 01ca7837..6853809e 100644 --- a/cmd/alphone/token_test.go +++ b/cmd/alphone/token_test.go @@ -25,7 +25,7 @@ func seedTokenUser(t *testing.T, getenv func(string) string) { err := createAdmin( t.Context(), getenv, - []string{"-email", "admin@example.com", "-name", "Admin"}, + []string{"-email", "admin@example.com", "-name", "Admin", "-role", "admin"}, strings.NewReader("correct horse battery\n"), io.Discard, ) From 54bcff20df553735f3968f0812dccedb816defa8 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 22:50:15 +0200 Subject: [PATCH 07/47] feat(graphres): create an account under the narrowest role --- internal/graphres/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/graphres/auth.go b/internal/graphres/auth.go index 3173be6d..5c3c4e5e 100644 --- a/internal/graphres/auth.go +++ b/internal/graphres/auth.go @@ -142,7 +142,7 @@ func (m MutationResolvers) Logout(ctx context.Context) (bool, error) { // CreateUser creates a user account. func (m MutationResolvers) CreateUser(ctx context.Context, email, name, password string) (*model.User, error) { - account, err := m.root.Admin.CreateAccount(ctx, email, name, password) + account, err := m.root.Admin.CreateAccount(ctx, email, name, password, role.Member.String()) if err != nil { return nil, err } From 556250e15b808dd23a2c8a3f26787ad4124278cf Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 22:50:23 +0200 Subject: [PATCH 08/47] test: build the brick's admin config in every harness --- internal/graphres/auth_test.go | 2 +- internal/server/graphql_test.go | 2 +- test/features/world_test.go | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/graphres/auth_test.go b/internal/graphres/auth_test.go index a80c5dc4..24207172 100644 --- a/internal/graphres/auth_test.go +++ b/internal/graphres/auth_test.go @@ -28,7 +28,7 @@ func newAuthResolver(store *testkit.Store) *graphres.Resolver { return &graphres.Resolver{ Version: "9.9.9", Auth: authkit.New(authkit.Config{Store: store, CookieName: "alphone_session"}), - Admin: authkit.NewAdmin(store), + Admin: authkit.NewAdmin(authkit.AdminConfig{Store: store}), Roles: standingRoleStore{tier: role.Member}, LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{Limit: 2, Window: time.Minute}), } diff --git a/internal/server/graphql_test.go b/internal/server/graphql_test.go index 4302e361..8c57525b 100644 --- a/internal/server/graphql_test.go +++ b/internal/server/graphql_test.go @@ -60,7 +60,7 @@ func newGraphServer(t *testing.T, cfg graphConfig) http.Handler { func newSubscribingGraphServer(t *testing.T, cfg graphConfig, hub *event.Hub) http.Handler { t.Helper() auth := authkit.New(authkit.Config{Store: cfg.Users, CookieName: server.SessionCookieName}) - admin := authkit.NewAdmin(cfg.Users) + admin := authkit.NewAdmin(authkit.AdminConfig{Store: cfg.Users}) 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) diff --git a/test/features/world_test.go b/test/features/world_test.go index 45cd9939..2216a0a8 100644 --- a/test/features/world_test.go +++ b/test/features/world_test.go @@ -127,13 +127,15 @@ func bootWorld(t *testing.T, liveImports bool) *world { Roles: roles, Live: hub, Auth: auth, - Admin: authkit.NewAdmin(users), + Admin: authkit.NewAdmin(authkit.AdminConfig{Store: users}), LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}), }, registered) if err != nil { t.Fatalf("composing the graph root: %v", err) } - if _, err := authkit.EnsureAdmin(context.Background(), users, ownerEmail, ownerName, ownerPassword); err != nil { + 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) @@ -294,7 +296,7 @@ func inertPlugins(t *testing.T) []sdk.Plugin { // addUser stores another user and returns its id. func (w *world) addUser(ctx context.Context, email, name string) (uuid.UUID, error) { - if _, err := authkit.EnsureAdmin(ctx, w.users, email, name, ownerPassword); err != nil { + if _, err := authkit.EnsureAdmin(ctx, w.users, email, name, ownerPassword, role.Member.String()); err != nil { return uuid.Nil, fmt.Errorf("seeding %s: %w", email, err) } stored, err := w.users.UserByEmail(ctx, email) From 1f32e0fe3691a7744971d0b17318e166782f1b6b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:32:41 +0200 Subject: [PATCH 09/47] feat(postgres): move every tier onto the account and drop the table --- internal/postgres/db/models.go | 6 - internal/postgres/db/queries.sql.go | 44 -- .../migrations/00014_move_user_roles.sql | 20 + internal/postgres/movedroles_test.go | 142 +++++ internal/postgres/queries.sql | 10 - internal/postgres/roles.go | 173 ------ internal/postgres/roles_internal_test.go | 105 ---- internal/postgres/roles_test.go | 498 ------------------ internal/postgres/tokens_test.go | 4 +- 9 files changed, 164 insertions(+), 838 deletions(-) create mode 100644 internal/postgres/migrations/00014_move_user_roles.sql create mode 100644 internal/postgres/movedroles_test.go delete mode 100644 internal/postgres/roles.go delete mode 100644 internal/postgres/roles_internal_test.go delete mode 100644 internal/postgres/roles_test.go diff --git a/internal/postgres/db/models.go b/internal/postgres/db/models.go index 0d0f0bb9..80c211eb 100644 --- a/internal/postgres/db/models.go +++ b/internal/postgres/db/models.go @@ -62,12 +62,6 @@ type CoreTenantMember struct { CreatedAt time.Time } -type CoreUserRole struct { - UserID uuid.UUID - Role string - CreatedAt time.Time -} - type CoreWebhookDelivery struct { ID uuid.UUID SubscriptionID uuid.UUID diff --git a/internal/postgres/db/queries.sql.go b/internal/postgres/db/queries.sql.go index 75dc4e77..42a2cee8 100644 --- a/internal/postgres/db/queries.sql.go +++ b/internal/postgres/db/queries.sql.go @@ -956,47 +956,3 @@ func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (CoreTas ) return i, err } - -const userRole = `-- name: UserRole :one -SELECT role -FROM core.user_roles -WHERE user_id = $1::uuid -` - -func (q *Queries) UserRole(ctx context.Context, userID uuid.UUID) (string, error) { - row := q.db.QueryRow(ctx, userRole, userID) - var role string - err := row.Scan(&role) - return role, err -} - -const userRoles = `-- name: UserRoles :many -SELECT user_id, role -FROM core.user_roles -WHERE user_id = ANY($1::uuid[]) -` - -type UserRolesRow struct { - UserID uuid.UUID - Role string -} - -func (q *Queries) UserRoles(ctx context.Context, userIds []uuid.UUID) ([]UserRolesRow, error) { - rows, err := q.db.Query(ctx, userRoles, userIds) - if err != nil { - return nil, err - } - defer rows.Close() - var items []UserRolesRow - for rows.Next() { - var i UserRolesRow - if err := rows.Scan(&i.UserID, &i.Role); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/internal/postgres/migrations/00014_move_user_roles.sql b/internal/postgres/migrations/00014_move_user_roles.sql new file mode 100644 index 00000000..b67ccf2e --- /dev/null +++ b/internal/postgres/migrations/00014_move_user_roles.sql @@ -0,0 +1,20 @@ +-- SPDX-License-Identifier: Elastic-2.0 + +-- +goose Up +UPDATE auth.users SET role = held.role +FROM core.user_roles held +WHERE held.user_id = auth.users.id; + +UPDATE auth.users SET role = 'member' WHERE role = ''; + +DROP TABLE core.user_roles; + +-- +goose Down +CREATE TABLE core.user_roles ( + user_id uuid PRIMARY KEY REFERENCES auth.users (id) ON DELETE CASCADE, + role text NOT NULL CHECK (role IN ('admin', 'member')), + created_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO core.user_roles (user_id, role) +SELECT id, role FROM auth.users WHERE role IN ('admin', 'member'); diff --git a/internal/postgres/movedroles_test.go b/internal/postgres/movedroles_test.go new file mode 100644 index 00000000..fb3643d1 --- /dev/null +++ b/internal/postgres/movedroles_test.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package postgres_test + +import ( + "database/sql" + "testing" + + "github.com/google/uuid" + "github.com/peterldowns/pgtestdb" + + "github.com/gopherium/alphone/internal/role" + "github.com/gopherium/alphone/internal/testdb" +) + +// seedUser stores one user and returns its identifier. +func seedUser(t *testing.T, db *sql.DB, email string) uuid.UUID { + t.Helper() + id := uuid.Must(uuid.NewV7()) + if _, err := db.ExecContext(t.Context(), + `INSERT INTO auth.users (id, email, name, password_hash, disabled, created_at) + VALUES ($1, $2, 'Maria Perez', 'hash', false, now())`, id, email); err != nil { + t.Fatalf("storing the user: %v", err) + } + return id +} + +// storedRole returns the role the brick's column holds for a user. +func storedRole(t *testing.T, db *sql.DB, userID uuid.UUID) string { + t.Helper() + var held string + if err := db.QueryRowContext(t.Context(), + "SELECT role FROM auth.users WHERE id = $1", userID).Scan(&held); err != nil { + t.Fatalf("reading the role: %v", err) + } + return held +} + +// grantTier stores one core.user_roles row for a user. +func grantTier(t *testing.T, db *sql.DB, userID uuid.UUID, tier string) { + t.Helper() + if _, err := db.ExecContext(t.Context(), + "INSERT INTO core.user_roles (user_id, role) VALUES ($1, $2)", userID, tier); err != nil { + t.Fatalf("granting %q: %v", tier, err) + } +} + +func TestMigrationMovesEveryTierOntoTheAccount(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping database test in short mode") + } + + cfg := pgtestdb.Custom(t, testdb.Config(), testdb.Migrator()) + db, err := sql.Open("pgx", cfg.URL()) + if err != nil { + t.Fatalf("opening the database: %v", err) + } + defer func() { _ = db.Close() }() + provider := coreProvider(t, db) + if _, err := provider.DownTo(t.Context(), movedRolesVersion-1); err != nil { + t.Fatalf("rolling back to the schema before the move: %v", err) + } + standing := seedUser(t, db, "admin@example.com") + working := seedUser(t, db, "member@example.com") + absent := seedUser(t, db, "norow@example.com") + grantTier(t, db, standing, role.Admin.String()) + grantTier(t, db, working, role.Member.String()) + + if _, err := provider.UpTo(t.Context(), movedRolesVersion); err != nil { + t.Fatalf("applying the move: %v", err) + } + + if got := storedRole(t, db, standing); got != role.Admin.String() { + t.Errorf("role = %q, want %q, an admin keeps the authority it had", got, role.Admin.String()) + } + if got := storedRole(t, db, working); got != role.Member.String() { + t.Errorf("role = %q, want %q", got, role.Member.String()) + } + if got := storedRole(t, db, absent); got != role.Member.String() { + t.Errorf("role = %q, want %q, an account holding no row is a member", got, role.Member.String()) + } +} + +func TestMigrationLeavesNoRolesTableBehind(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping database test in short mode") + } + + cfg := pgtestdb.Custom(t, testdb.Config(), testdb.Migrator()) + db, err := sql.Open("pgx", cfg.URL()) + if err != nil { + t.Fatalf("opening the database: %v", err) + } + defer func() { _ = db.Close() }() + + var held bool + if err := db.QueryRowContext(t.Context(), + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'core' AND table_name = 'user_roles' + )`).Scan(&held); err != nil { + t.Fatalf("reading the table list: %v", err) + } + + if held { + t.Error("core.user_roles still stands, want the move to drop it") + } +} + +func TestMigrationRestoresTheRolesTableGoingDown(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping database test in short mode") + } + + cfg := pgtestdb.Custom(t, testdb.Config(), testdb.Migrator()) + db, err := sql.Open("pgx", cfg.URL()) + if err != nil { + t.Fatalf("opening the database: %v", err) + } + defer func() { _ = db.Close() }() + standing := seedUser(t, db, "admin@example.com") + if _, err := db.ExecContext(t.Context(), + "UPDATE auth.users SET role = $1 WHERE id = $2", role.Admin.String(), standing); err != nil { + t.Fatalf("standing the user in the admin tier: %v", err) + } + + if _, err := coreProvider(t, db).DownTo(t.Context(), movedRolesVersion-1); err != nil { + t.Fatalf("rolling the move back: %v", err) + } + + var held string + if err := db.QueryRowContext(t.Context(), + "SELECT role FROM core.user_roles WHERE user_id = $1", standing).Scan(&held); err != nil { + t.Fatalf("reading the restored row: %v", err) + } + if held != role.Admin.String() { + t.Errorf("restored role = %q, want %q", held, role.Admin.String()) + } +} diff --git a/internal/postgres/queries.sql b/internal/postgres/queries.sql index 786ff684..6ea2aeb3 100644 --- a/internal/postgres/queries.sql +++ b/internal/postgres/queries.sql @@ -198,13 +198,3 @@ LEFT JOIN core.tenant_members m ON m.tenant_id = t.id AND m.user_id = @user_id:: WHERE m.user_id IS NOT NULL OR t.id = @default_id::uuid ORDER BY m.user_id IS NOT NULL DESC LIMIT 1; - --- name: UserRole :one -SELECT role -FROM core.user_roles -WHERE user_id = @user_id::uuid; - --- name: UserRoles :many -SELECT user_id, role -FROM core.user_roles -WHERE user_id = ANY(@user_ids::uuid[]); diff --git a/internal/postgres/roles.go b/internal/postgres/roles.go deleted file mode 100644 index 6ff2fca6..00000000 --- a/internal/postgres/roles.go +++ /dev/null @@ -1,173 +0,0 @@ -// SPDX-License-Identifier: Elastic-2.0 - -package postgres - -import ( - "context" - "errors" - "fmt" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - - "github.com/gopherium/gouncer" - - "github.com/gopherium/alphone/internal/postgres/db" - "github.com/gopherium/alphone/internal/role" -) - -// ErrLastAdmin reports a change that would leave the deployment with no enabled admin. -var ErrLastAdmin = role.ErrLastAdmin - -// grantKnownUser stands one user in a tier only when the deployment holds that user. -const grantKnownUser = `INSERT INTO core.user_roles (user_id, role) -SELECT $1, $2 WHERE EXISTS (SELECT 1 FROM auth.users WHERE id = $1) -ON CONFLICT (user_id) DO UPDATE SET role = EXCLUDED.role` - -// holdAdmins locks every admin row in a fixed order so two unseatings cannot pass each other. -const holdAdmins = "SELECT user_id FROM core.user_roles WHERE role = 'admin' ORDER BY user_id FOR UPDATE" - -// guardedDisable bars one user from logging in only while the deployment keeps an enabled admin, -// answering whether the user is known and whether it was barred. -const guardedDisable = `WITH barred AS ( - UPDATE auth.users SET disabled = true - WHERE id = $1 AND ( - NOT EXISTS (SELECT 1 FROM core.user_roles held WHERE held.user_id = $1 AND held.role = 'admin') - OR EXISTS ( - SELECT 1 FROM core.user_roles other - JOIN auth.users u ON u.id = other.user_id - WHERE other.role = 'admin' AND other.user_id <> $1 AND NOT u.disabled - ) - ) - RETURNING id -) -SELECT EXISTS (SELECT 1 FROM auth.users WHERE id = $1), EXISTS (SELECT 1 FROM barred)` - -// guardedDemote stands one user below admin only while another enabled admin stands, -// answering whether the tier was stored. -const guardedDemote = `WITH stood AS ( - INSERT INTO core.user_roles (user_id, role) - SELECT $1, $2 WHERE EXISTS (SELECT 1 FROM auth.users WHERE id = $1) - ON CONFLICT (user_id) DO UPDATE SET role = EXCLUDED.role - WHERE EXISTS ( - SELECT 1 FROM core.user_roles other - JOIN auth.users u ON u.id = other.user_id - WHERE other.role = 'admin' AND other.user_id <> $1 AND NOT u.disabled - ) - RETURNING user_id -) -SELECT EXISTS (SELECT 1 FROM auth.users WHERE id = $1), EXISTS (SELECT 1 FROM stood)` - -// RoleStore persists the tier every user stands in. -type RoleStore struct { - pool *pgxpool.Pool - queries *db.Queries -} - -// NewRoleStore returns a [RoleStore] backed by pool. -func NewRoleStore(pool *pgxpool.Pool) *RoleStore { - return &RoleStore{pool: pool, queries: db.New(pool)} -} - -// RoleOf returns the tier a user stands in, member when it holds no row. -func (s *RoleStore) RoleOf(ctx context.Context, userID uuid.UUID) (role.Role, error) { - stored, err := s.queries.UserRole(ctx, userID) - if errors.Is(err, pgx.ErrNoRows) { - return role.Member, nil - } - if err != nil { - return role.Member, fmt.Errorf("postgres: read user role: %w", err) - } - return role.Of(stored), nil -} - -// RolesOf returns the tier each named user stands in, member for those holding no row. -func (s *RoleStore) RolesOf(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]role.Role, error) { - tiers := make(map[uuid.UUID]role.Role, len(userIDs)) - for _, id := range userIDs { - tiers[id] = role.Member - } - rows, err := s.queries.UserRoles(ctx, userIDs) - if err != nil { - return nil, fmt.Errorf("postgres: read user roles: %w", err) - } - for _, row := range rows { - tiers[row.UserID] = role.Of(row.Role) - } - return tiers, nil -} - -// Grant stores the tier a user stands in, refusing to unseat the last enabled admin. -func (s *RoleStore) Grant(ctx context.Context, userID uuid.UUID, tier role.Role) error { - if tier != role.Admin { - return s.demote(ctx, userID, tier) - } - tag, err := s.pool.Exec(ctx, grantKnownUser, userID, tier.String()) - if err != nil { - return fmt.Errorf("postgres: grant user role: %w", err) - } - if tag.RowsAffected() == 0 { - return gouncer.ErrUserNotFound - } - return nil -} - -// unseating runs one write that may remove admin cover behind a lock over every admin row. -func (s *RoleStore) unseating(ctx context.Context, write func(pgx.Tx) error) error { - tx, err := s.pool.Begin(ctx) - if err != nil { - return err - } - defer func() { _ = tx.Rollback(ctx) }() - if _, err := tx.Exec(ctx, holdAdmins); err != nil { - return err - } - if err := write(tx); err != nil { - return err - } - return tx.Commit(ctx) -} - -// Disable bars one user from logging in and ends its sessions, refusing to unseat the last enabled admin. -func (s *RoleStore) Disable(ctx context.Context, userID uuid.UUID) error { - var known, barred bool - err := s.unseating(ctx, func(tx pgx.Tx) error { - if err := tx.QueryRow(ctx, guardedDisable, userID).Scan(&known, &barred); err != nil { - return err - } - if !barred { - return nil - } - _, err := tx.Exec(ctx, "DELETE FROM auth.sessions WHERE user_id = $1", userID) - return err - }) - if err != nil { - return fmt.Errorf("postgres: disable user: %w", err) - } - if !known { - return gouncer.ErrUserNotFound - } - if !barred { - return ErrLastAdmin - } - return nil -} - -// demote stores a tier below admin only while another enabled admin stands. -func (s *RoleStore) demote(ctx context.Context, userID uuid.UUID, tier role.Role) error { - var known, stood bool - err := s.unseating(ctx, func(tx pgx.Tx) error { - return tx.QueryRow(ctx, guardedDemote, userID, tier.String()).Scan(&known, &stood) - }) - if err != nil { - return fmt.Errorf("postgres: demote user: %w", err) - } - if !known { - return gouncer.ErrUserNotFound - } - if !stood { - return ErrLastAdmin - } - return nil -} diff --git a/internal/postgres/roles_internal_test.go b/internal/postgres/roles_internal_test.go deleted file mode 100644 index 9a8317d5..00000000 --- a/internal/postgres/roles_internal_test.go +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: Elastic-2.0 - -package postgres - -import ( - "errors" - "testing" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/peterldowns/pgtestdb" -) - -// errWriteRefused reports a write the guarded transaction could not run. -var errWriteRefused = errors.New("write refused") - -// probeConfig names the database server the guarded transaction tests run against. -func probeConfig() pgtestdb.Config { - return pgtestdb.Config{ - DriverName: "pgx", - User: "postgres", - Password: "alphone", - Host: "localhost", - Port: "5433", - Database: "postgres", - Options: "sslmode=disable", - } -} - -// probePool returns a pool over a database migrated by the given migrator. -func probePool(t *testing.T, migrator pgtestdb.Migrator) *pgxpool.Pool { - t.Helper() - if testing.Short() { - t.Skip("skipping database test in short mode") - } - cfg := pgtestdb.Custom(t, probeConfig(), migrator) - pool, err := pgxpool.New(t.Context(), cfg.URL()) - if err != nil { - t.Fatalf("connecting pool: %v", err) - } - t.Cleanup(pool.Close) - return pool -} - -// heldPool returns a pool over a database holding only the rows the guarded transaction locks. -func heldPool(t *testing.T) *pgxpool.Pool { - t.Helper() - pool := probePool(t, pgtestdb.NoopMigrator{}) - if _, err := pool.Exec(t.Context(), `CREATE SCHEMA core; - CREATE TABLE core.user_roles (user_id uuid PRIMARY KEY, role text NOT NULL)`); err != nil { - t.Fatalf("creating the roles table: %v", err) - } - return pool -} - -func TestUnseatingReportsAWriteItCouldNotRun(t *testing.T) { - t.Parallel() - - store := NewRoleStore(heldPool(t)) - - err := store.unseating(t.Context(), func(pgx.Tx) error { return errWriteRefused }) - - if !errors.Is(err, errWriteRefused) { - t.Errorf("unseating() error = %v, want %v", err, errWriteRefused) - } -} - -func TestUnseatingReportsATransactionItCouldNotOpen(t *testing.T) { - t.Parallel() - - pool := heldPool(t) - store := NewRoleStore(pool) - pool.Close() - - err := store.unseating(t.Context(), func(pgx.Tx) error { return nil }) - - if err == nil { - t.Error("unseating() on a closed pool error = nil, want error") - } -} - -func TestDisableReportsAGuardItCouldNotRead(t *testing.T) { - t.Parallel() - - store := NewRoleStore(heldPool(t)) - - err := store.Disable(t.Context(), uuid.Must(uuid.NewV7())) - - if err == nil { - t.Error("Disable() error = nil on a database holding no users, want error") - } -} - -func TestUnseatingReportsAdminsItCouldNotHold(t *testing.T) { - t.Parallel() - - store := NewRoleStore(probePool(t, pgtestdb.NoopMigrator{})) - - held := store.unseating(t.Context(), func(pgx.Tx) error { return nil }) - - if held == nil { - t.Error("unseating() error = nil on a database holding no roles, want error") - } -} diff --git a/internal/postgres/roles_test.go b/internal/postgres/roles_test.go deleted file mode 100644 index 0865ba40..00000000 --- a/internal/postgres/roles_test.go +++ /dev/null @@ -1,498 +0,0 @@ -// SPDX-License-Identifier: Elastic-2.0 - -package postgres_test - -import ( - "context" - "database/sql" - "errors" - "testing" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/peterldowns/pgtestdb" - - "github.com/gopherium/gouncer" - - "github.com/gopherium/alphone/internal/postgres" - "github.com/gopherium/alphone/internal/role" - "github.com/gopherium/alphone/internal/testdb" -) - -// seedPoolUser stores one auth user through the pool and returns its id. -func seedPoolUser(t *testing.T, pool *pgxpool.Pool, email string) uuid.UUID { - t.Helper() - id := uuid.Must(uuid.NewV7()) - if _, err := pool.Exec(t.Context(), - `INSERT INTO auth.users (id, email, name, password_hash, disabled, created_at) - VALUES ($1, $2, 'Maria Perez', 'hash', false, now())`, id, email); err != nil { - t.Fatalf("storing the user: %v", err) - } - return id -} - -// seedUser stores one auth user and returns its id. -func seedUser(t *testing.T, db *sql.DB, email string) uuid.UUID { - t.Helper() - id := uuid.Must(uuid.NewV7()) - if _, err := db.ExecContext(t.Context(), - `INSERT INTO auth.users (id, email, name, password_hash, disabled, created_at) - VALUES ($1, $2, 'Maria Perez', 'hash', false, now())`, id, email); err != nil { - t.Fatalf("storing the user: %v", err) - } - return id -} - -// seedSession stores one live session for a user. -func seedSession(t *testing.T, pool *pgxpool.Pool, userID uuid.UUID) { - t.Helper() - if _, err := pool.Exec(t.Context(), - `INSERT INTO auth.sessions (token_hash, user_id, expires_at, created_at) - VALUES ($1, $2, now() + interval '1 hour', now())`, []byte(uuid.Must(uuid.NewV7()).String()), userID); err != nil { - t.Fatalf("storing the session: %v", err) - } -} - -// sessionCount returns how many sessions one user holds. -func sessionCount(t *testing.T, pool *pgxpool.Pool, userID uuid.UUID) int { - t.Helper() - var held int - if err := pool.QueryRow(t.Context(), - "SELECT count(*) FROM auth.sessions WHERE user_id = $1", userID).Scan(&held); err != nil { - t.Fatalf("counting the sessions: %v", err) - } - return held -} - -// userDisabled reports whether one user is barred from logging in. -func userDisabled(t *testing.T, pool *pgxpool.Pool, id uuid.UUID) bool { - t.Helper() - var disabled bool - if err := pool.QueryRow(t.Context(), - "SELECT disabled FROM auth.users WHERE id = $1", id).Scan(&disabled); err != nil { - t.Fatalf("reading the disabled flag: %v", err) - } - return disabled -} - -// roleOf returns the tier stored for one user. -func roleOf(t *testing.T, db *sql.DB, id uuid.UUID) string { - t.Helper() - var stored string - err := db.QueryRowContext(t.Context(), "SELECT role FROM core.user_roles WHERE user_id = $1", id).Scan(&stored) - if err == sql.ErrNoRows { - return "" - } - if err != nil { - t.Fatalf("reading the role: %v", err) - } - return stored -} - -func TestMigrationGrantsAdminToAUserFromBeforeRoles(t *testing.T) { - t.Parallel() - if testing.Short() { - t.Skip("skipping database test in short mode") - } - - cfg := pgtestdb.Custom(t, testdb.Config(), testdb.Migrator()) - db, err := sql.Open("pgx", cfg.URL()) - if err != nil { - t.Fatalf("opening the database: %v", err) - } - defer func() { _ = db.Close() }() - provider := coreProvider(t, db) - if _, err := provider.DownTo(t.Context(), grantedRolesVersion-1); err != nil { - t.Fatalf("rolling back to the schema before roles: %v", err) - } - existing := seedUser(t, db, "before@example.com") - - if _, err := provider.UpTo(t.Context(), grantedRolesVersion); err != nil { - t.Fatalf("applying the roles migration: %v", err) - } - - if got := roleOf(t, db, existing); got != role.Admin.String() { - t.Errorf("role = %q, want %q, a user from before roles keeps the authority it had", - got, role.Admin.String()) - } -} - -func TestAUserCreatedAfterTheMigrationHoldsNoRow(t *testing.T) { - t.Parallel() - if testing.Short() { - t.Skip("skipping database test in short mode") - } - - cfg := pgtestdb.Custom(t, testdb.Config(), testdb.Migrator()) - db, err := sql.Open("pgx", cfg.URL()) - if err != nil { - t.Fatalf("opening the database: %v", err) - } - defer func() { _ = db.Close() }() - - fresh := seedUser(t, db, "after@example.com") - - if got := roleOf(t, db, fresh); got != "" { - t.Errorf("role = %q, want no row, absence is what makes a member", got) - } -} - -func TestRoleStoreReadsMemberForAUserItHoldsNoRowFor(t *testing.T) { - t.Parallel() - - store := postgres.NewRoleStore(newTestPool(t)) - - got, err := store.RoleOf(t.Context(), uuid.Must(uuid.NewV7())) - - if err != nil { - t.Fatalf("RoleOf() error = %v, want nil", err) - } - if got != role.Member { - t.Errorf("RoleOf() = %v, want %v, absence is what makes a member", got, role.Member) - } -} - -func TestRoleStoreRoundTripsATier(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - owner := seedPoolUser(t, pool, "owner@example.com") - - if err := store.Grant(t.Context(), owner, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - got, err := store.RoleOf(t.Context(), owner) - if err != nil { - t.Fatalf("RoleOf() error = %v, want nil", err) - } - if got != role.Admin { - t.Errorf("RoleOf() = %v, want %v", got, role.Admin) - } -} - -func TestRoleStoreReportsGrantingAUserItCannotFind(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - ghost := uuid.Must(uuid.NewV7()) - - for _, tier := range []role.Role{role.Admin, role.Member} { - if err := store.Grant(t.Context(), ghost, tier); !errors.Is(err, gouncer.ErrUserNotFound) { - t.Errorf("Grant(%v) error = %v, want %v", tier, err, gouncer.ErrUserNotFound) - } - } - var rows int - if err := pool.QueryRow(t.Context(), - "SELECT count(*) FROM core.user_roles WHERE user_id = $1", ghost).Scan(&rows); err != nil { - t.Fatalf("counting the rows: %v", err) - } - if rows != 0 { - t.Errorf("stored %d rows for a user nobody holds, want none", rows) - } -} - -func TestRoleStoreRefusesToDemoteTheLastAdmin(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - only := seedPoolUser(t, pool, "only@example.com") - if err := store.Grant(t.Context(), only, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - err := store.Grant(t.Context(), only, role.Member) - - if !errors.Is(err, postgres.ErrLastAdmin) { - t.Errorf("Grant(member) error = %v, want %v", err, postgres.ErrLastAdmin) - } - got, _ := store.RoleOf(t.Context(), only) - if got != role.Admin { - t.Errorf("RoleOf() = %v, want the admin left standing", got) - } -} - -func TestRoleStoreCountsOnlyEnabledAdminsAsCover(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - staying := seedPoolUser(t, pool, "staying@example.com") - leaving := seedPoolUser(t, pool, "leaving@example.com") - for _, admin := range []uuid.UUID{staying, leaving} { - if err := store.Grant(t.Context(), admin, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - } - if _, err := pool.Exec(t.Context(), - "UPDATE auth.users SET disabled = true WHERE id = $1", leaving); err != nil { - t.Fatalf("disabling the other admin: %v", err) - } - - err := store.Grant(t.Context(), staying, role.Member) - - if !errors.Is(err, postgres.ErrLastAdmin) { - t.Errorf("Grant(member) error = %v, want %v, a disabled admin is dead cover", err, postgres.ErrLastAdmin) - } -} - -func TestRoleStoreDemotesAnAdminWhileAnotherStands(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - staying := seedPoolUser(t, pool, "staying@example.com") - leaving := seedPoolUser(t, pool, "leaving@example.com") - for _, admin := range []uuid.UUID{staying, leaving} { - if err := store.Grant(t.Context(), admin, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - } - - if err := store.Grant(t.Context(), leaving, role.Member); err != nil { - t.Fatalf("Grant(member) error = %v, want nil while another admin stands", err) - } - - got, _ := store.RoleOf(t.Context(), leaving) - if got != role.Member { - t.Errorf("RoleOf() = %v, want %v", got, role.Member) - } -} - -func TestRoleStoreRefusesTwoAdminsDemotingEachOtherAtOnce(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - first := seedPoolUser(t, pool, "first@example.com") - second := seedPoolUser(t, pool, "second@example.com") - for _, admin := range []uuid.UUID{first, second} { - if err := store.Grant(t.Context(), admin, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - } - - holding, err := pool.Begin(t.Context()) - if err != nil { - t.Fatalf("beginning the holding transaction: %v", err) - } - defer func() { _ = holding.Rollback(t.Context()) }() - if _, err := holding.Exec(t.Context(), - "SELECT 1 FROM core.user_roles WHERE role = 'admin' FOR UPDATE"); err != nil { - t.Fatalf("holding the admins still: %v", err) - } - - demotion := make(chan error, 1) - go func() { demotion <- store.Grant(context.WithoutCancel(t.Context()), second, role.Member) }() - select { - case err := <-demotion: - t.Fatalf("Grant(member) answered %v while the admins were held, want it waiting its turn", err) - case <-time.After(250 * time.Millisecond): - } - - if _, err := holding.Exec(t.Context(), - "UPDATE core.user_roles SET role = 'member' WHERE user_id = $1", first); err != nil { - t.Fatalf("demoting the first admin: %v", err) - } - if err := holding.Commit(t.Context()); err != nil { - t.Fatalf("committing the first demotion: %v", err) - } - - if err := <-demotion; !errors.Is(err, role.ErrLastAdmin) { - t.Errorf("Grant(member) error = %v, want %v, the second demotion reads the first", err, role.ErrLastAdmin) - } - if tier, _ := store.RoleOf(t.Context(), second); tier != role.Admin { - t.Errorf("the second user stands in %v, want %v left standing", tier, role.Admin) - } -} - -func TestRoleStoreRefusesToDisableTheLastAdmin(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - only := seedPoolUser(t, pool, "only@example.com") - if err := store.Grant(t.Context(), only, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - err := store.Disable(t.Context(), only) - - if !errors.Is(err, role.ErrLastAdmin) { - t.Errorf("Disable() error = %v, want %v", err, role.ErrLastAdmin) - } - if disabled := userDisabled(t, pool, only); disabled { - t.Error("the last admin is disabled, want the deployment left with cover") - } -} - -func TestRoleStoreDisablesAnAdminWhileAnotherStands(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - staying := seedPoolUser(t, pool, "staying@example.com") - leaving := seedPoolUser(t, pool, "leaving@example.com") - for _, admin := range []uuid.UUID{staying, leaving} { - if err := store.Grant(t.Context(), admin, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - } - - if err := store.Disable(t.Context(), leaving); err != nil { - t.Fatalf("Disable() error = %v, want nil while another admin stands", err) - } - - if !userDisabled(t, pool, leaving) { - t.Error("the admin is not disabled, want the change stored") - } -} - -func TestRoleStoreSweepsTheSessionsOfTheUserItDisables(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - staying := seedPoolUser(t, pool, "staying@example.com") - leaving := seedPoolUser(t, pool, "leaving@example.com") - if err := store.Grant(t.Context(), staying, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - seedSession(t, pool, leaving) - - if err := store.Disable(t.Context(), leaving); err != nil { - t.Fatalf("Disable() error = %v, want nil", err) - } - - if sessions := sessionCount(t, pool, leaving); sessions != 0 { - t.Errorf("sessions = %d, want none, a barred user is logged out at once", sessions) - } -} - -func TestRoleStoreDisablesAMemberWhateverTheAdminsAre(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - only := seedPoolUser(t, pool, "only@example.com") - staff := seedPoolUser(t, pool, "staff@example.com") - if err := store.Grant(t.Context(), only, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - if err := store.Disable(t.Context(), staff); err != nil { - t.Fatalf("Disable() error = %v, want nil, a member is nobody's cover", err) - } - - if !userDisabled(t, pool, staff) { - t.Error("the member is not disabled, want the change stored") - } -} - -func TestRoleStoreReportsDisablingAUserItCannotFind(t *testing.T) { - t.Parallel() - - store := postgres.NewRoleStore(newTestPool(t)) - - err := store.Disable(t.Context(), uuid.Must(uuid.NewV7())) - - if !errors.Is(err, gouncer.ErrUserNotFound) { - t.Errorf("Disable() error = %v, want %v", err, gouncer.ErrUserNotFound) - } -} - -func TestRoleStoreReadsManyTiersAtOnce(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - boss := seedPoolUser(t, pool, "boss@example.com") - staff := seedPoolUser(t, pool, "staff@example.com") - if err := store.Grant(t.Context(), boss, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil", err) - } - - tiers, err := store.RolesOf(t.Context(), []uuid.UUID{boss, staff}) - - if err != nil { - t.Fatalf("RolesOf() error = %v, want nil", err) - } - if tiers[boss] != role.Admin { - t.Errorf("boss = %v, want %v", tiers[boss], role.Admin) - } - if tiers[staff] != role.Member { - t.Errorf("staff = %v, want %v, a user with no row is a member", tiers[staff], role.Member) - } -} - -func TestRoleStoreReadsNoTiersForNobody(t *testing.T) { - t.Parallel() - - store := postgres.NewRoleStore(newTestPool(t)) - - tiers, err := store.RolesOf(t.Context(), nil) - - if err != nil { - t.Fatalf("RolesOf() error = %v, want nil", err) - } - if len(tiers) != 0 { - t.Errorf("RolesOf(nobody) = %v, want empty", tiers) - } -} - -func TestRoleStoreReportsAConnectionFailure(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewRoleStore(pool) - pool.Close() - - if _, err := store.RoleOf(t.Context(), uuid.Must(uuid.NewV7())); err == nil { - t.Error("RoleOf() on a closed pool error = nil, want error") - } - if _, err := store.RolesOf(t.Context(), []uuid.UUID{uuid.Must(uuid.NewV7())}); err == nil { - t.Error("RolesOf() on a closed pool error = nil, want error") - } - if err := store.Grant(t.Context(), uuid.Must(uuid.NewV7()), role.Admin); err == nil { - t.Error("Grant(admin) on a closed pool error = nil, want error") - } - err := store.Grant(t.Context(), uuid.Must(uuid.NewV7()), role.Member) - if err == nil || errors.Is(err, postgres.ErrLastAdmin) { - t.Errorf("Grant(member) on a closed pool error = %v, want a connection error", err) - } - disabling := store.Disable(t.Context(), uuid.Must(uuid.NewV7())) - if disabling == nil || errors.Is(disabling, postgres.ErrLastAdmin) { - t.Errorf("Disable() on a closed pool error = %v, want a connection error", disabling) - } -} - -func TestTheRoleColumnRefusesATierItDoesNotKnow(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - held := seedPoolUser(t, pool, "held@example.com") - - _, err := pool.Exec(t.Context(), - "INSERT INTO core.user_roles (user_id, role) VALUES ($1, 'root')", held) - - if err == nil { - t.Error("an unknown tier was stored, want the column to refuse it") - } -} - -func TestTheRoleTableRefusesAUserNobodyHolds(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - - _, err := pool.Exec(t.Context(), - "INSERT INTO core.user_roles (user_id, role) VALUES ($1, 'member')", uuid.Must(uuid.NewV7())) - - if err == nil { - t.Error("a tier was stored for a user nobody holds, want the table to refuse it") - } -} diff --git a/internal/postgres/tokens_test.go b/internal/postgres/tokens_test.go index 3052bdbe..69db632f 100644 --- a/internal/postgres/tokens_test.go +++ b/internal/postgres/tokens_test.go @@ -210,8 +210,8 @@ func TestTokenStoreReportsConnectionFailure(t *testing.T) { // scopedTokensVersion is the migration granting api_tokens their scopes and expiry. const scopedTokensVersion = 12 -// grantedRolesVersion is the migration granting every user a role. -const grantedRolesVersion = 13 +// movedRolesVersion is the migration moving every tier onto the account. +const movedRolesVersion = 14 // coreProvider returns a goose provider over the core migrations of db. func coreProvider(t *testing.T, db *sql.DB) *goose.Provider { From 74130676f8e295806810cc7815736713ffcc32a3 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:33:38 +0200 Subject: [PATCH 10/47] refactor(server): read the role the identity carries --- internal/credential/credential.go | 18 --- internal/credential/credential_test.go | 19 ---- internal/server/graphql_auth_test.go | 5 +- internal/server/graphql_test.go | 18 --- internal/server/roles_test.go | 147 +++---------------------- internal/server/server.go | 5 - internal/server/tokens.go | 52 ++------- 7 files changed, 25 insertions(+), 239 deletions(-) diff --git a/internal/credential/credential.go b/internal/credential/credential.go index a1234872..8824fa68 100644 --- a/internal/credential/credential.go +++ b/internal/credential/credential.go @@ -9,7 +9,6 @@ import ( "github.com/google/uuid" "github.com/gopherium/alphone/internal/apitoken" - "github.com/gopherium/alphone/internal/role" ) // tokenPrefix namespaces attribution stamped from an API token. @@ -18,23 +17,6 @@ const tokenPrefix = "token:" // tokenKey is the context key carrying the API token a request presented. type tokenKey struct{} -// roleKey is the context key carrying the tier the caller stands in. -type roleKey struct{} - -// WithRole returns ctx carrying the tier its caller stands in. -func WithRole(ctx context.Context, tier role.Role) context.Context { - return context.WithValue(ctx, roleKey{}, tier) -} - -// RoleOf returns the tier the caller stands in, member when nothing stamped one. -func RoleOf(ctx context.Context) role.Role { - tier, ok := ctx.Value(roleKey{}).(role.Role) - if !ok { - return role.Member - } - return tier -} - // Token is the API token a request authenticated with. type Token struct { ID uuid.UUID diff --git a/internal/credential/credential_test.go b/internal/credential/credential_test.go index e4660e11..55c62ccb 100644 --- a/internal/credential/credential_test.go +++ b/internal/credential/credential_test.go @@ -9,7 +9,6 @@ import ( "github.com/gopherium/alphone/internal/apitoken" "github.com/gopherium/alphone/internal/credential" - "github.com/gopherium/alphone/internal/role" ) func TestOriginNamesTheTokenTheRequestCarries(t *testing.T) { @@ -44,24 +43,6 @@ func TestTokenOfReturnsThePrincipalTheRequestCarries(t *testing.T) { } } -func TestRoleOfReturnsTheTierTheRequestCarries(t *testing.T) { - t.Parallel() - - ctx := credential.WithRole(t.Context(), role.Admin) - - if got := credential.RoleOf(ctx); got != role.Admin { - t.Errorf("RoleOf() = %v, want %v", got, role.Admin) - } -} - -func TestRoleOfDemotesAnUnstampedRequest(t *testing.T) { - t.Parallel() - - if got := credential.RoleOf(t.Context()); got != role.Member { - t.Errorf("RoleOf(bare context) = %v, want %v, an unstamped caller is a member", got, role.Member) - } -} - func TestTokenOfReportsASessionCarriesNoToken(t *testing.T) { t.Parallel() diff --git a/internal/server/graphql_auth_test.go b/internal/server/graphql_auth_test.go index bd045c09..cb4424c0 100644 --- a/internal/server/graphql_auth_test.go +++ b/internal/server/graphql_auth_test.go @@ -10,8 +10,6 @@ import ( "strings" "testing" - "github.com/google/uuid" - "github.com/gopherium/alphone/internal/role" ) @@ -40,11 +38,12 @@ func newAuthGraphServer(t *testing.T, tier role.Role) http.Handler { t.Helper() users := newFakeUserStore() ada := addAda(t, users) + ada.Role = tier.String() + users.Users[ada.ID] = ada return newGraphServer(t, graphConfig{ Contacts: newFakeContactStore(), Tasks: newFakeTaskStore(), Users: users, - Roles: &fakeRoleStore{tiers: map[uuid.UUID]role.Role{ada.ID: tier}}, Version: "9.9.9", }) } diff --git a/internal/server/graphql_test.go b/internal/server/graphql_test.go index 8c57525b..e4e28840 100644 --- a/internal/server/graphql_test.go +++ b/internal/server/graphql_test.go @@ -11,34 +11,22 @@ import ( "testing" "time" - "github.com/google/uuid" - "github.com/gopherium/gouncer/authkit" "github.com/gopherium/gouncer/authkit/ratelimit" "github.com/gopherium/alphone/internal/event" "github.com/gopherium/alphone/internal/graphres" "github.com/gopherium/alphone/internal/graphroot" - "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/server" "github.com/gopherium/alphone/sdk" ) -// serverRoles returns the role store the server reads, nil when the test wired none. -func serverRoles(roles *fakeRoleStore) server.RoleStore { - if roles == nil { - return nil - } - return roles -} - // graphConfig carries the stores and bounds a test graph server composes. type graphConfig struct { Contacts graphres.ContactStore Tasks graphres.TaskStore Users server.UserStore Tokens server.TokenStore - Roles *fakeRoleStore Version string Plugins map[string]http.Handler PluginPublicPaths map[string][]string @@ -68,17 +56,12 @@ func newSubscribingGraphServer(t *testing.T, cfg graphConfig, hub *event.Hub) ht for _, plugin := range plugins { t.Cleanup(func() { _ = plugin.Stop(context.Background()) }) } - resolverRoles := cfg.Roles - if resolverRoles == nil { - resolverRoles = &fakeRoleStore{tiers: map[uuid.UUID]role.Role{}} - } resolver := &graphres.Resolver{ Version: cfg.Version, Contacts: cfg.Contacts, Tasks: cfg.Tasks, Auth: auth, Admin: admin, - Roles: resolverRoles, LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}), } if hub != nil { @@ -93,7 +76,6 @@ func newSubscribingGraphServer(t *testing.T, cfg graphConfig, hub *event.Hub) ht Auth: auth, GraphRoot: root, Tokens: cfg.Tokens, - Roles: serverRoles(cfg.Roles), Plugins: cfg.Plugins, PluginPublicPaths: cfg.PluginPublicPaths, PluginAreas: cfg.PluginAreas, diff --git a/internal/server/roles_test.go b/internal/server/roles_test.go index 221531f3..28847ae7 100644 --- a/internal/server/roles_test.go +++ b/internal/server/roles_test.go @@ -3,74 +3,24 @@ package server_test import ( - "context" "fmt" "net/http" "net/http/httptest" - "strings" "testing" "github.com/google/uuid" + "github.com/gopherium/gouncer/authkit" "github.com/gopherium/gouncer/authkit/testkit" "github.com/gopherium/alphone/internal/apitoken" - "github.com/gopherium/alphone/internal/credential" "github.com/gopherium/alphone/internal/role" ) -// fakeRoleStore answers the tier of the users it was told about. -type fakeRoleStore struct { - tiers map[uuid.UUID]role.Role - err error -} - -// RoleOf returns the stored tier, member for a user it holds nothing for. -func (s *fakeRoleStore) RoleOf(_ context.Context, userID uuid.UUID) (role.Role, error) { - if s.err != nil { - return role.Member, s.err - } - tier, ok := s.tiers[userID] - if !ok { - return role.Member, nil - } - return tier, nil -} - -// RolesOf returns the tier each named user stands in. -func (s *fakeRoleStore) RolesOf(_ context.Context, userIDs []uuid.UUID) (map[uuid.UUID]role.Role, error) { - if s.err != nil { - return nil, s.err - } - tiers := make(map[uuid.UUID]role.Role, len(userIDs)) - for _, id := range userIDs { - tier, ok := s.tiers[id] - if !ok { - tier = role.Member - } - tiers[id] = tier - } - return tiers, nil -} - -// Grant stores the tier a user stands in. -func (s *fakeRoleStore) Grant(_ context.Context, userID uuid.UUID, tier role.Role) error { - if s.err != nil { - return s.err - } - s.tiers[userID] = tier - return nil -} - -// Disable bars a user, reporting whatever the store was told to report. -func (s *fakeRoleStore) Disable(context.Context, uuid.UUID) error { - return s.err -} - // roleHandler answers the tier the request context carries. func roleHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = fmt.Fprint(w, credential.RoleOf(r.Context())) + _, _ = fmt.Fprint(w, authkit.IdentityFromContext(r.Context()).Role) }) } @@ -79,27 +29,26 @@ type roleServer struct { handler http.Handler users *testkit.Store tokens *fakeTokenStore - roles *fakeRoleStore ada uuid.UUID } // newRoleServer returns a server whose probe route answers the caller's tier. -func newRoleServer(t *testing.T, storeErr error) roleServer { +func newRoleServer(t *testing.T, tier role.Role) roleServer { t.Helper() users := newFakeUserStore() ada := addAda(t, users) + ada.Role = tier.String() + users.Users[ada.ID] = ada tokens := newFakeTokenStore() - roles := &fakeRoleStore{tiers: map[uuid.UUID]role.Role{}, err: storeErr} handler := newGraphServer(t, graphConfig{ Contacts: newFakeContactStore(), Tasks: newFakeTaskStore(), Users: users, Tokens: tokens, - Roles: roles, Version: "9.9.9", Plugins: map[string]http.Handler{"probe": roleHandler()}, }) - return roleServer{handler: handler, users: users, tokens: tokens, roles: roles, ada: ada.ID} + return roleServer{handler: handler, users: users, tokens: tokens, ada: ada.ID} } // mintFor stores a full scope token for the seeded user and returns its secret. @@ -125,8 +74,7 @@ func getWithCookie(handler http.Handler, path string, cookie *http.Cookie) *http func TestBearerTokenCarriesItsOwnersRole(t *testing.T) { t.Parallel() - server := newRoleServer(t, nil) - server.roles.tiers[server.ada] = role.Admin + server := newRoleServer(t, role.Admin) secret := server.mintFor(t) recorder := getWithBearer(server.handler, "/api/plugins/probe/role", secret) @@ -136,97 +84,28 @@ func TestBearerTokenCarriesItsOwnersRole(t *testing.T) { } } -func TestASessionCarriesMemberWithNoRoleStoreWired(t *testing.T) { - t.Parallel() - - users := newFakeUserStore() - addAda(t, users) - handler := newGraphServer(t, graphConfig{ - Contacts: newFakeContactStore(), - Tasks: newFakeTaskStore(), - Users: users, - Version: "9.9.9", - Plugins: map[string]http.Handler{"probe": roleHandler()}, - }) - cookie := loginCookie(t, handler) - - recorder := getWithCookie(handler, "/api/plugins/probe/role", cookie) - - if got := recorder.Body.String(); got != role.Member.String() { - t.Errorf("role = %q, want %q, an unstamped caller is a member", got, role.Member.String()) - } -} - -func TestBearerTokenOfAUserWithNoRowCarriesMember(t *testing.T) { +func TestBearerTokenOfAnAccountHoldingNoRoleCarriesNone(t *testing.T) { t.Parallel() - server := newRoleServer(t, nil) + server := newRoleServer(t, "") secret := server.mintFor(t) recorder := getWithBearer(server.handler, "/api/plugins/probe/role", secret) - if got := recorder.Body.String(); got != role.Member.String() { - t.Errorf("role = %q, want %q, a user with no row is a member", got, role.Member.String()) + if got := recorder.Body.String(); got != "" { + t.Errorf("role = %q, want it empty, an account holding none carries none", got) } } func TestASessionCarriesItsOwnersRole(t *testing.T) { t.Parallel() - server := newRoleServer(t, nil) - server.roles.tiers[server.ada] = role.Admin + server := newRoleServer(t, role.Admin) cookie := loginCookie(t, server.handler) recorder := getWithCookie(server.handler, "/api/plugins/probe/role", cookie) if got := recorder.Body.String(); got != role.Admin.String() { - t.Errorf("role = %q, want %q, a session carries its role like a token does", got, role.Admin.String()) - } -} - -func TestAFailingRoleStoreRefusesASessionToo(t *testing.T) { - t.Parallel() - - server := newRoleServer(t, nil) - cookie := loginCookie(t, server.handler) - server.roles.err = errTokenBackend - - recorder := getWithCookie(server.handler, "/api/plugins/probe/role", cookie) - - if recorder.Code != http.StatusInternalServerError { - t.Errorf("status = %d, want %d on the session path", recorder.Code, http.StatusInternalServerError) - } -} - -func TestAFailingRoleStoreLeavesTheGraphCallerAnonymous(t *testing.T) { - t.Parallel() - - server := newRoleServer(t, nil) - cookie := loginCookie(t, server.handler) - server.roles.err = errTokenBackend - - request := httptest.NewRequest(http.MethodPost, "/api/graphql", - strings.NewReader(`{"query":"{ version }"}`)) - request.Header.Set("Content-Type", "application/json") - request.AddCookie(cookie) - recorder := httptest.NewRecorder() - server.handler.ServeHTTP(recorder, request) - - if !strings.Contains(recorder.Body.String(), "UNAUTHENTICATED") { - t.Errorf("body = %s, want the caller left anonymous rather than trusted", recorder.Body.String()) - } -} - -func TestAFailingRoleStoreRefusesRatherThanDemotes(t *testing.T) { - t.Parallel() - - server := newRoleServer(t, errTokenBackend) - secret := server.mintFor(t) - - recorder := getWithBearer(server.handler, "/api/plugins/probe/role", secret) - - if recorder.Code != http.StatusInternalServerError { - t.Errorf("status = %d, want %d, an unreadable role refuses rather than demotes", - recorder.Code, http.StatusInternalServerError) + t.Errorf("role = %q, want %q", got, role.Admin.String()) } } diff --git a/internal/server/server.go b/internal/server/server.go index 3f7a2f44..55d55da8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -37,9 +37,6 @@ type Config struct { // Tokens resolves API tokens presented as bearer credentials. Nil // leaves the session cookie as the only accepted credential. Tokens TokenStore - // Roles reads the tier every caller stands in. Nil leaves every - // caller a member. - Roles RoleStore // Plugins maps a plugin id to its HTTP handler, mounted under // /api/plugins/{id}/ behind the session middleware. Plugins map[string]http.Handler @@ -82,7 +79,6 @@ func NewServer(cfg Config) http.Handler { auth: auth, users: cfg.Users, tokens: cfg.Tokens, - roles: cfg.Roles, maxStreamLifetime: maxStreamLifetime, streams: newStreamLimiter(maxStreamsPerUser), } @@ -114,7 +110,6 @@ type server struct { auth *authkit.Handlers users UserStore tokens TokenStore - roles RoleStore maxStreamLifetime time.Duration streams *streamLimiter } diff --git a/internal/server/tokens.go b/internal/server/tokens.go index 6db123f4..d641d0b4 100644 --- a/internal/server/tokens.go +++ b/internal/server/tokens.go @@ -5,7 +5,6 @@ package server import ( "context" "errors" - "fmt" "net/http" "strings" "time" @@ -17,7 +16,6 @@ import ( "github.com/gopherium/alphone/internal/apitoken" "github.com/gopherium/alphone/internal/credential" - "github.com/gopherium/alphone/internal/role" ) // bearerScheme prefixes the credential in an Authorization header. @@ -35,27 +33,10 @@ type UserStore interface { UserByID(ctx context.Context, id uuid.UUID) (gouncer.User, error) } -// RoleStore reads the tier a user stands in. -type RoleStore interface { - RoleOf(ctx context.Context, userID uuid.UUID) (role.Role, error) -} - -// withRole returns ctx carrying the tier its identity stands in. -func (s *server) withRole(ctx context.Context, userID uuid.UUID) (context.Context, error) { - if s.roles == nil { - return ctx, nil - } - tier, err := s.roles.RoleOf(ctx, userID) - if err != nil { - return ctx, fmt.Errorf("server: read user role: %w", err) - } - return credential.WithRole(ctx, tier), nil -} - // requireIdentity admits requests carrying either a usable API token or a // login session, passing the authenticated identity down the chain. func (s *server) requireIdentity(next http.Handler) http.Handler { - session := s.auth.RequireSession(s.roleStamped(next)) + session := s.auth.RequireSession(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { secret, ok := s.bearerSecret(r) if !ok { @@ -66,19 +47,6 @@ func (s *server) requireIdentity(next http.Handler) http.Handler { }) } -// roleStamped serves the request carrying the tier its session identity stands in. -func (s *server) roleStamped(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - identity := authkit.IdentityFromContext(r.Context()) - ctx, err := s.withRole(r.Context(), identity.ID) - if err != nil { - authkit.RespondError(w, http.StatusInternalServerError, "internal error") - return - } - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - // identifyIdentity resolves any presented credential without requiring one, // leaving the request anonymous when none is usable. func (s *server) identifyIdentity(next http.Handler) http.Handler { @@ -122,11 +90,7 @@ func (s *server) sessionContext(r *http.Request) context.Context { if err != nil { return r.Context() } - stamped, err := s.withRole(authkit.WithIdentity(r.Context(), identity), identity.ID) - if err != nil { - return r.Context() - } - return stamped + return authkit.WithIdentity(r.Context(), identity) } // bearerSecret returns the credential of an Authorization Bearer header. @@ -159,11 +123,15 @@ func (s *server) identityForToken(ctx context.Context, secret string) (context.C return ctx, apitoken.ErrNotFound } _ = s.tokens.TouchLastUsed(ctx, token.ID, time.Now().UTC()) - stamped := authkit.WithIdentity(ctx, authkit.Identity{ID: user.ID, Email: user.Email, Name: user.Name}) - stamped = credential.WithToken(stamped, credential.Token{ + stamped := authkit.WithIdentity(ctx, authkit.Identity{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + }) + return credential.WithToken(stamped, credential.Token{ ID: token.ID, Name: token.Name, Scopes: token.Scopes, - }) - return s.withRole(stamped, user.ID) + }), nil } From 2c9f4bb1781f74dc53ab5978708e031f998f8f9b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:34:08 +0200 Subject: [PATCH 11/47] feat(graphres): guard every role write behind the caller's reach --- internal/graphres/auth.go | 36 +-- internal/graphres/auth_test.go | 4 +- internal/graphres/errors.go | 4 + internal/graphres/graphres.go | 46 +-- internal/graphres/roles_test.go | 436 ++++++++++++++-------------- internal/graphres/scope.go | 5 +- internal/graphres/scopegate_test.go | 11 +- 7 files changed, 262 insertions(+), 280 deletions(-) diff --git a/internal/graphres/auth.go b/internal/graphres/auth.go index 5c3c4e5e..63dec14b 100644 --- a/internal/graphres/auth.go +++ b/internal/graphres/auth.go @@ -14,7 +14,6 @@ import ( "github.com/gopherium/gouncer/authkit" "github.com/gopherium/alphone/graph/model" - "github.com/gopherium/alphone/internal/credential" "github.com/gopherium/alphone/internal/role" ) @@ -47,7 +46,8 @@ 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) { - return toAuthIdentity(authkit.IdentityFromContext(ctx), credential.RoleOf(ctx)), nil + identity := authkit.IdentityFromContext(ctx) + return toAuthIdentity(identity, role.Role(identity.Role)), nil } // Users lists every user account. @@ -56,17 +56,9 @@ func (q QueryResolvers) Users(ctx context.Context) ([]*model.User, error) { if err != nil { return nil, err } - ids := make([]uuid.UUID, len(accounts)) - for i, account := range accounts { - ids[i] = account.ID - } - tiers, err := q.root.rolesOf(ctx, ids) - if err != nil { - return nil, err - } users := make([]*model.User, len(accounts)) for i, account := range accounts { - users[i] = toUser(account, tiers[account.ID]) + users[i] = toUser(account, role.Role(account.Role)) } return users, nil } @@ -115,11 +107,7 @@ func (m MutationResolvers) Login(ctx context.Context, email, password string) (* if err := setResponseCookie(ctx, cookie); err != nil { return nil, err } - tier, err := m.root.roleOf(ctx, identity.ID) - if err != nil { - return nil, err - } - return &model.LoginPayload{Me: toAuthIdentity(identity, tier)}, nil + return &model.LoginPayload{Me: toAuthIdentity(identity, role.Role(identity.Role))}, nil } // Logout ends the calling session and clears its cookie. @@ -155,11 +143,8 @@ func (m MutationResolvers) SetUserDisabled(ctx context.Context, id uuid.UUID, di if disabled && actor.ID == id { return false, authkit.ErrSelfDisable } - if disabled { - if err := m.root.Roles.Disable(ctx, id); err != nil { - return false, err - } - return true, nil + 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 @@ -173,7 +158,14 @@ func (m MutationResolvers) SetUserRole(ctx context.Context, id uuid.UUID, tier s if err != nil { return false, err } - if err := m.root.Roles.Grant(ctx, id, stood); err != nil { + 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 { + return false, err + } + if err := m.root.Admin.SetAccountRole(ctx, actor.ID, id, stood.String()); err != nil { return false, err } return true, nil diff --git a/internal/graphres/auth_test.go b/internal/graphres/auth_test.go index 24207172..f2d4b529 100644 --- a/internal/graphres/auth_test.go +++ b/internal/graphres/auth_test.go @@ -17,19 +17,17 @@ import ( "github.com/gopherium/gouncer/authkit/testkit" "github.com/gopherium/alphone/internal/graphres" - "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/sdk" ) const testPassword = "password1234" -// newAuthResolver returns a resolver whose auth seams serve store, every user standing as a member. +// newAuthResolver returns a resolver whose auth seams serve store. func newAuthResolver(store *testkit.Store) *graphres.Resolver { return &graphres.Resolver{ Version: "9.9.9", Auth: authkit.New(authkit.Config{Store: store, CookieName: "alphone_session"}), Admin: authkit.NewAdmin(authkit.AdminConfig{Store: store}), - Roles: standingRoleStore{tier: role.Member}, LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{Limit: 2, Window: time.Minute}), } } diff --git a/internal/graphres/errors.go b/internal/graphres/errors.go index 233987f8..edfcdf24 100644 --- a/internal/graphres/errors.go +++ b/internal/graphres/errors.go @@ -9,6 +9,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/vektah/gqlparser/v2/gqlerror" + "github.com/gopherium/gouncer" "github.com/gopherium/gouncer/authkit" "github.com/gopherium/alphone/graph/scalar" @@ -40,7 +41,10 @@ var validationErrors = []error{ errExactlyOneTaskFilter, errInvalidFirst, authkit.ErrSelfDisable, + authkit.ErrSelfRole, + gouncer.ErrLastPrivileged, role.ErrLastAdmin, + role.ErrBeyondReach, role.ErrUnknownTier, apitoken.ErrEmptyName, apitoken.ErrMalformedScope, diff --git a/internal/graphres/graphres.go b/internal/graphres/graphres.go index d3f562ef..b2843169 100644 --- a/internal/graphres/graphres.go +++ b/internal/graphres/graphres.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" + "github.com/gopherium/gouncer" "github.com/gopherium/gouncer/authkit" "github.com/gopherium/alphone/graph/model" @@ -64,39 +65,22 @@ type WebhookStore interface { DeleteSubscription(ctx context.Context, userID, id uuid.UUID) error } -// RoleStore reads and writes the tier users stand in. -type RoleStore interface { - RoleOf(ctx context.Context, userID uuid.UUID) (role.Role, error) - RolesOf(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]role.Role, error) - Grant(ctx context.Context, userID uuid.UUID, tier role.Role) error - Disable(ctx context.Context, userID uuid.UUID) error -} - -// roleOf returns the tier one user stands in, member when no store was wired. -func (r *Resolver) roleOf(ctx context.Context, userID uuid.UUID) (role.Role, error) { - if r.Roles == nil { - return role.Member, nil - } - return r.Roles.RoleOf(ctx, userID) -} - -// rolesOf returns the tier each named user stands in, member for any the store leaves out. -func (r *Resolver) rolesOf(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID]role.Role, error) { - tiers := make(map[uuid.UUID]role.Role, len(userIDs)) - for _, id := range userIDs { - tiers[id] = role.Member - } - if r.Roles == nil { - return tiers, nil - } - stored, err := r.Roles.RolesOf(ctx, userIDs) +// outranking refuses an actor writing an account whose role holds a capability the actor lacks. +func (r *Resolver) outranking(ctx context.Context, actor authkit.Identity, target uuid.UUID) error { + held, err := r.Admin.ListAccounts(ctx) if err != nil { - return nil, err + return err } - for id, tier := range stored { - tiers[id] = tier + for _, account := range held { + if account.ID != target { + continue + } + if !role.Outranks(role.Role(actor.Role), role.Role(account.Role)) { + return role.ErrBeyondReach + } + return nil } - return tiers, nil + return gouncer.ErrUserNotFound } // TokenStore serves the caller's own API tokens. @@ -137,8 +121,6 @@ type Resolver struct { Tenants TenantStore // Tokens serves the caller's own API tokens. Tokens TokenStore - // Roles serves the tier users stand in. Nil leaves every user a member. - Roles RoleStore // Events announces domain events. Nil publishes nothing. Events Publisher // Live hands subscriptions the frames they may see. Nil serves no subscription. diff --git a/internal/graphres/roles_test.go b/internal/graphres/roles_test.go index 5c3690c6..61d4de4c 100644 --- a/internal/graphres/roles_test.go +++ b/internal/graphres/roles_test.go @@ -8,139 +8,73 @@ import ( "fmt" "testing" + gqlclient "github.com/99designs/gqlgen/client" "github.com/google/uuid" "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit" "github.com/gopherium/gouncer/authkit/testkit" "github.com/gopherium/alphone/internal/graphres" "github.com/gopherium/alphone/internal/role" ) -// errRoleBackend reports a role store that cannot answer. -var errRoleBackend = errors.New("role backend unavailable") +// stewardRole is the plugin declared role standing above admin in these tests. +const stewardRole role.Role = "steward" -// failingRoleStore refuses every read. -type failingRoleStore struct{} +// errListing reports a store that cannot list the accounts. +var errListing = errors.New("accounts unavailable") -// RoleOf refuses to answer one tier. -func (failingRoleStore) RoleOf(context.Context, uuid.UUID) (role.Role, error) { - return role.Member, errRoleBackend -} - -// RolesOf refuses to answer many tiers. -func (failingRoleStore) RolesOf(context.Context, []uuid.UUID) (map[uuid.UUID]role.Role, error) { - return nil, errRoleBackend -} - -// Grant refuses to store a tier. -func (failingRoleStore) Grant(context.Context, uuid.UUID, role.Role) error { - return errRoleBackend -} - -// Disable refuses to bar a user. -func (failingRoleStore) Disable(context.Context, uuid.UUID) error { - return errRoleBackend -} - -// standingRoleStore answers one fixed tier for everybody. -type standingRoleStore struct { - tier role.Role -} +// errWriting reports a store that cannot store a role. +var errWriting = errors.New("role unwritable") -// RoleOf answers the fixed tier. -func (s standingRoleStore) RoleOf(context.Context, uuid.UUID) (role.Role, error) { - return s.tier, nil -} - -// RolesOf answers the fixed tier for each named user. -func (s standingRoleStore) RolesOf(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]role.Role, error) { - tiers := make(map[uuid.UUID]role.Role, len(ids)) - for _, id := range ids { - tiers[id] = s.tier +// 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, "manage_reports"); err != nil { + panic(err) } - return tiers, nil } -// Grant stores nothing and reports success. -func (standingRoleStore) Grant(context.Context, uuid.UUID, role.Role) error { - return nil -} - -// Disable bars nobody and reports success. -func (standingRoleStore) Disable(context.Context, uuid.UUID) error { - return nil -} - -// grantingRoleStore remembers the last tier it was asked to store. -type grantingRoleStore struct { - standingRoleStore - granted role.Role - to uuid.UUID -} - -// Grant remembers the tier asked for. -func (s *grantingRoleStore) Grant(_ context.Context, userID uuid.UUID, tier role.Role) error { - s.granted, s.to = tier, userID - return nil -} - -// unseatingRoleStore refuses to unseat the last admin. -type unseatingRoleStore struct { - standingRoleStore -} - -// Grant refuses every write as the last admin refusal. -func (unseatingRoleStore) Grant(context.Context, uuid.UUID, role.Role) error { - return role.ErrLastAdmin -} - -// Disable refuses to bar the last admin. -func (unseatingRoleStore) Disable(context.Context, uuid.UUID) error { - return role.ErrLastAdmin -} - -// missingRoleStore knows no user at all. -type missingRoleStore struct { - standingRoleStore +// roledStore returns a store holding one account under the given tier. +func roledStore(t *testing.T, tier role.Role) (*testkit.Store, gouncer.User) { + t.Helper() + store := testkit.NewStore() + held := store.AddUser(t, "grace@example.com", "Grace Hopper", testPassword) + held.Role = tier.String() + store.Users[held.ID] = held + return store, held } -// Disable reports that no such user exists. -func (missingRoleStore) Disable(context.Context, uuid.UUID) error { - return gouncer.ErrUserNotFound +// newRoledResolver returns a resolver over a store holding one account under the tier. +func newRoledResolver(t *testing.T, tier role.Role) (*graphres.Resolver, gouncer.User) { + t.Helper() + store, held := roledStore(t, tier) + return newAuthResolver(store), held } -// newRoledResolver returns an auth resolver holding one account whose tiers come from roles. -func newRoledResolver(t *testing.T, roles graphres.RoleStore) *graphres.Resolver { +// newActingClient returns a graph client acting as the given identity. +func newActingClient(t *testing.T, resolver *graphres.Resolver, actor authkit.Identity) *gqlclient.Client { t.Helper() - store := testkit.NewStore() - store.AddUser(t, "grace@example.com", "Grace Hopper", testPassword) - resolver := newAuthResolver(store) - resolver.Roles = roles - return resolver + return newDecoratedGraphClient(t, resolver, func(ctx context.Context) context.Context { + return authkit.WithIdentity(ctx, actor) + }) } -func TestUsersReportsAFailingRoleStore(t *testing.T) { - t.Parallel() - - resolver := newRoledResolver(t, failingRoleStore{}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) - - answered, err := client.RawPost(`{ users { id role } }`) +// settingRole returns the mutation standing a user in a tier. +func settingRole(userID uuid.UUID, tier string) string { + return fmt.Sprintf(`mutation { setUserRole(id: %q, role: %q) }`, userID, tier) +} - if err != nil { - t.Fatalf("RawPost() error = %v, want nil", err) - } - if len(answered.Errors) == 0 { - t.Error("users answered no error while the role store refuses, want one") - } +// settingDisabled returns the mutation barring or admitting a user. +func settingDisabled(userID uuid.UUID, disabled bool) string { + return fmt.Sprintf(`mutation { setUserDisabled(id: %q, disabled: %t) }`, userID, disabled) } func TestUsersCarriesEachAccountsTier(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, standingRoleStore{tier: role.Admin}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + resolver, held := newRoledResolver(t, role.Admin) + client := newGraphClient(t, resolver, held.ID) var listed struct { Users []struct { @@ -157,11 +91,11 @@ func TestUsersCarriesEachAccountsTier(t *testing.T) { } } -func TestUsersLeavesEverybodyAMemberWithNoStoreWired(t *testing.T) { +func TestUsersReportsAnAccountHoldingNoRoleAsHoldingNone(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, nil) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + resolver, held := newRoledResolver(t, "") + client := newGraphClient(t, resolver, held.ID) var listed struct { Users []struct { @@ -173,217 +107,279 @@ func TestUsersLeavesEverybodyAMemberWithNoStoreWired(t *testing.T) { if len(listed.Users) == 0 { t.Fatal("users answered nothing, want the seeded account") } - if listed.Users[0].Role != role.Member.String() { - t.Errorf("role = %q, want %q with no store wired", listed.Users[0].Role, role.Member.String()) + if listed.Users[0].Role != "" { + t.Errorf("role = %q, want it empty rather than a role the account does not hold", + listed.Users[0].Role) } } -func TestLoginReportsAFailingRoleStore(t *testing.T) { +func TestMeAnswersTheCallersTier(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, failingRoleStore{}) - client := newHTTPGraphClient(t, resolver, uuid.Nil) - - answered, err := client.RawPost( - `mutation { login(email: "grace@example.com", password: "` + testPassword + `") { me { role } } }`) + resolver, held := newRoledResolver(t, role.Admin) + client := newActingClient(t, resolver, authkit.Identity{ID: held.ID, Role: held.Role}) - if err != nil { - t.Fatalf("RawPost() error = %v, want nil", err) + var answered struct { + Me struct { + Role string `json:"role"` + } `json:"me"` } - if len(answered.Errors) == 0 { - t.Error("login answered no error while the role store refuses, want one") + client.MustPost(`{ me { role } }`, &answered) + + if answered.Me.Role != role.Admin.String() { + t.Errorf("role = %q, want %q", answered.Me.Role, role.Admin.String()) } } -func TestLoginLeavesTheCallerAMemberWithNoStoreWired(t *testing.T) { +func TestSetUserRoleStandsAUserInTheTierItNames(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, nil) - client := newHTTPGraphClient(t, resolver, uuid.Nil) + store, held := roledStore(t, role.Member) + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) var answered struct { - Login struct { - Me struct { - Role string `json:"role"` - } `json:"me"` - } `json:"login"` + SetUserRole bool `json:"setUserRole"` } - client.MustPost( - `mutation { login(email: "grace@example.com", password: "`+testPassword+`") { me { role } } }`, &answered) + client.MustPost(settingRole(held.ID, role.Admin.String()), &answered) - if answered.Login.Me.Role != role.Member.String() { - t.Errorf("role = %q, want %q with no store wired", answered.Login.Me.Role, role.Member.String()) + if !answered.SetUserRole { + t.Error("setUserRole answered false, want the change reported") + } + if got := store.Users[held.ID].Role; got != role.Admin.String() { + t.Errorf("stored role = %q, want %q", got, role.Admin.String()) } } -func TestLoginAnswersTheCallersTier(t *testing.T) { +func TestSetUserRoleRefusesATierNoDeploymentKnows(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, standingRoleStore{tier: role.Admin}) - client := newHTTPGraphClient(t, resolver, uuid.Nil) + store, held := roledStore(t, role.Member) + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) - var answered struct { - Login struct { - Me struct { - Role string `json:"role"` - } `json:"me"` - } `json:"login"` + answered, err := client.RawPost(settingRole(held.ID, "root")) + + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) } - client.MustPost( - `mutation { login(email: "grace@example.com", password: "`+testPassword+`") { me { role } } }`, &answered) + if got := firstErrorCode(t, answered.Errors); got != "VALIDATION" { + t.Errorf("code = %q, want VALIDATION", got) + } + if got := store.Users[held.ID].Role; got != role.Member.String() { + t.Errorf("stored role = %q, want the refused write to store nothing", got) + } +} + +func TestSetUserRoleRefusesGrantingBeyondTheCallersReach(t *testing.T) { + t.Parallel() + + store, held := roledStore(t, role.Member) + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) + + answered, err := client.RawPost(settingRole(held.ID, "steward")) - if answered.Login.Me.Role != role.Admin.String() { - t.Errorf("role = %q, want %q", answered.Login.Me.Role, role.Admin.String()) + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) + } + if len(answered.Errors) == 0 { + t.Error("setUserRole answered no error, want an admin refused a role it does not hold") + } + if got := store.Users[held.ID].Role; got != role.Member.String() { + t.Errorf("stored role = %q, want the refused write to store nothing", got) } } -// settingRole returns the operation standing one user in a tier. -func settingRole(userID uuid.UUID, tier string) string { - return fmt.Sprintf(`mutation { setUserRole(id: %q, role: %q) }`, userID, tier) +func TestSetUserRoleRefusesTouchingAnAccountBeyondTheCallersReach(t *testing.T) { + t.Parallel() + + store, held := roledStore(t, "steward") + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) + + answered, err := client.RawPost(settingRole(held.ID, role.Member.String())) + + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) + } + if len(answered.Errors) == 0 { + t.Error("setUserRole answered no error, want an admin refused an account holding more") + } + if got := store.Users[held.ID].Role; got != "steward" { + t.Errorf("stored role = %q, want the refused write to leave it", got) + } } -func TestSetUserRoleStandsAUserInTheTierItNames(t *testing.T) { +func TestSetUserDisabledRefusesBarringItself(t *testing.T) { t.Parallel() - roles := &grantingRoleStore{} - resolver := newRoledResolver(t, roles) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) - promoted := uuid.Must(uuid.NewV7()) + store, held := roledStore(t, role.Admin) + resolver := newAuthResolver(store) + client := newActingClient(t, resolver, authkit.Identity{ID: held.ID, Role: held.Role}) + + answered, err := client.RawPost(settingDisabled(held.ID, true)) + + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) + } + if len(answered.Errors) == 0 { + t.Error("setUserDisabled answered no error, want an account refused barring itself") + } +} + +func TestSetUserDisabledBarsThroughTheAccountSeam(t *testing.T) { + t.Parallel() + + store, held := roledStore(t, role.Member) + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) var answered struct { - SetUserRole bool `json:"setUserRole"` + SetUserDisabled bool `json:"setUserDisabled"` } - client.MustPost(settingRole(promoted, role.Admin.String()), &answered) + client.MustPost(settingDisabled(held.ID, true), &answered) - if !answered.SetUserRole { - t.Error("setUserRole answered false, want the change reported") + if !answered.SetUserDisabled { + t.Error("setUserDisabled answered false, want the change reported") } - if roles.granted != role.Admin || roles.to != promoted { - t.Errorf("granted %v to %v, want %v to %v", roles.granted, roles.to, role.Admin, promoted) + if !store.Users[held.ID].Disabled { + t.Error("the account is enabled, want the guarded write to bar it") } } -func TestSetUserRoleRefusesATierNoDeploymentKnows(t *testing.T) { +func TestSetUserDisabledRefusesAnAccountBeyondTheCallersReach(t *testing.T) { t.Parallel() - roles := &grantingRoleStore{} - resolver := newRoledResolver(t, roles) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + store, held := roledStore(t, "steward") + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) - answered, err := client.RawPost(settingRole(uuid.Must(uuid.NewV7()), "root")) + answered, err := client.RawPost(settingDisabled(held.ID, true)) if err != nil { t.Fatalf("RawPost() error = %v, want nil", err) } - if got := firstErrorCode(t, answered.Errors); got != "VALIDATION" { - t.Errorf("code = %q, want VALIDATION", got) + if len(answered.Errors) == 0 { + t.Error("setUserDisabled answered no error, want an admin refused an account holding more") } - if roles.granted != "" { - t.Errorf("granted %v, want nothing stored for a tier nobody knows", roles.granted) + if store.Users[held.ID].Disabled { + t.Error("the account is barred, want the refused write to leave it") } } -func TestSetUserRoleRefusesToUnseatTheLastAdmin(t *testing.T) { +func TestSetUserRoleReportsAStoreThatCannotList(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, unseatingRoleStore{}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + store, held := roledStore(t, role.Member) + store.ListUsersErr = errListing + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) - answered, err := client.RawPost(settingRole(uuid.Must(uuid.NewV7()), role.Member.String())) + answered, err := client.RawPost(settingRole(held.ID, role.Admin.String())) if err != nil { t.Fatalf("RawPost() error = %v, want nil", err) } - if got := firstErrorCode(t, answered.Errors); got != "VALIDATION" { - t.Errorf("code = %q, want VALIDATION", got) + if len(answered.Errors) == 0 { + t.Error("setUserRole answered no error while the store refuses to list, want one") } } -func TestSetUserDisabledRefusesToBarTheLastAdmin(t *testing.T) { +func TestSetUserRoleReportsAStoreThatCannotWrite(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, unseatingRoleStore{}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + store, held := roledStore(t, role.Member) + store.SetRoleErr = errWriting + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) - answered, err := client.RawPost( - fmt.Sprintf(`mutation { setUserDisabled(id: %q, disabled: true) }`, uuid.Must(uuid.NewV7()))) + answered, err := client.RawPost(settingRole(held.ID, role.Admin.String())) if err != nil { t.Fatalf("RawPost() error = %v, want nil", err) } - if got := firstErrorCode(t, answered.Errors); got != "VALIDATION" { - t.Errorf("code = %q, want VALIDATION", got) + if len(answered.Errors) == 0 { + t.Error("setUserRole answered no error while the store refuses the write, want one") } } -func TestSetUserDisabledEnablesThroughTheAccountSeam(t *testing.T) { +func TestSetUserDisabledReportsAStoreThatCannotList(t *testing.T) { t.Parallel() - store := testkit.NewStore() - account := store.AddUser(t, "barred@example.com", "Maria Perez", testPassword) - if err := store.SetUserDisabled(t.Context(), account.ID, true); err != nil { - t.Fatalf("barring the account ahead of the test: %v", err) - } + store, held := roledStore(t, role.Member) + store.ListUsersErr = errListing resolver := newAuthResolver(store) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) - var answered struct { - SetUserDisabled bool `json:"setUserDisabled"` - } - client.MustPost( - fmt.Sprintf(`mutation { setUserDisabled(id: %q, disabled: false) }`, account.ID), &answered) + answered, err := client.RawPost(settingDisabled(held.ID, true)) - if !answered.SetUserDisabled { - t.Error("setUserDisabled answered false, want enabling reported, it only adds cover") - } - restored, err := store.UserByID(t.Context(), account.ID) if err != nil { - t.Fatalf("UserByID() error = %v, want the enabled account", err) + t.Fatalf("RawPost() error = %v, want nil", err) } - if restored.Disabled { - t.Error("the account is still barred, want enabling to have reached the seam") + if len(answered.Errors) == 0 { + t.Error("setUserDisabled answered no error while the store refuses to list, want one") } } -func TestSetUserDisabledReportsAUserTheStoreCannotFind(t *testing.T) { +func TestSetUserDisabledAdmitsAnAccountItBarred(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, missingRoleStore{}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + store, held := roledStore(t, role.Member) + held.Disabled = true + store.Users[held.ID] = held + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) - answered, err := client.RawPost( - fmt.Sprintf(`mutation { setUserDisabled(id: %q, disabled: true) }`, uuid.Must(uuid.NewV7()))) + var answered struct { + SetUserDisabled bool `json:"setUserDisabled"` + } + client.MustPost(settingDisabled(held.ID, false), &answered) - if err != nil { - t.Fatalf("RawPost() error = %v, want nil", err) + if !answered.SetUserDisabled { + t.Error("setUserDisabled answered false, want the change reported") } - if got := firstErrorCode(t, answered.Errors); got != "NOT_FOUND" { - t.Errorf("code = %q, want NOT_FOUND", got) + if store.Users[held.ID].Disabled { + t.Error("the account is still barred, want it admitted") } } -func TestSetUserDisabledLeavesEnablingUnguarded(t *testing.T) { +func TestSetUserDisabledReportsAStoreThatCannotWrite(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, unseatingRoleStore{}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + store, held := roledStore(t, role.Member) + store.SetDisabledErr = errWriting + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) - answered, err := client.RawPost( - fmt.Sprintf(`mutation { setUserDisabled(id: %q, disabled: false) }`, uuid.Must(uuid.NewV7()))) + answered, err := client.RawPost(settingDisabled(held.ID, true)) if err != nil { t.Fatalf("RawPost() error = %v, want nil", err) } - if got := firstErrorCode(t, answered.Errors); got == "VALIDATION" { - t.Errorf("code = %q, want the guard skipped, enabling a user only adds cover", got) + if len(answered.Errors) == 0 { + t.Error("setUserDisabled answered no error while the store refuses the write, want one") } } -func TestSetUserRoleReportsAFailingRoleStore(t *testing.T) { +func TestSetUserRoleReportsAnAccountNobodyHolds(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, failingRoleStore{}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + store, _ := roledStore(t, role.Member) + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) answered, err := client.RawPost(settingRole(uuid.Must(uuid.NewV7()), role.Admin.String())) @@ -391,6 +387,6 @@ func TestSetUserRoleReportsAFailingRoleStore(t *testing.T) { t.Fatalf("RawPost() error = %v, want nil", err) } if len(answered.Errors) == 0 { - t.Error("setUserRole answered no error while the role store refuses, want one") + t.Error("setUserRole answered no error, want an account nobody holds refused") } } diff --git a/internal/graphres/scope.go b/internal/graphres/scope.go index 9267de15..d30e5d23 100644 --- a/internal/graphres/scope.go +++ b/internal/graphres/scope.go @@ -9,8 +9,11 @@ import ( "github.com/vektah/gqlparser/v2/ast" "github.com/vektah/gqlparser/v2/gqlerror" + "github.com/gopherium/gouncer/authkit" + "github.com/gopherium/alphone/internal/apitoken" "github.com/gopherium/alphone/internal/credential" + "github.com/gopherium/alphone/internal/role" ) // scopeDirectiveName names the directive a root field declares its area with. @@ -120,7 +123,7 @@ func (m ScopeMap) Needed(operation ast.Operation, field string) string { func ScopeGate(scopes ScopeMap) graphql.OperationMiddleware { return func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler { token, carried := credential.TokenOf(ctx) - tier := credential.RoleOf(ctx) + tier := role.Of(authkit.IdentityFromContext(ctx).Role) operation := graphql.GetOperationContext(ctx) if operation.Operation == nil { return scopeRefusal("the operation") diff --git a/internal/graphres/scopegate_test.go b/internal/graphres/scopegate_test.go index 7d477cb8..25a19b0c 100644 --- a/internal/graphres/scopegate_test.go +++ b/internal/graphres/scopegate_test.go @@ -12,6 +12,8 @@ import ( "github.com/vektah/gqlparser/v2/ast" "github.com/vektah/gqlparser/v2/validator/rules" + "github.com/gopherium/gouncer/authkit" + "github.com/gopherium/alphone/internal/apitoken" "github.com/gopherium/alphone/internal/credential" "github.com/gopherium/alphone/internal/graphres" @@ -44,13 +46,18 @@ func gatedAsToken(t *testing.T, query string, held apitoken.Scopes) *graphql.Res // gatedAsRole runs one operation through the scope gate as a session standing in one tier. func gatedAsRole(t *testing.T, query string, tier role.Role) *graphql.Response { t.Helper() - return gatedWith(t, query, credential.WithRole(t.Context(), tier)) + return gatedWith(t, query, standingAs(t.Context(), tier)) +} + +// standingAs returns ctx carrying an identity standing in one tier. +func standingAs(ctx context.Context, tier role.Role) context.Context { + return authkit.WithIdentity(ctx, authkit.Identity{Role: tier.String()}) } // gatedAsTokenOf runs one operation through the gate as a token whose owner stands in one tier. func gatedAsTokenOf(t *testing.T, query string, held apitoken.Scopes, tier role.Role) *graphql.Response { t.Helper() - ctx := credential.WithRole(t.Context(), tier) + ctx := standingAs(t.Context(), tier) return gatedWith(t, query, credential.WithToken(ctx, credential.Token{Name: "probe", Scopes: held})) } From f0516970cd284aeec819c1811c64b6cc368963a8 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:34:35 +0200 Subject: [PATCH 12/47] feat(cmd): give a role to every account holding none --- cmd/alphone/grantrole.go | 41 +++++++++ cmd/alphone/grantrole_exec_test.go | 43 +++++++++ cmd/alphone/grantrole_test.go | 135 +++++++++++++++++++++++++++++ cmd/alphone/main.go | 5 +- cmd/alphone/main_test.go | 8 +- 5 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 cmd/alphone/grantrole.go create mode 100644 cmd/alphone/grantrole_exec_test.go create mode 100644 cmd/alphone/grantrole_test.go diff --git a/cmd/alphone/grantrole.go b/cmd/alphone/grantrole.go new file mode 100644 index 00000000..f79ad3cc --- /dev/null +++ b/cmd/alphone/grantrole.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + authkitpg "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/alphone/internal/role" +) + +// grantRole gives a role to every account holding none, from command-line arguments. +func grantRole(ctx context.Context, getenv func(string) string, args []string, stdout io.Writer) error { + flags := flag.NewFlagSet("grantrole", flag.ContinueOnError) + flags.SetOutput(stdout) + named := flags.String("role", "", "role to give every account holding none") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return fmt.Errorf("parse flags: %w", err) + } + + databaseURL := getenv("ALPHONE_DATABASE_URL") + if databaseURL == "" { + return errors.New("ALPHONE_DATABASE_URL is required") + } + held, err := role.Parse(*named) + if err != nil { + return err + } + if err := migrateSchemas(ctx, databaseURL); err != nil { + return err + } + return authkitpg.RunGrantRole(ctx, databaseURL, []string{"-role", held.String()}, stdout) +} diff --git a/cmd/alphone/grantrole_exec_test.go b/cmd/alphone/grantrole_exec_test.go new file mode 100644 index 00000000..a174f1c4 --- /dev/null +++ b/cmd/alphone/grantrole_exec_test.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package main + +import ( + "bytes" + "os/exec" + "strings" + "testing" + + authkitpg "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/alphone/internal/role" +) + +func TestMainBinaryGrantsARoleToEveryAccountHoldingNone(t *testing.T) { + t.Parallel() + + binary, env := coverBinary(t) + databaseURL := testDatabaseURL(t) + holding := storeRoleless(t, databaseURL, "none@example.com") + var stdout bytes.Buffer + granting := exec.Command(binary, "grantrole", "-role", "member") + granting.Dir = t.TempDir() + granting.Env = append(env, "ALPHONE_DATABASE_URL="+databaseURL) + granting.Stdout = &stdout + + if err := granting.Run(); err != nil { + t.Fatalf("grantrole: %v, answered %s", err, stdout.String()) + } + + users := authkitpg.NewUserStore(testPool(t, databaseURL)) + held, err := users.UserByID(t.Context(), holding.ID) + if err != nil { + t.Fatalf("UserByID() error = %v, want nil", err) + } + if held.Role != role.Member.String() { + t.Errorf("role = %q, want %q written by the running binary", held.Role, role.Member.String()) + } + if !strings.Contains(stdout.String(), "1") { + t.Errorf("output = %q, want it to count the account that took the role", stdout.String()) + } +} diff --git a/cmd/alphone/grantrole_test.go b/cmd/alphone/grantrole_test.go new file mode 100644 index 00000000..2e884b69 --- /dev/null +++ b/cmd/alphone/grantrole_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package main + +import ( + "errors" + "strings" + "testing" + + "github.com/gopherium/gouncer" + authkitpg "github.com/gopherium/gouncer/authkit/postgres" + + "github.com/gopherium/alphone/internal/role" +) + +// storeRoleless stores one account holding no role and returns it. +func storeRoleless(t *testing.T, databaseURL, email string) gouncer.User { + t.Helper() + held, err := gouncer.NewUser(email, "Maria Perez", "correct horse battery") + if err != nil { + t.Fatalf("gouncer.NewUser() error = %v, want nil", err) + } + if err := authkitpg.NewUserStore(testPool(t, databaseURL)).CreateUser(t.Context(), held); err != nil { + t.Fatalf("CreateUser() error = %v, want nil", err) + } + return held +} + +func TestGrantRoleReachesEveryAccountHoldingNone(t *testing.T) { + t.Parallel() + + databaseURL := testDatabaseURL(t) + getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": databaseURL}) + holding := storeRoleless(t, databaseURL, "none@example.com") + var stdout strings.Builder + + if err := grantRole(t.Context(), getenv, []string{"-role", "member"}, &stdout); err != nil { + t.Fatalf("grantRole() error = %v, want nil", err) + } + + users := authkitpg.NewUserStore(testPool(t, databaseURL)) + held, err := users.UserByID(t.Context(), holding.ID) + if err != nil { + t.Fatalf("UserByID() error = %v, want nil", err) + } + if held.Role != role.Member.String() { + t.Errorf("role = %q, want %q", held.Role, role.Member.String()) + } + if !strings.Contains(stdout.String(), "1") { + t.Errorf("output = %q, want it to count the account that took the role", stdout.String()) + } +} + +func TestGrantRoleLeavesAnAccountThatHoldsOne(t *testing.T) { + t.Parallel() + + databaseURL := testDatabaseURL(t) + getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": databaseURL}) + standing := storeRoleless(t, databaseURL, "standing@example.com") + if err := grantRole(t.Context(), getenv, []string{"-role", "admin"}, &strings.Builder{}); err != nil { + t.Fatalf("first grantRole() error = %v, want nil", err) + } + + if err := grantRole(t.Context(), getenv, []string{"-role", "member"}, &strings.Builder{}); err != nil { + t.Fatalf("second grantRole() error = %v, want nil", err) + } + + users := authkitpg.NewUserStore(testPool(t, databaseURL)) + held, err := users.UserByID(t.Context(), standing.ID) + if err != nil { + t.Fatalf("UserByID() error = %v, want nil", err) + } + if held.Role != role.Admin.String() { + t.Errorf("role = %q, want %q, a second run leaves an account that holds one", held.Role, role.Admin.String()) + } +} + +func TestGrantRoleRefusesARoleTheRegistryDoesNotKnow(t *testing.T) { + t.Parallel() + + getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": testDatabaseURL(t)}) + + err := grantRole(t.Context(), getenv, []string{"-role", "superadmin"}, &strings.Builder{}) + + if !errors.Is(err, role.ErrUnknownTier) { + t.Errorf("grantRole() error = %v, want a role no plugin declared refused", err) + } +} + +func TestGrantRoleNamesTheMissingDatabaseBeforeTheRole(t *testing.T) { + t.Parallel() + + err := grantRole(t.Context(), testGetenv(nil), nil, &strings.Builder{}) + + if err == nil || errors.Is(err, role.ErrUnknownTier) { + t.Errorf("grantRole() error = %v, want the database url named first", err) + } +} + +func TestGrantRoleRefusesAFlagItDoesNotKnow(t *testing.T) { + t.Parallel() + + err := grantRole(t.Context(), testGetenv(nil), []string{"-bogus"}, &strings.Builder{}) + + if err == nil { + t.Error("grantRole() error = nil, want the unknown flag refused") + } +} + +func TestGrantRoleReportsADatabaseItCannotReach(t *testing.T) { + t.Parallel() + + getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": unreachableDatabaseURL}) + + err := grantRole(t.Context(), getenv, []string{"-role", "member"}, &strings.Builder{}) + + if err == nil { + t.Error("grantRole() error = nil, want the unreachable database reported") + } +} + +func TestGrantRolePrintsItsFlags(t *testing.T) { + t.Parallel() + + var stdout strings.Builder + + err := grantRole(t.Context(), testGetenv(nil), []string{"-h"}, &stdout) + + if err != nil { + t.Fatalf("grantRole() error = %v, want nil", err) + } + if !strings.Contains(stdout.String(), "-role") { + t.Errorf("output = %q, want the flags listed", stdout.String()) + } +} diff --git a/cmd/alphone/main.go b/cmd/alphone/main.go index f7e0bfda..8a958c19 100644 --- a/cmd/alphone/main.go +++ b/cmd/alphone/main.go @@ -23,6 +23,7 @@ const usage = `AlphOne, a plugin first CRM. Usage: alphone serve the API and the web application alphone createadmin create the first administrator + alphone grantrole give a role to every account holding none alphone token create and revoke API tokens alphone seed store the demo data alphone help print this text @@ -50,6 +51,8 @@ func dispatch(ctx context.Context, args []string) error { switch args[0] { case "createadmin": return createAdmin(ctx, os.Getenv, args[1:], os.Stdin, os.Stdout) + case "grantrole": + return grantRole(ctx, os.Getenv, args[1:], os.Stdout) case "token": return token(ctx, os.Getenv, args[1:], os.Stdout) case "seed": @@ -58,7 +61,7 @@ func dispatch(ctx context.Context, args []string) error { _, err := fmt.Fprintln(os.Stdout, usage) return err default: - return fmt.Errorf("%w %q, want createadmin, seed or token, or no argument to serve", + return fmt.Errorf("%w %q, want createadmin, grantrole, seed or token, or no argument to serve", errUnknownSubcommand, args[0]) } } diff --git a/cmd/alphone/main_test.go b/cmd/alphone/main_test.go index f38bbe06..4caf0f3a 100644 --- a/cmd/alphone/main_test.go +++ b/cmd/alphone/main_test.go @@ -206,7 +206,7 @@ func TestDispatchRefusesAnUnknownSubcommand(t *testing.T) { if !strings.Contains(err.Error(), "not-a-subcommand") { t.Errorf("error = %v, want the offending argument named", err) } - for _, name := range []string{"createadmin", "seed", "token"} { + for _, name := range []string{"createadmin", "grantrole", "seed", "token"} { if !strings.Contains(err.Error(), name) { t.Errorf("error = %v, want %q offered", err, name) } @@ -216,7 +216,7 @@ func TestDispatchRefusesAnUnknownSubcommand(t *testing.T) { func TestUsageNamesEverySubcommand(t *testing.T) { t.Parallel() - for _, name := range []string{"createadmin", "seed", "token"} { + for _, name := range []string{"createadmin", "grantrole", "seed", "token"} { if !strings.Contains(usage, name) { t.Errorf("usage = %q, want %q named", usage, name) } @@ -545,12 +545,10 @@ func TestRunServesAPI(t *testing.T) { if err != nil { t.Fatalf("gouncer.NewUser() error = %v, want nil", err) } + admin.Role = role.Admin.String() if err := authkitpg.NewUserStore(pool).CreateUser(t.Context(), admin); err != nil { t.Fatalf("CreateUser() error = %v, want nil", err) } - if err := postgres.NewRoleStore(pool).Grant(t.Context(), admin.ID, role.Admin); err != nil { - t.Fatalf("Grant() error = %v, want nil, whoever provisions the first user makes it an admin", err) - } login, err := http.Post( baseURL+"/api/graphql", From a1fc02ccff12556438d3b47ad8c84ffa56284d5b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:35:15 +0200 Subject: [PATCH 13/47] refactor(cmd): name the seams that carry the privileged cover --- cmd/alphone/privileged_test.go | 37 ++++++++++++++++++++++++++++++++++ cmd/alphone/run.go | 25 ++++++++++++++--------- 2 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 cmd/alphone/privileged_test.go diff --git a/cmd/alphone/privileged_test.go b/cmd/alphone/privileged_test.go new file mode 100644 index 00000000..482a5521 --- /dev/null +++ b/cmd/alphone/privileged_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package main + +import ( + "slices" + "testing" + + "github.com/gopherium/alphone/internal/role" +) + +func TestTheAdminSeamCarriesThePrivilegedCover(t *testing.T) { + t.Parallel() + + held := adminConfig(nil) + + if !slices.Equal(held.Privileged, role.Privileged()) { + t.Errorf("Privileged = %v, want %v, an empty cover admits every role at the brick's guard", + held.Privileged, role.Privileged()) + } + if len(held.Privileged) == 0 { + t.Error("the cover is empty, want the roles that administer accounts") + } +} + +func TestTheLoginSeamCarriesThePrivilegedCover(t *testing.T) { + t.Parallel() + + held := authConfig(nil) + + if !slices.Equal(held.Privileged, role.Privileged()) { + t.Errorf("Privileged = %v, want %v", held.Privileged, role.Privileged()) + } + if held.CookieName == "" { + t.Error("the login seam names no cookie, want the session cookie the server reads") + } +} diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index 4bc5ce80..dcb89d4d 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -60,7 +60,6 @@ func run( contacts := postgres.NewContactStore(pool) tasks := postgres.NewTaskStore(pool) tokens := postgres.NewTokenStore(pool) - roles := postgres.NewRoleStore(pool) webhooks := postgres.NewWebhookStore(pool) dispatcher := webhook.NewDispatcher(webhooks, logger) deliveries := webhook.NewWorker(webhooks, logger) @@ -93,12 +92,8 @@ func run( return fmt.Errorf("start plugins: %w", err) } - auth := authkit.New(authkit.Config{ - Store: userStore, - CookieName: server.SessionCookieName, - Privileged: role.Privileged(), - }) - admin := authkit.NewAdmin(authkit.AdminConfig{Store: userStore, Privileged: role.Privileged()}) + auth := authkit.New(authConfig(userStore)) + admin := authkit.NewAdmin(adminConfig(userStore)) graphRoot, err := graphroot.FromPlugins(&graphres.Resolver{ Version: version.Version(), Contacts: contacts, @@ -106,7 +101,6 @@ func run( Webhooks: webhooks, Tenants: postgres.NewTenantStore(pool), Tokens: tokens, - Roles: roles, Events: events, Live: hub, Auth: auth, @@ -123,7 +117,6 @@ func run( Auth: auth, GraphRoot: graphRoot, Tokens: tokens, - Roles: roles, Plugins: host.Routes(), PluginPublicPaths: host.PublicPaths(), PluginAreas: pluginAreas(registered), @@ -156,6 +149,20 @@ 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 { + return authkit.Config{ + Store: store, + CookieName: server.SessionCookieName, + Privileged: role.Privileged(), + } +} + +// adminConfig returns the administration configuration guarding the privileged cover. +func adminConfig(store *authkitpg.UserStore) authkit.AdminConfig { + return authkit.AdminConfig{Store: store, Privileged: role.Privileged()} +} + // declareRoles grants the registry every role a registered plugin declares. func declareRoles(registry *role.Registry, registered []sdk.Plugin) error { for _, plugin := range registered { From f9cd4418e83dc64268f39545df99366b1c562893 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:35:44 +0200 Subject: [PATCH 14/47] test: drive the role rails through the brick --- cmd/alphone/createadmin.go | 22 +--------------------- cmd/alphone/main_exec_test.go | 4 ++++ cmd/alphone/roles_exec_test.go | 22 +--------------------- cmd/alphone/seed.go | 3 --- cmd/alphone/seed_test.go | 14 +++++--------- test/features/features/roles.feature | 4 ++-- test/features/steps_roles_test.go | 16 ++++++++-------- test/features/world_test.go | 8 +------- 8 files changed, 22 insertions(+), 71 deletions(-) diff --git a/cmd/alphone/createadmin.go b/cmd/alphone/createadmin.go index 5c9119c3..c1d28493 100644 --- a/cmd/alphone/createadmin.go +++ b/cmd/alphone/createadmin.go @@ -8,14 +8,12 @@ import ( "flag" "fmt" "io" - "strings" "github.com/jackc/pgx/v5/pgxpool" "github.com/gopherium/gouncer/authkit" authkitpg "github.com/gopherium/gouncer/authkit/postgres" - "github.com/gopherium/alphone/internal/postgres" "github.com/gopherium/alphone/internal/role" ) @@ -58,23 +56,5 @@ func createAdmin( } users := authkitpg.NewUserStore(pool) - if err := authkit.CreateAdmin(ctx, users, *email, *name, held.String(), stdin, stdout); err != nil { - return err - } - return grantRole(ctx, pool, users, *email, held) -} - -// grantRole puts the named user in the given tier. -func grantRole( - ctx context.Context, - pool *pgxpool.Pool, - users *authkitpg.UserStore, - email string, - held role.Role, -) error { - owner, err := users.UserByEmail(ctx, strings.ToLower(strings.TrimSpace(email))) - if err != nil { - return err - } - return postgres.NewRoleStore(pool).Grant(ctx, owner.ID, held) + return authkit.CreateAdmin(ctx, users, *email, *name, held.String(), stdin, stdout) } diff --git a/cmd/alphone/main_exec_test.go b/cmd/alphone/main_exec_test.go index c54a13fd..c79cd6de 100644 --- a/cmd/alphone/main_exec_test.go +++ b/cmd/alphone/main_exec_test.go @@ -306,6 +306,10 @@ type graphAnswer struct { Me struct { Role string `json:"role"` } `json:"me"` + Users []struct { + ID string `json:"id"` + Email string `json:"email"` + } `json:"users"` ImportUpload struct { ID string `json:"id"` } `json:"importUpload"` diff --git a/cmd/alphone/roles_exec_test.go b/cmd/alphone/roles_exec_test.go index c226a709..eb1f7ca9 100644 --- a/cmd/alphone/roles_exec_test.go +++ b/cmd/alphone/roles_exec_test.go @@ -7,11 +7,8 @@ import ( "strings" "testing" - "github.com/jackc/pgx/v5/pgxpool" "github.com/peterldowns/pgtestdb" - authkitpg "github.com/gopherium/gouncer/authkit/postgres" - "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/testdb" ) @@ -25,22 +22,6 @@ func barePostgres(t *testing.T) string { return pgtestdb.Custom(t, testdb.Config(), pgtestdb.NoopMigrator{}).URL() } -func TestGrantAdminReportsAnUnknownUser(t *testing.T) { - t.Parallel() - - pool, err := pgxpool.New(t.Context(), testDatabaseURL(t)) - if err != nil { - t.Fatalf("connecting pool: %v", err) - } - t.Cleanup(pool.Close) - - err = grantRole(t.Context(), pool, authkitpg.NewUserStore(pool), "nobody@example.com", role.Admin) - - if err == nil { - t.Error("grantRole() error = nil, want a refusal for a user that does not exist") - } -} - func TestCreateAdminProvisionsAnAdminOnABareDatabase(t *testing.T) { t.Parallel() @@ -61,8 +42,7 @@ func TestCreateAdminProvisionsAnAdminOnABareDatabase(t *testing.T) { defer func() { _ = db.Close() }() var tier string if err := db.QueryRowContext(t.Context(), - `SELECT r.role FROM core.user_roles r - JOIN auth.users u ON u.id = r.user_id WHERE u.email = 'admin@example.com'`).Scan(&tier); err != nil { + "SELECT role FROM auth.users WHERE email = 'admin@example.com'").Scan(&tier); err != nil { t.Fatalf("reading the provisioned role: %v", err) } if tier != role.Admin.String() { diff --git a/cmd/alphone/seed.go b/cmd/alphone/seed.go index 2f50e4c4..8511e4a5 100644 --- a/cmd/alphone/seed.go +++ b/cmd/alphone/seed.go @@ -97,9 +97,6 @@ func seedUsers(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) } created[login.email] = made } - if err := grantRole(ctx, pool, users, seedAdminEmail, role.Admin); err != nil { - return nil, err - } return created, nil } diff --git a/cmd/alphone/seed_test.go b/cmd/alphone/seed_test.go index 8e0b535e..20404702 100644 --- a/cmd/alphone/seed_test.go +++ b/cmd/alphone/seed_test.go @@ -110,11 +110,7 @@ func TestSeedStandsAMemberBesideTheAdmin(t *testing.T) { if err != nil { t.Fatalf("UserByEmail() error = %v, want the seeded member", err) } - tier, err := postgres.NewRoleStore(pool).RoleOf(t.Context(), member.ID) - if err != nil { - t.Fatalf("RoleOf() error = %v, want nil", err) - } - if tier != role.Member { + if tier := role.Of(member.Role); tier != role.Member { t.Errorf("the seeded colleague stands in %v, want %v", tier, role.Member) } if !strings.Contains(stdout.String(), seedMemberEmail) { @@ -495,19 +491,19 @@ func TestSeedReportsAdminStorageFailure(t *testing.T) { } } -func TestSeedReportsARoleGrantFailure(t *testing.T) { +func TestSeedReportsAnUnstorableAccount(t *testing.T) { t.Parallel() databaseURL := testDatabaseURL(t) pool := testPool(t, databaseURL) if _, err := pool.Exec(t.Context(), - "ALTER TABLE core.user_roles ADD CONSTRAINT seed_sabotage CHECK (false)"); err != nil { - t.Fatalf("breaking the roles table: %v", err) + "ALTER TABLE auth.users ADD CONSTRAINT seed_sabotage CHECK (false)"); err != nil { + t.Fatalf("breaking the users table: %v", err) } getenv := testGetenv(map[string]string{"ALPHONE_DATABASE_URL": databaseURL}) if err := seed(t.Context(), getenv, &strings.Builder{}); err == nil { - t.Fatal("seed() error = nil, want the unstored admin grant reported") + t.Fatal("seed() error = nil, want the unstored account reported") } } diff --git a/test/features/features/roles.feature b/test/features/features/roles.feature index 7266970c..713d73ed 100644 --- a/test/features/features/roles.feature +++ b/test/features/features/roles.feature @@ -40,6 +40,6 @@ Feature: A role narrows what a user may do When the admin's session promotes the member to "admin" Then the member's session sees its role as "admin" - Scenario: The last admin cannot be demoted + Scenario: An admin cannot change its own role When the admin's session demotes itself to "member" - Then the operation is refused as the last admin + Then the operation is refused as a change to its own role diff --git a/test/features/steps_roles_test.go b/test/features/steps_roles_test.go index ba5c0ab4..a3b72efa 100644 --- a/test/features/steps_roles_test.go +++ b/test/features/steps_roles_test.go @@ -13,7 +13,6 @@ import ( "github.com/google/uuid" "github.com/gopherium/alphone/internal/apitoken" - "github.com/gopherium/alphone/internal/postgres" "github.com/gopherium/alphone/internal/role" ) @@ -106,9 +105,9 @@ func registerRoleWriteSteps(sc *godog.ScenarioContext) { return w.postGraphAsSession(ctx, settingUserRole(w.ownerID, tier)) }) - sc.Then(`^the operation is refused as the last admin$`, func(ctx context.Context) error { + sc.Then(`^the operation is refused as a change to its own role$`, func(ctx context.Context) error { w := worldFrom(ctx) - if err := w.refusedAsLastAdmin(); err != nil { + if err := w.refusedAsOwnRole(); err != nil { return err } return w.standsAsAdmin(ctx, w.ownerID) @@ -132,8 +131,8 @@ func (w *world) roleSeen(tier string) error { return nil } -// refusedAsLastAdmin reports whether the last operation was refused for unseating the last admin. -func (w *world) refusedAsLastAdmin() error { +// refusedAsOwnRole reports whether the last operation was refused as a change to the caller's own role. +func (w *world) refusedAsOwnRole() error { parsed, err := w.scopeErrors() if err != nil { return err @@ -145,8 +144,8 @@ func (w *world) refusedAsLastAdmin() error { if code := refused.Extensions["code"]; code != "VALIDATION" { return fmt.Errorf("code = %v, want VALIDATION", code) } - if !strings.Contains(refused.Message, "last admin") { - return fmt.Errorf("message = %q, want it to name the last admin", refused.Message) + if !strings.Contains(refused.Message, "your own role") { + return fmt.Errorf("message = %q, want it to name the caller's own role", refused.Message) } return nil } @@ -229,10 +228,11 @@ func disablingUser(userID uuid.UUID) string { // standsAsAdmin reports whether the named user holds the admin tier. func (w *world) standsAsAdmin(ctx context.Context, userID uuid.UUID) error { - tier, err := postgres.NewRoleStore(w.pool).RoleOf(ctx, userID) + held, err := w.users.UserByID(ctx, userID) 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) } diff --git a/test/features/world_test.go b/test/features/world_test.go index 2216a0a8..f8c5e9dd 100644 --- a/test/features/world_test.go +++ b/test/features/world_test.go @@ -104,7 +104,6 @@ func bootWorld(t *testing.T, liveImports bool) *world { contacts := postgres.NewContactStore(pool) tasks := postgres.NewTaskStore(pool) tokens := postgres.NewTokenStore(pool) - roles := postgres.NewRoleStore(pool) webhooks := postgres.NewWebhookStore(pool) hub := event.NewHub() auth := authkit.New(authkit.Config{Store: users, CookieName: server.SessionCookieName}) @@ -124,10 +123,9 @@ func bootWorld(t *testing.T, liveImports bool) *world { Webhooks: webhooks, Tenants: postgres.NewTenantStore(pool), Tokens: tokens, - Roles: roles, Live: hub, Auth: auth, - Admin: authkit.NewAdmin(authkit.AdminConfig{Store: users}), + Admin: authkit.NewAdmin(authkit.AdminConfig{Store: users, Privileged: role.Privileged()}), LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}), }, registered) if err != nil { @@ -142,9 +140,6 @@ func bootWorld(t *testing.T, liveImports bool) *world { if err != nil { t.Fatalf("reading the owner: %v", err) } - if err := roles.Grant(context.Background(), owner.ID, role.Admin); err != nil { - t.Fatalf("granting the owner its tier: %v", err) - } minted, err := apitoken.Mint(owner.ID, "mcp scenario", apitoken.Full(), apitoken.Never) if err != nil { t.Fatalf("minting the token: %v", err) @@ -158,7 +153,6 @@ func bootWorld(t *testing.T, liveImports bool) *world { Auth: auth, GraphRoot: root, Tokens: tokens, - Roles: roles, FieldSources: []sdk.FieldSource{fieldsPlugin}, Version: "test", })) From 25bb1d4207b4c27223dbc5bf1cec1393416bee2b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:37:19 +0200 Subject: [PATCH 15/47] docs: restore the role column rather than the dropped table --- .../docs/self-hosting/updates-and-backups.md | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/src/content/docs/self-hosting/updates-and-backups.md b/docs/src/content/docs/self-hosting/updates-and-backups.md index 61e7147d..6c2c4f46 100644 --- a/docs/src/content/docs/self-hosting/updates-and-backups.md +++ b/docs/src/content/docs/self-hosting/updates-and-backups.md @@ -58,25 +58,32 @@ Migrations only move forward, so if the newer version already migrated the schema, restore the matching backup instead of just pinning the older image. -If you ever roll a migration back by hand, know that the roles -migration is the one that loses data. Its down step drops the table -holding who is an admin, so every promotion and demotion goes with it, -and applying it again makes every user an admin. Save the rows first: +If you ever roll a migration back by hand, know that the role each +account holds is the thing most easily lost. It lives in a `role` +column on the account row. The migration that added that column drops +it on the way down, and every promotion and demotion goes with it. +Save the roles first: ```sh -docker compose exec -T postgres \ - pg_dump -U alphone --data-only --no-owner -t core.user_roles alphone > user_roles.sql +docker compose exec -T postgres psql -U alphone alphone -tAc \ + "SELECT email || ',' || role FROM auth.users" > roles.csv ``` -Applying the migration again refills that table with one admin row per -user, so put your own rows back over the top: +Put them back once the column exists again: ```sh -docker compose exec -T postgres psql -U alphone alphone \ - -c 'TRUNCATE core.user_roles' -docker compose exec -T postgres psql -U alphone alphone < user_roles.sql +while IFS=, read -r email held; do + docker compose exec -T postgres psql -U alphone alphone \ + -c "UPDATE auth.users SET role = '$held' WHERE email = '$email'" +done < roles.csv ``` +Accounts that end up holding no role can do nothing until they are +given one. `alphone grantrole -role member` gives a role to every +account holding none, and says how many it changed. It leaves the +accounts that already hold one alone, so running it twice changes +nothing the second time. + ## Backup scenario A nightly `pg_dump` covers a single-server install. Save this as From 23f39c15aeb135d9808e90be3ac9b21bf0518088 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:37:42 +0200 Subject: [PATCH 16/47] feat(role): add ErrBeyondReach for role capability violations --- internal/role/role.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/role/role.go b/internal/role/role.go index 15749e13..0cce60da 100644 --- a/internal/role/role.go +++ b/internal/role/role.go @@ -21,6 +21,9 @@ var ErrUnknownTier = errors.New("unknown tier") // ErrEmptyRole reports a role declared with no name. var ErrEmptyRole = errors.New("empty role") +// ErrBeyondReach reports a write touching a role holding a capability the caller lacks. +var ErrBeyondReach = errors.New("role beyond reach") + // Role is the tier a user stands in. type Role string From d8434c40a17941251943ec78e4eb46c94d3df120 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:51:31 +0200 Subject: [PATCH 17/47] feat(role): name every capability the registry holds --- internal/role/capability_test.go | 16 ++++++++++++++++ internal/role/role.go | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/internal/role/capability_test.go b/internal/role/capability_test.go index 239fe2d1..5a7962d0 100644 --- a/internal/role/capability_test.go +++ b/internal/role/capability_test.go @@ -53,6 +53,22 @@ func TestTheCoreKnowsOnlyAdminAndMember(t *testing.T) { } } +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) + } + + if got := registry.Capabilities(); !slices.Equal(got, []role.Capability{"manage_reports", role.ManageUsers}) { + t.Errorf("Capabilities() = %v, want each capability once, in name order", got) + } + if got := role.Capabilities(); !slices.Equal(got, []role.Capability{role.ManageUsers}) { + t.Errorf("Capabilities() = %v, want the core capability alone", got) + } +} + func TestTheDefaultRegistryAnswersThePackageFunctions(t *testing.T) { t.Parallel() diff --git a/internal/role/role.go b/internal/role/role.go index 0cce60da..2055943b 100644 --- a/internal/role/role.go +++ b/internal/role/role.go @@ -102,6 +102,22 @@ func (r *Registry) Roles() []Role { 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() @@ -172,6 +188,11 @@ 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) From 6cdead10a94988d5d9cdc41de1db04744a9e23ca Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:51:40 +0200 Subject: [PATCH 18/47] feat(graph): let a root field name the capability it needs --- graph/generated.go | 16 ++++++++++++---- graph/schema.graphql | 8 ++++---- graph/schema/auth.graphqls | 6 +++--- graph/schema/core.graphqls | 2 +- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/graph/generated.go b/graph/generated.go index f7f1af4d..c937df52 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -171,7 +171,7 @@ type ComplexityRoot struct { ArchiveField func(childComplexity int, id uuid.UUID) int CreateContact func(childComplexity int, name string, identities []*model.ContactIdentityInput) int CreateTask func(childComplexity int, input model.CreateTaskInput) int - CreateUser func(childComplexity int, email string, name string, password string) int + CreateUser func(childComplexity int, email string, name string, password string, role *string) int CreateWebhook func(childComplexity int, url string, events []string) int DefineField func(childComplexity int, name string, label string, kind model.FieldKind) int DeleteContactIdentity func(childComplexity int, contactID uuid.UUID, identityID uuid.UUID) int @@ -321,7 +321,7 @@ type MutationResolver interface { DeleteContactIdentity(ctx context.Context, contactID uuid.UUID, identityID uuid.UUID) (bool, error) Login(ctx context.Context, email string, password string) (*model.LoginPayload, error) Logout(ctx context.Context) (bool, error) - CreateUser(ctx context.Context, email string, name string, password string) (*model.User, error) + CreateUser(ctx context.Context, email string, name string, password string, role *string) (*model.User, error) SetUserDisabled(ctx context.Context, id uuid.UUID, disabled bool) (bool, error) SetUserRole(ctx context.Context, id uuid.UUID, role string) (bool, error) CreateTask(ctx context.Context, input model.CreateTaskInput) (*model.CreateTaskPayload, error) @@ -903,7 +903,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.ComplexityRoot.Mutation.CreateUser(childComplexity, args["email"].(string), args["name"].(string), args["password"].(string)), true + return e.ComplexityRoot.Mutation.CreateUser(childComplexity, args["email"].(string), args["name"].(string), args["password"].(string), args["role"].(*string)), true case "Mutation.createWebhook": if e.ComplexityRoot.Mutation.CreateWebhook == nil { break @@ -2568,6 +2568,14 @@ func (ec *executionContext) field_Mutation_createUser_args(ctx context.Context, return nil, err } args["password"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "role", + func(ctx context.Context, v any) (*string, error) { + return ec.unmarshalOString2ᚖstring(ctx, v) + }) + if err != nil { + return nil, err + } + args["role"] = arg3 return args, nil } @@ -5112,7 +5120,7 @@ func (ec *executionContext) _Mutation_createUser(ctx context.Context, field grap }, func(ctx context.Context) (any, error) { fc := graphql.GetFieldContext(ctx) - return ec.Resolvers.Mutation().CreateUser(ctx, fc.Args["email"].(string), fc.Args["name"].(string), fc.Args["password"].(string)) + return ec.Resolvers.Mutation().CreateUser(ctx, fc.Args["email"].(string), fc.Args["name"].(string), fc.Args["password"].(string), fc.Args["role"].(*string)) }, nil, func(ctx context.Context, selections ast.SelectionSet, v *model.User) graphql.Marshaler { diff --git a/graph/schema.graphql b/graph/schema.graphql index 4eb0f338..cb96a56e 100644 --- a/graph/schema.graphql +++ b/graph/schema.graphql @@ -1,5 +1,5 @@ directive @goField(forceResolver: Boolean, name: String, omittable: Boolean) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION -directive @scope(area: String!, write: Boolean!, admin: Boolean = false) on FIELD_DEFINITION +directive @scope(area: String!, write: Boolean!, admin: Boolean = false, capability: String) on FIELD_DEFINITION type ApiToken { id: UUID! name: String! @@ -136,9 +136,9 @@ type Mutation { deleteContactIdentity(contactId: UUID!, identityId: UUID!): Boolean! @scope(area: "contacts", write: true) login(email: String!, password: String!): LoginPayload! @scope(area: "auth", write: true) logout: Boolean! @scope(area: "auth", write: true) - createUser(email: String!, name: String!, password: String!): User! @scope(area: "users", write: true, admin: true) - setUserDisabled(id: UUID!, disabled: Boolean!): Boolean! @scope(area: "users", write: true, admin: true) - setUserRole(id: UUID!, role: String!): Boolean! @scope(area: "users", write: true, admin: true) + createUser(email: String!, name: String!, password: String!, role: String): User! @scope(area: "users", write: true, capability: "manage_users") + setUserDisabled(id: UUID!, disabled: Boolean!): Boolean! @scope(area: "users", write: true, capability: "manage_users") + setUserRole(id: UUID!, role: String!): Boolean! @scope(area: "users", write: true, capability: "manage_users") createTask(input: CreateTaskInput!): CreateTaskPayload! @scope(area: "tasks", write: true) updateTask(id: UUID!, input: UpdateTaskInput!): Task! @scope(area: "tasks", write: true) apiTokenCreate(name: String!, scopes: [String!]!, ttlDays: Int): ApiTokenSecret! @scope(area: "tokens", write: true) diff --git a/graph/schema/auth.graphqls b/graph/schema/auth.graphqls index 83813200..e7ee7fe6 100644 --- a/graph/schema/auth.graphqls +++ b/graph/schema/auth.graphqls @@ -26,7 +26,7 @@ extend type Query { extend type Mutation { login(email: String!, password: String!): LoginPayload! @scope(area: "auth", write: true) logout: Boolean! @scope(area: "auth", write: true) - createUser(email: String!, name: String!, password: String!): User! @scope(area: "users", write: true, admin: true) - setUserDisabled(id: UUID!, disabled: Boolean!): Boolean! @scope(area: "users", write: true, admin: true) - setUserRole(id: UUID!, role: String!): Boolean! @scope(area: "users", write: true, admin: true) + createUser(email: String!, name: String!, password: String!, role: String): User! @scope(area: "users", write: true, capability: "manage_users") + setUserDisabled(id: UUID!, disabled: Boolean!): Boolean! @scope(area: "users", write: true, capability: "manage_users") + setUserRole(id: UUID!, role: String!): Boolean! @scope(area: "users", write: true, capability: "manage_users") } diff --git a/graph/schema/core.graphqls b/graph/schema/core.graphqls index 8adae780..aa2b897d 100644 --- a/graph/schema/core.graphqls +++ b/graph/schema/core.graphqls @@ -4,7 +4,7 @@ directive @goField( omittable: Boolean ) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION -directive @scope(area: String!, write: Boolean!, admin: Boolean = false) on FIELD_DEFINITION +directive @scope(area: String!, write: Boolean!, admin: Boolean = false, capability: String) on FIELD_DEFINITION scalar UUID scalar DateTime From 14a26951adb03de422f05ba3ffcfd1e2d92f1466 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:51:49 +0200 Subject: [PATCH 19/47] feat(graphres): gate every root field on a capability --- internal/graphres/auth.go | 24 ++++++-- internal/graphres/roles_test.go | 85 +++++++++++++++++++++++++++++ internal/graphres/scope.go | 31 +++++++++-- internal/graphres/scope_test.go | 33 +++++++++-- internal/graphres/scopegate_test.go | 20 +++++++ 5 files changed, 177 insertions(+), 16 deletions(-) diff --git a/internal/graphres/auth.go b/internal/graphres/auth.go index 63dec14b..5463cd29 100644 --- a/internal/graphres/auth.go +++ b/internal/graphres/auth.go @@ -128,13 +128,29 @@ func (m MutationResolvers) Logout(ctx context.Context) (bool, error) { return true, nil } -// CreateUser creates a user account. -func (m MutationResolvers) CreateUser(ctx context.Context, email, name, password string) (*model.User, error) { - account, err := m.root.Admin.CreateAccount(ctx, email, name, password, role.Member.String()) +// CreateUser creates a user account under the role it names, the narrowest when it names none. +func (m MutationResolvers) CreateUser( + ctx context.Context, + email, name, password string, + named *string, +) (*model.User, error) { + stood := role.Member + if named != nil { + parsed, err := role.Parse(*named) + if err != nil { + return nil, err + } + stood = parsed + } + actor := authkit.IdentityFromContext(ctx) + if !role.Outranks(role.Role(actor.Role), stood) { + return nil, role.ErrBeyondReach + } + account, err := m.root.Admin.CreateAccount(ctx, email, name, password, stood.String()) if err != nil { return nil, err } - return toUser(account, role.Member), nil + return toUser(account, stood), nil } // SetUserDisabled updates whether the account may log in, keeping the deployment an enabled admin. diff --git a/internal/graphres/roles_test.go b/internal/graphres/roles_test.go index 61d4de4c..2557b257 100644 --- a/internal/graphres/roles_test.go +++ b/internal/graphres/roles_test.go @@ -131,6 +131,91 @@ func TestMeAnswersTheCallersTier(t *testing.T) { } } +func TestCreateUserStartsAtTheNarrowestRoleWhenTheInputNamesNone(t *testing.T) { + t.Parallel() + + store, _ := roledStore(t, role.Admin) + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) + + var answered struct { + CreateUser struct { + Role string `json:"role"` + } `json:"createUser"` + } + client.MustPost(`mutation { createUser(`+ + `email: "maria@example.com", name: "Maria Perez", password: "correct horse battery"`+ + `) { role } }`, &answered) + + if answered.CreateUser.Role != role.Member.String() { + t.Errorf("role = %q, want %q, an account starts at the narrowest role", + answered.CreateUser.Role, role.Member.String()) + } +} + +func TestCreateUserStartsAtTheRoleTheInputNames(t *testing.T) { + t.Parallel() + + store, _ := roledStore(t, role.Admin) + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) + + var answered struct { + CreateUser struct { + Role string `json:"role"` + } `json:"createUser"` + } + client.MustPost(`mutation { createUser(`+ + `email: "maria@example.com", name: "Maria Perez", password: "correct horse battery", role: "admin"`+ + `) { role } }`, &answered) + + if answered.CreateUser.Role != role.Admin.String() { + t.Errorf("role = %q, want %q", answered.CreateUser.Role, role.Admin.String()) + } +} + +func TestCreateUserRefusesARoleTheRegistryDoesNotKnow(t *testing.T) { + t.Parallel() + + store, _ := roledStore(t, role.Admin) + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) + + answered, err := client.RawPost(`mutation { createUser(` + + `email: "maria@example.com", name: "Maria Perez", password: "correct horse battery", role: "root"` + + `) { role } }`) + + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) + } + if got := firstErrorCode(t, answered.Errors); got != "VALIDATION" { + t.Errorf("code = %q, want VALIDATION", got) + } +} + +func TestCreateUserRefusesARoleBeyondTheCallersReach(t *testing.T) { + t.Parallel() + + store, _ := roledStore(t, role.Admin) + resolver := newAuthResolver(store) + actor := authkit.Identity{ID: uuid.Must(uuid.NewV7()), Role: role.Admin.String()} + client := newActingClient(t, resolver, actor) + + answered, err := client.RawPost(`mutation { createUser(` + + `email: "maria@example.com", name: "Maria Perez", password: "correct horse battery", ` + + `role: "` + stewardRole.String() + `") { role } }`) + + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) + } + if len(answered.Errors) == 0 { + t.Error("createUser answered no error, want an admin refused a role it does not hold") + } +} + func TestSetUserRoleStandsAUserInTheTierItNames(t *testing.T) { t.Parallel() diff --git a/internal/graphres/scope.go b/internal/graphres/scope.go index d30e5d23..10092b08 100644 --- a/internal/graphres/scope.go +++ b/internal/graphres/scope.go @@ -37,11 +37,15 @@ type scopeKey struct { // adminArgument names the scope argument reserving a field to the admin tier. const adminArgument = "admin" -// fieldScope is the area, access and tier one root field needs. +// capabilityArgument names the scope argument naming the capability a field needs. +const capabilityArgument = "capability" + +// fieldScope is the area, access and capability one root field needs. type fieldScope struct { - area string - write bool - admin bool + area string + write bool + admin bool + capability role.Capability } // ScopeMap answers what every root field of a schema needs of its caller. @@ -80,6 +84,9 @@ func scopeOf(declared *ast.Directive) fieldScope { if adminOnly := declared.Arguments.ForName(adminArgument); adminOnly != nil { scope.admin = adminOnly.Value.Raw == "true" } + if needed := declared.Arguments.ForName(capabilityArgument); needed != nil { + scope.capability = role.Capability(needed.Value.Raw) + } return scope } @@ -88,6 +95,18 @@ func (m ScopeMap) AdminOnly(operation ast.Operation, field string) bool { return m[scopeKey{operation, field}].admin } +// 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 { + held := m[scopeKey{operation, field}] + if held.capability != "" { + return held.capability + } + if held.admin { + return role.ManageUsers + } + return "" +} + // Allows reports whether held scopes reach one root field, refusing anything the schema does not scope. func (m ScopeMap) Allows(operation ast.Operation, field string, held apitoken.Scopes) bool { if isIntrospection(field) { @@ -123,7 +142,7 @@ func (m ScopeMap) Needed(operation ast.Operation, field string) string { func ScopeGate(scopes ScopeMap) graphql.OperationMiddleware { return func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler { token, carried := credential.TokenOf(ctx) - tier := role.Of(authkit.IdentityFromContext(ctx).Role) + tier := role.Role(authkit.IdentityFromContext(ctx).Role) operation := graphql.GetOperationContext(ctx) if operation.Operation == nil { return scopeRefusal("the operation") @@ -133,7 +152,7 @@ func ScopeGate(scopes ScopeMap) graphql.OperationMiddleware { if carried && !scopes.Allows(kind, selected.Name, token.Scopes) { return scopeRefusal(scopes.Needed(kind, selected.Name)) } - if !tier.Allows(scopes.AdminOnly(kind, selected.Name)) { + if needed := scopes.Capability(kind, selected.Name); needed != "" && !role.Can(tier, needed) { return refusal("admin required", scopes.Needed(kind, selected.Name)) } } diff --git a/internal/graphres/scope_test.go b/internal/graphres/scope_test.go index 6f9a647a..0d8ab590 100644 --- a/internal/graphres/scope_test.go +++ b/internal/graphres/scope_test.go @@ -10,11 +10,12 @@ import ( "github.com/gopherium/alphone/internal/apitoken" "github.com/gopherium/alphone/internal/graphres" + "github.com/gopherium/alphone/internal/role" ) // scopedSchema is the miniature schema every scope gate test runs against. const scopedSchema = ` -directive @scope(area: String!, write: Boolean!, admin: Boolean = false) on FIELD_DEFINITION +directive @scope(area: String!, write: Boolean!, admin: Boolean = false, capability: String) on FIELD_DEFINITION type Query { contacts: String! @scope(area: "contacts", write: false) me: String! @scope(area: "auth", write: false) @@ -25,9 +26,10 @@ type Query { type Mutation { createContact: String! @scope(area: "contacts", write: true) createTask: String! @scope(area: "tasks", write: true) - createUser: String! @scope(area: "users", write: true, admin: true) - setUserRole: String! @scope(area: "users", write: true, admin: true) + createUser: String! @scope(area: "users", write: true, capability: "manage_users") + setUserRole: String! @scope(area: "users", write: true, capability: "manage_users") setUserDisabled: String! @scope(area: "users", write: true, admin: true) + needsReports: String! @scope(area: "users", write: true, capability: "manage_reports") } type Subscription { coreEvent: String! @scope(area: "events", write: false) @@ -49,7 +51,7 @@ func TestScopeMapReadsEveryRootOperation(t *testing.T) { scopes := newScopeMap(t) - if got, want := len(scopes), 10; got != want { + if got, want := len(scopes), 11; got != want { t.Errorf("scope map holds %d fields, want %d across query, mutation and subscription", got, want) } if !scopes.Allows(ast.Query, "contacts", apitoken.ParseScopes("contacts:read")) { @@ -121,13 +123,32 @@ func TestScopeMapRefusesTokenManagementToEveryToken(t *testing.T) { } } +func TestScopeMapReadsTheCapabilityAFieldDeclares(t *testing.T) { + t.Parallel() + + scopes := newScopeMap(t) + + if got := scopes.Capability(ast.Mutation, "createUser"); got != role.ManageUsers { + t.Errorf("createUser needs %q, want %q", got, role.ManageUsers) + } + if got := scopes.Capability(ast.Mutation, "setUserRole"); got != role.ManageUsers { + t.Errorf("setUserRole needs %q, want %q", got, role.ManageUsers) + } + if got := scopes.Capability(ast.Mutation, "createContact"); got != "" { + t.Errorf("createContact needs %q, want no capability of an unmarked field", got) + } + if got := scopes.Capability(ast.Query, "users"); got != "" { + t.Errorf("the users listing needs %q, want a member reading its colleagues", got) + } +} + func TestScopeMapReadsTheAdminFlagAFieldDeclares(t *testing.T) { t.Parallel() scopes := newScopeMap(t) - if !scopes.AdminOnly(ast.Mutation, "createUser") { - t.Error("createUser is not admin only, want the declared flag read") + if !scopes.AdminOnly(ast.Mutation, "setUserDisabled") { + t.Error("setUserDisabled is not admin only, want the declared flag read") } if scopes.AdminOnly(ast.Mutation, "createContact") { t.Error("createContact is admin only, want an unmarked field open to every tier") diff --git a/internal/graphres/scopegate_test.go b/internal/graphres/scopegate_test.go index 25a19b0c..e4742c0b 100644 --- a/internal/graphres/scopegate_test.go +++ b/internal/graphres/scopegate_test.go @@ -122,6 +122,26 @@ func TestScopeGateRefusesEveryUserManagementFieldToAMember(t *testing.T) { } } +func TestScopeGateRefusesAFieldWhoseCapabilityTheRoleLacks(t *testing.T) { + t.Parallel() + + answered := gatedAsRole(t, `mutation { needsReports }`, role.Admin) + + if got, want := refusalOf(t, answered), "admin required"; got != want { + t.Errorf("refusal = %q, want %q, an admin holding no manage_reports is refused", got, want) + } +} + +func TestScopeGateLetsARoleHoldingTheCapabilityThrough(t *testing.T) { + t.Parallel() + + answered := gatedAsRole(t, `mutation { needsReports }`, stewardRole) + + if len(answered.Errors) != 0 { + t.Errorf("errors = %v, want none, the declared role holds manage_reports", answered.Errors) + } +} + func TestScopeGateLetsAnAdminSessionReachAnAdminField(t *testing.T) { t.Parallel() From 6bd518644a6576c053edd4fef21a0c5ad66079fd Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sat, 22 Aug 2026 23:52:00 +0200 Subject: [PATCH 20/47] test(graph): refuse a bare admin flag and an unknown capability --- graph/scope_test.go | 70 ++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/graph/scope_test.go b/graph/scope_test.go index 25b7f088..09d3c0a4 100644 --- a/graph/scope_test.go +++ b/graph/scope_test.go @@ -6,10 +6,13 @@ import ( "fmt" "os" "path/filepath" + "slices" "testing" "github.com/vektah/gqlparser/v2/ast" "github.com/vektah/gqlparser/v2/parser" + + "github.com/gopherium/alphone/internal/role" ) // scopeDirective names the directive every root field declares its area with. @@ -78,13 +81,28 @@ func scopeProblems(operation string, field *ast.FieldDefinition) []string { problems = append(problems, fmt.Sprintf("%s.%s declares an empty area", operation, field.Name)) } problems = append(problems, accessProblems(operation, field, declared)...) - if admin := declared.Arguments.ForName("admin"); admin != nil && !isBoolean(admin.Value) { - problems = append(problems, fmt.Sprintf( - "%s.%s declares admin %s, want true or false", operation, field.Name, admin.Value.Raw)) - } + problems = append(problems, capabilityProblems(operation, field, declared)...) return problems } +// capabilityProblems reports how one root field's capability declaration falls short. +func capabilityProblems(operation string, field *ast.FieldDefinition, declared *ast.Directive) []string { + if admin := declared.Arguments.ForName("admin"); admin != nil && admin.Value.Raw == "true" { + return []string{fmt.Sprintf( + "%s.%s reserves itself with admin: true, want a capability the role table knows", + operation, field.Name)} + } + needed := declared.Arguments.ForName("capability") + if needed == nil { + return nil + } + if !slices.Contains(role.Capabilities(), role.Capability(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)} + } + return nil +} + // accessProblems reports how one root field's write flag falls short of its operation. func accessProblems(operation string, field *ast.FieldDefinition, declared *ast.Directive) []string { write := declared.Arguments.ForName("write") @@ -98,11 +116,6 @@ func accessProblems(operation string, field *ast.FieldDefinition, declared *ast. return nil } -// isBoolean reports whether one directive argument value is a boolean literal. -func isBoolean(value *ast.Value) bool { - return value != nil && value.Kind == ast.BooleanValue -} - func TestEveryRootFieldDeclaresTheScopeItNeeds(t *testing.T) { t.Parallel() @@ -141,20 +154,20 @@ type Contact { unscoped: String! } } } -func TestScopeCheckingAcceptsAnAdminFlag(t *testing.T) { +func TestScopeCheckingAcceptsACapability(t *testing.T) { t.Parallel() synthetic := ` -type Mutation { one: String! @scope(area: "users", write: true, admin: true) } +type Mutation { one: String! @scope(area: "users", write: true, capability: "manage_users") } extend type Mutation { two: String! @scope(area: "users", write: true, admin: false) } ` if got := scopedFieldsIn(t, "synthetic", synthetic); got != 2 { - t.Errorf("counted %d root fields, want 2 with the admin flag present", got) + t.Errorf("counted %d root fields, want 2 with the capability present", got) } } -func TestOnlyUserManagementIsReservedToAdmins(t *testing.T) { +func TestOnlyUserManagementNeedsTheManageUsersCapability(t *testing.T) { t.Parallel() reserved := map[string]bool{} @@ -170,27 +183,27 @@ func TestOnlyUserManagementIsReservedToAdmins(t *testing.T) { if err != nil { t.Fatalf("reading %s: %v", path, err) } - for _, field := range adminFieldsIn(t, path, string(raw)) { + for _, field := range gatedFieldsIn(t, path, string(raw)) { reserved[field] = true } } want := map[string]bool{"createUser": true, "setUserDisabled": true, "setUserRole": true} if len(reserved) != len(want) { - t.Errorf("admin only fields = %v, want %v", reserved, want) + t.Errorf("fields needing manage_users = %v, want %v", reserved, want) } for field := range want { if !reserved[field] { - t.Errorf("%s is not admin only, want user management reserved to admins", field) + t.Errorf("%s needs no capability, want user management gated on manage_users", field) } } if reserved["users"] { - t.Error("the users listing is admin only, want a member reading its colleagues") + t.Error("the users listing needs a capability, want a member reading its colleagues") } } -// adminFieldsIn names the root fields of one SDL source reserved to the admin tier. -func adminFieldsIn(t *testing.T, name, source string) []string { +// gatedFieldsIn names the root fields of one SDL source needing the manage users capability. +func gatedFieldsIn(t *testing.T, name, source string) []string { t.Helper() doc, err := parser.ParseSchema(&ast.Source{Name: name, Input: source}) if err != nil { @@ -206,7 +219,8 @@ func adminFieldsIn(t *testing.T, name, source string) []string { if declared == nil { continue } - if admin := declared.Arguments.ForName("admin"); admin != nil && admin.Value.Raw == "true" { + needed := declared.Arguments.ForName("capability") + if needed != nil && needed.Value.Raw == string(role.ManageUsers) { reserved = append(reserved, field.Name) } } @@ -214,12 +228,22 @@ func adminFieldsIn(t *testing.T, name, source string) []string { return reserved } -func TestScopeCheckingFlagsAMalformedAdminFlag(t *testing.T) { +func TestScopeCheckingFlagsACapabilityTheTableDoesNotKnow(t *testing.T) { + t.Parallel() + + synthetic := `type Mutation { one: String! @scope(area: "users", write: true, capability: "manage_moons") }` + + if got := scopeProblemsIn(t, "synthetic", synthetic); len(got) == 0 { + t.Error("a capability no role holds raised no problem, want it flagged") + } +} + +func TestScopeCheckingRefusesTheBareAdminFlag(t *testing.T) { t.Parallel() - synthetic := `type Mutation { one: String! @scope(area: "users", write: true, admin: 1) }` + synthetic := `type Mutation { one: String! @scope(area: "users", write: true, admin: true) }` if got := scopeProblemsIn(t, "synthetic", synthetic); len(got) == 0 { - t.Error("a malformed admin flag raised no problem, want it flagged") + t.Error("a field reserved with admin: true raised no problem, want a capability required") } } From c21be675ee81dde4444fa3deb94f6f7968428be9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:11:07 +0200 Subject: [PATCH 21/47] feat(graph): answer what the caller may do and may grant --- frontend/src/gql/gql.ts | 12 ++--- frontend/src/gql/graphql.ts | 8 ++-- graph/generated.go | 82 +++++++++++++++++++++++++++++++-- graph/schema.graphql | 2 + graph/schema/auth.graphqls | 2 + internal/graphres/auth.go | 14 +++++- internal/graphres/roles_test.go | 48 +++++++++++++++++++ 7 files changed, 153 insertions(+), 15 deletions(-) diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 87242022..c466be11 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -14,8 +14,8 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size */ type Documents = { - "\n\tquery Me {\n\t\tme {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\trole\n\t\t}\n\t}\n": typeof types.MeDocument, - "\n\tmutation Login($email: String!, $password: String!) {\n\t\tlogin(email: $email, password: $password) {\n\t\t\tme {\n\t\t\t\tid\n\t\t\t\temail\n\t\t\t\tname\n\t\t\t\trole\n\t\t\t}\n\t\t}\n\t}\n": typeof types.LoginDocument, + "\n\tquery Me {\n\t\tme {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\trole\n\t\t\tcapabilities\n\t\t\tgrantable\n\t\t}\n\t}\n": typeof types.MeDocument, + "\n\tmutation Login($email: String!, $password: String!) {\n\t\tlogin(email: $email, password: $password) {\n\t\t\tme {\n\t\t\t\tid\n\t\t\t\temail\n\t\t\t\tname\n\t\t\t\trole\n\t\t\t\tcapabilities\n\t\t\t\tgrantable\n\t\t\t}\n\t\t}\n\t}\n": typeof types.LoginDocument, "\n\tmutation Logout {\n\t\tlogout\n\t}\n": typeof types.LogoutDocument, "\n\tquery Users {\n\t\tusers {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\tdisabled\n\t\t\tcreatedAt\n\t\t\trole\n\t\t}\n\t}\n": typeof types.UsersDocument, "\n\tmutation SetUserRole($id: UUID!, $role: String!) {\n\t\tsetUserRole(id: $id, role: $role)\n\t}\n": typeof types.SetUserRoleDocument, @@ -38,8 +38,8 @@ type Documents = { "\n\tquery Version {\n\t\tversion\n\t}\n": typeof types.VersionDocument, }; const documents: Documents = { - "\n\tquery Me {\n\t\tme {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\trole\n\t\t}\n\t}\n": types.MeDocument, - "\n\tmutation Login($email: String!, $password: String!) {\n\t\tlogin(email: $email, password: $password) {\n\t\t\tme {\n\t\t\t\tid\n\t\t\t\temail\n\t\t\t\tname\n\t\t\t\trole\n\t\t\t}\n\t\t}\n\t}\n": types.LoginDocument, + "\n\tquery Me {\n\t\tme {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\trole\n\t\t\tcapabilities\n\t\t\tgrantable\n\t\t}\n\t}\n": types.MeDocument, + "\n\tmutation Login($email: String!, $password: String!) {\n\t\tlogin(email: $email, password: $password) {\n\t\t\tme {\n\t\t\t\tid\n\t\t\t\temail\n\t\t\t\tname\n\t\t\t\trole\n\t\t\t\tcapabilities\n\t\t\t\tgrantable\n\t\t\t}\n\t\t}\n\t}\n": types.LoginDocument, "\n\tmutation Logout {\n\t\tlogout\n\t}\n": types.LogoutDocument, "\n\tquery Users {\n\t\tusers {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\tdisabled\n\t\t\tcreatedAt\n\t\t\trole\n\t\t}\n\t}\n": types.UsersDocument, "\n\tmutation SetUserRole($id: UUID!, $role: String!) {\n\t\tsetUserRole(id: $id, role: $role)\n\t}\n": types.SetUserRoleDocument, @@ -79,11 +79,11 @@ export function graphql(source: string): unknown; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n\tquery Me {\n\t\tme {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\trole\n\t\t}\n\t}\n"): (typeof documents)["\n\tquery Me {\n\t\tme {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\trole\n\t\t}\n\t}\n"]; +export function graphql(source: "\n\tquery Me {\n\t\tme {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\trole\n\t\t\tcapabilities\n\t\t\tgrantable\n\t\t}\n\t}\n"): (typeof documents)["\n\tquery Me {\n\t\tme {\n\t\t\tid\n\t\t\temail\n\t\t\tname\n\t\t\trole\n\t\t\tcapabilities\n\t\t\tgrantable\n\t\t}\n\t}\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n\tmutation Login($email: String!, $password: String!) {\n\t\tlogin(email: $email, password: $password) {\n\t\t\tme {\n\t\t\t\tid\n\t\t\t\temail\n\t\t\t\tname\n\t\t\t\trole\n\t\t\t}\n\t\t}\n\t}\n"): (typeof documents)["\n\tmutation Login($email: String!, $password: String!) {\n\t\tlogin(email: $email, password: $password) {\n\t\t\tme {\n\t\t\t\tid\n\t\t\t\temail\n\t\t\t\tname\n\t\t\t\trole\n\t\t\t}\n\t\t}\n\t}\n"]; +export function graphql(source: "\n\tmutation Login($email: String!, $password: String!) {\n\t\tlogin(email: $email, password: $password) {\n\t\t\tme {\n\t\t\t\tid\n\t\t\t\temail\n\t\t\t\tname\n\t\t\t\trole\n\t\t\t\tcapabilities\n\t\t\t\tgrantable\n\t\t\t}\n\t\t}\n\t}\n"): (typeof documents)["\n\tmutation Login($email: String!, $password: String!) {\n\t\tlogin(email: $email, password: $password) {\n\t\t\tme {\n\t\t\t\tid\n\t\t\t\temail\n\t\t\t\tname\n\t\t\t\trole\n\t\t\t\tcapabilities\n\t\t\t\tgrantable\n\t\t\t}\n\t\t}\n\t}\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index d29c0e16..be936145 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -28,7 +28,7 @@ export type UpdateTaskInput = { export type MeQueryVariables = Exact<{ [key: string]: never; }>; -export type MeQuery = { me: { id: string, email: string, name: string, role: string } }; +export type MeQuery = { me: { id: string, email: string, name: string, role: string, capabilities: Array, grantable: Array } }; export type LoginMutationVariables = Exact<{ email: string; @@ -36,7 +36,7 @@ export type LoginMutationVariables = Exact<{ }>; -export type LoginMutation = { login: { me: { id: string, email: string, name: string, role: string } } }; +export type LoginMutation = { login: { me: { id: string, email: string, name: string, role: string, capabilities: Array, grantable: Array } } }; export type LogoutMutationVariables = Exact<{ [key: string]: never; }>; @@ -191,8 +191,8 @@ export type VersionQueryVariables = Exact<{ [key: string]: never; }>; export type VersionQuery = { version: string }; -export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; -export const LoginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Login"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]} as unknown as DocumentNode; +export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"capabilities"}},{"kind":"Field","name":{"kind":"Name","value":"grantable"}}]}}]}}]} as unknown as DocumentNode; +export const LoginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Login"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"capabilities"}},{"kind":"Field","name":{"kind":"Name","value":"grantable"}}]}}]}}]}}]} as unknown as DocumentNode; export const LogoutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Logout"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logout"}}]}}]} as unknown as DocumentNode; export const UsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Users"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"users"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"disabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; export const SetUserRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetUserRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"role"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setUserRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"role"},"value":{"kind":"Variable","name":{"kind":"Name","value":"role"}}}]}]}}]} as unknown as DocumentNode; diff --git a/graph/generated.go b/graph/generated.go index c937df52..ae225dd1 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -105,10 +105,12 @@ type ComplexityRoot struct { } Identity struct { - Email func(childComplexity int) int - ID func(childComplexity int) int - Name func(childComplexity int) int - Role func(childComplexity int) int + Capabilities func(childComplexity int) int + Email func(childComplexity int) int + Grantable func(childComplexity int) int + ID func(childComplexity int) int + Name func(childComplexity int) int + Role func(childComplexity int) int } ImportAssignment struct { @@ -598,12 +600,24 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.FieldDefinition.Name(childComplexity), true + case "Identity.capabilities": + if e.ComplexityRoot.Identity.Capabilities == nil { + break + } + + return e.ComplexityRoot.Identity.Capabilities(childComplexity), true case "Identity.email": if e.ComplexityRoot.Identity.Email == nil { break } return e.ComplexityRoot.Identity.Email(childComplexity), true + case "Identity.grantable": + if e.ComplexityRoot.Identity.Grantable == nil { + break + } + + return e.ComplexityRoot.Identity.Grantable(childComplexity), true case "Identity.id": if e.ComplexityRoot.Identity.ID == nil { break @@ -1973,6 +1987,10 @@ func (ec *executionContext) childFields_Identity(ctx context.Context, field grap return ec.fieldContext_Identity_name(ctx, field) case "role": return ec.fieldContext_Identity_role(ctx, field) + case "capabilities": + return ec.fieldContext_Identity_capabilities(ctx, field) + case "grantable": + return ec.fieldContext_Identity_grantable(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Identity", field.Name) } @@ -4083,6 +4101,52 @@ func (ec *executionContext) fieldContext_Identity_role(_ context.Context, field return graphql.NewScalarFieldContext("Identity", field, false, false, errors.New("field of type String does not have child fields")) } +func (ec *executionContext) _Identity_capabilities(ctx context.Context, field graphql.CollectedField, obj *model.Identity) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Identity_capabilities(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Capabilities, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []string) graphql.Marshaler { + return ec.marshalNString2ᚕstringᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Identity_capabilities(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("Identity", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _Identity_grantable(ctx context.Context, field graphql.CollectedField, obj *model.Identity) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Identity_grantable(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Grantable, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []string) graphql.Marshaler { + return ec.marshalNString2ᚕstringᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Identity_grantable(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("Identity", field, false, false, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _ImportAssignment_column(ctx context.Context, field graphql.CollectedField, obj *model.ImportAssignment) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -9813,6 +9877,16 @@ func (ec *executionContext) _Identity(ctx context.Context, sel ast.SelectionSet, if out.Values[i] == graphql.Null { out.Invalids++ } + case "capabilities": + out.Values[i] = ec._Identity_capabilities(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "grantable": + out.Values[i] = ec._Identity_grantable(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/graph/schema.graphql b/graph/schema.graphql index cb96a56e..f384b37e 100644 --- a/graph/schema.graphql +++ b/graph/schema.graphql @@ -77,6 +77,8 @@ type Identity { email: String! name: String! role: String! + capabilities: [String!]! + grantable: [String!]! } type ImportAssignment { column: Int! diff --git a/graph/schema/auth.graphqls b/graph/schema/auth.graphqls index e7ee7fe6..857cb2f3 100644 --- a/graph/schema/auth.graphqls +++ b/graph/schema/auth.graphqls @@ -3,6 +3,8 @@ type Identity { email: String! name: String! role: String! + capabilities: [String!]! + grantable: [String!]! } type LoginPayload { diff --git a/internal/graphres/auth.go b/internal/graphres/auth.go index 5463cd29..f991e3d3 100644 --- a/internal/graphres/auth.go +++ b/internal/graphres/auth.go @@ -29,7 +29,19 @@ func (e rateLimitedError) Error() string { // toAuthIdentity maps an authkit identity onto its graph model. func toAuthIdentity(identity authkit.Identity, tier role.Role) *model.Identity { - return &model.Identity{ID: identity.ID, Email: identity.Email, Name: identity.Name, Role: tier.String()} + grantable := role.Grantable(tier) + named := make([]string, 0, len(grantable)) + for _, held := range grantable { + named = append(named, held.String()) + } + return &model.Identity{ + ID: identity.ID, + Email: identity.Email, + Name: identity.Name, + Role: tier.String(), + Capabilities: role.CapabilitiesOf(tier), + Grantable: named, + } } // toUser maps an authkit account onto its graph model. diff --git a/internal/graphres/roles_test.go b/internal/graphres/roles_test.go index 2557b257..7b52d9c3 100644 --- a/internal/graphres/roles_test.go +++ b/internal/graphres/roles_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "slices" "testing" gqlclient "github.com/99designs/gqlgen/client" @@ -216,6 +217,53 @@ func TestCreateUserRefusesARoleBeyondTheCallersReach(t *testing.T) { } } +func TestMeAnswersTheCapabilitiesTheRoleHolds(t *testing.T) { + t.Parallel() + + resolver, held := newRoledResolver(t, role.Admin) + client := newActingClient(t, resolver, authkit.Identity{ID: held.ID, Role: held.Role}) + + var answered struct { + Me struct { + Capabilities []string `json:"capabilities"` + Grantable []string `json:"grantable"` + } `json:"me"` + } + client.MustPost(`{ me { capabilities grantable } }`, &answered) + + if !slices.Equal(answered.Me.Capabilities, []string{string(role.ManageUsers)}) { + t.Errorf("capabilities = %v, want the admin's manage_users", answered.Me.Capabilities) + } + if !slices.Contains(answered.Me.Grantable, role.Admin.String()) { + t.Errorf("grantable = %v, want an admin able to grant admin", answered.Me.Grantable) + } + if slices.Contains(answered.Me.Grantable, stewardRole.String()) { + t.Errorf("grantable = %v, want a role holding more kept out of reach", answered.Me.Grantable) + } +} + +func TestMeAnswersNothingForAnAccountHoldingNoRole(t *testing.T) { + t.Parallel() + + resolver, held := newRoledResolver(t, "") + client := newActingClient(t, resolver, authkit.Identity{ID: held.ID, Role: held.Role}) + + var answered struct { + Me struct { + Capabilities []string `json:"capabilities"` + Grantable []string `json:"grantable"` + } `json:"me"` + } + client.MustPost(`{ me { capabilities grantable } }`, &answered) + + if len(answered.Me.Capabilities) != 0 { + t.Errorf("capabilities = %v, want none for an account holding no role", answered.Me.Capabilities) + } + if !slices.Equal(answered.Me.Grantable, []string{role.Member.String()}) { + t.Errorf("grantable = %v, want only the role holding nothing", answered.Me.Grantable) + } +} + func TestSetUserRoleStandsAUserInTheTierItNames(t *testing.T) { t.Parallel() From c1a394fbd491272e9a28bf18b6abc88747025674 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:11:13 +0200 Subject: [PATCH 22/47] feat(sdk): ask a capability rather than compare a role --- sdk/frontend/index.ts | 4 +- sdk/frontend/session.ts | 52 ++++++++++++----- sdk/frontend/test/capabilities.test.tsx | 74 +++++++++++++++++++++++++ sdk/frontend/test/session.test.tsx | 17 ++---- sdk/frontend/testing.tsx | 18 ++++-- 5 files changed, 133 insertions(+), 32 deletions(-) create mode 100644 sdk/frontend/test/capabilities.test.tsx diff --git a/sdk/frontend/index.ts b/sdk/frontend/index.ts index 3bab0899..32147612 100644 --- a/sdk/frontend/index.ts +++ b/sdk/frontend/index.ts @@ -64,8 +64,8 @@ export { } from '@gopherium/godmin' export { useCanvas, useFrameLocation } from '@gopherium/godmin/router' export { ValidationError, validationMessage } from './errors' -export { roleOf, useSession } from './session' -export type { Role, Session } from './session' +export { MANAGE_USERS, can, useSession } from './session' +export type { Capability, Role, Session } from './session' export { createGraphClient, graphError, graphExtensions } from './graph' export type { GraphClient } from './graph' export { GraphProvider, useGraph } from './GraphProvider' diff --git a/sdk/frontend/session.ts b/sdk/frontend/session.ts index 1078416f..f1b7bf14 100644 --- a/sdk/frontend/session.ts +++ b/sdk/frontend/session.ts @@ -4,8 +4,14 @@ import type { User } from '@gopherium/react-auth' import { useSession as useAuthSession } from '@gopherium/react-auth' import { useMemo } from 'react' -/** Role is the tier a signed-in account stands in. */ -export type Role = 'admin' | 'member' +/** Role is the role a signed-in account holds, as the deployment names it. */ +export type Role = string + +/** Capability is a named permission a screen or control asks for. */ +export type Capability = string + +/** The capability administering accounts. */ +export const MANAGE_USERS: Capability = 'manage_users' /** Session is the signed-in account as a plugin screen reads it. */ export interface Session { @@ -15,17 +21,12 @@ export interface Session { email: string /** name is the account display name. */ name: string - /** role is the tier the account stands in. */ + /** role is the role the account holds. */ role: Role -} - -/** - * Reads the tier the stored text names, member for anything it cannot read. - * @param stored - The tier as the graph answers it. - * @returns The tier, member when the text names no known tier. - */ -export function roleOf(stored: string | undefined): Role { - return stored === 'admin' ? 'admin' : 'member' + /** capabilities names what the account may do, as the server answered it. */ + capabilities: Capability[] + /** grantable names the roles the account may give another account. */ + grantable: Role[] } /** @@ -33,12 +34,35 @@ export function roleOf(stored: string | undefined): Role { * @returns The session, or null without one. */ export function useSession(): Session | null { - const account = useAuthSession().data as (User & { role?: string }) | null | undefined + const account = useAuthSession().data as + | (User & { role?: string, capabilities?: string[], grantable?: string[] }) + | null + | undefined return useMemo( () => account - ? { id: account.id, email: account.email, name: account.name, role: roleOf(account.role) } + ? { + id: account.id, + email: account.email, + name: account.name, + role: account.role ?? '', + capabilities: account.capabilities ?? [], + grantable: account.grantable ?? [], + } : null, [account], ) } + +/** + * Reports whether the session holds the capability, no session holding none. + * @param session - The signed-in account, or nothing. + * @param capability - The capability the decision point asks for. + * @returns Whether the session holds it. + */ +export function can( + session: { capabilities?: Capability[] } | null | undefined, + capability: Capability, +): boolean { + return (session?.capabilities ?? []).includes(capability) +} diff --git a/sdk/frontend/test/capabilities.test.tsx b/sdk/frontend/test/capabilities.test.tsx new file mode 100644 index 00000000..7ce9ecfb --- /dev/null +++ b/sdk/frontend/test/capabilities.test.tsx @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { seedSession } from '@gopherium/react-auth/testing' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { expect, test } from 'vitest' + +import { MANAGE_USERS, can, useSession } from '../index' +import { adminSession, memberSession } from '../testing' + +/** Reports what a plugin screen may do with the session it reads. */ +function Probe() { + const session = useSession() + return ( +
    +
  • {`manages:${String(can(session, MANAGE_USERS))}`}
  • +
  • {`grantable:${(session?.grantable ?? []).join('|')}`}
  • +
+ ) +} + +/** Renders the probe with the given account seeded as the session. */ +function renderWithSession(user: typeof adminSession | null) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }) + seedSession(client, user) + render( + + + , + ) +} + +test('an admin session manages users', () => { + renderWithSession(adminSession) + + expect(screen.getByText('manages:true')).toBeInTheDocument() +}) + +test('a member session manages nothing', () => { + renderWithSession(memberSession) + + expect(screen.getByText('manages:false')).toBeInTheDocument() +}) + +test('no session manages nothing', () => { + renderWithSession(null) + + expect(screen.getByText('manages:false')).toBeInTheDocument() +}) + +test('a session carries the roles it may grant', () => { + renderWithSession(adminSession) + + expect(screen.getByText('grantable:admin|member')).toBeInTheDocument() +}) + +test('a session from a server that sends none of it holds nothing', () => { + const { role: _role, capabilities: _capabilities, grantable: _grantable, ...bare } = adminSession + renderWithSession(bare as typeof adminSession) + + expect(screen.getByText('manages:false')).toBeInTheDocument() + expect(screen.getByText('grantable:')).toBeInTheDocument() +}) + +test('a capability the session was never sent is refused', () => { + renderWithSession(adminSession) + + expect(can(adminSession, 'manage_tenants')).toBe(false) + expect(can(memberSession, MANAGE_USERS)).toBe(false) + expect(can(null, MANAGE_USERS)).toBe(false) + expect(can(undefined, MANAGE_USERS)).toBe(false) +}) diff --git a/sdk/frontend/test/session.test.tsx b/sdk/frontend/test/session.test.tsx index a5392f17..f469d792 100644 --- a/sdk/frontend/test/session.test.tsx +++ b/sdk/frontend/test/session.test.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import { expect, test } from 'vitest' -import { roleOf, useSession } from '../session' +import { useSession } from '../session' import { adminSession, memberSession } from '../testing' /** Prints the session a plugin screen would read, or that none is active. */ @@ -49,21 +49,14 @@ test('carries the canned member standing as a member', () => { expect(screen.getByText(new RegExp(memberSession.email))).toHaveTextContent('member') }) -test('demotes a tier it cannot read to member', () => { - renderWithSession({ ...adminSession, role: 'root' }) - - expect(screen.getByText(new RegExp(adminSession.email))).toHaveTextContent('member') -}) - test('answers null without a session', () => { renderWithSession(null) expect(screen.getByText('signed out')).toBeInTheDocument() }) -test('roleOf reads only the tiers a deployment knows', () => { - expect(roleOf('admin')).toBe('admin') - expect(roleOf('member')).toBe('member') - expect(roleOf('root')).toBe('member') - expect(roleOf(undefined)).toBe('member') +test('a session carries the role the server stored, whatever a plugin named it', () => { + renderWithSession({ ...adminSession, role: 'steward' }) + + expect(screen.getByText(/steward/)).toBeInTheDocument() }) diff --git a/sdk/frontend/testing.tsx b/sdk/frontend/testing.tsx index 6b48a011..675a5f60 100644 --- a/sdk/frontend/testing.tsx +++ b/sdk/frontend/testing.tsx @@ -29,11 +29,21 @@ import type { FrontendPlugin } from './index' export { HttpResponse, http, seedSession, server } from '@gopherium/react-auth/testing' export { graphql } from 'msw' -/** adminSession is the canned signed-in account standing as an admin. */ -export const adminSession = { ...defaultUser, role: 'admin' } +/** adminSession is the canned signed-in account holding the admin role. */ +export const adminSession = { + ...defaultUser, + role: 'admin', + capabilities: ['manage_users'], + grantable: ['admin', 'member'], +} -/** memberSession is the canned signed-in account standing as a member. */ -export const memberSession = { ...defaultUser, role: 'member' } +/** memberSession is the canned signed-in account holding the member role. */ +export const memberSession = { + ...defaultUser, + role: 'member', + capabilities: [] as string[], + grantable: ['member'], +} /** FakeGraph drives a graph client's subscriptions from a test. */ export interface FakeGraph { From 97e73935dbf6ecae1bfef522eca23f79585c93bb Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:11:21 +0200 Subject: [PATCH 23/47] feat(frontend): offer only the roles the reader may grant --- frontend/src/auth/graphTransport.ts | 15 ++-- frontend/src/auth/operations.ts | 4 ++ frontend/src/test/users-route.test.tsx | 36 ++++------ frontend/src/users/UsersScreen.tsx | 97 ++++++++++++++++++++------ 4 files changed, 102 insertions(+), 50 deletions(-) diff --git a/frontend/src/auth/graphTransport.ts b/frontend/src/auth/graphTransport.ts index 03b0fdea..719dd21a 100644 --- a/frontend/src/auth/graphTransport.ts +++ b/frontend/src/auth/graphTransport.ts @@ -3,15 +3,14 @@ import type { TypedDocumentNode } from '@graphql-typed-document-node/core' import { print } from 'graphql' -import { type Role, roleOf } from '@alphone/frontend-sdk' import { InvalidCredentialsError, RateLimitedError, UnauthorizedError } from '@gopherium/react-auth' import { EmailTakenError, ValidationError } from '@gopherium/react-auth/admin' import type { NewUser, User as BrickAccount } from '@gopherium/react-auth/admin' /** - * Account is one user account as the admin screens consume it, carrying its tier. + * Account is one user account as the admin screens consume it. */ -export type Account = BrickAccount & { role: Role } +export type Account = BrickAccount import { createUserMutation, @@ -98,7 +97,7 @@ function toAccount(user: { name: user.name, disabled: user.disabled, created_at: new Date(user.createdAt), - role: roleOf(user.role), + role: user.role ?? '', } } @@ -218,11 +217,11 @@ async function setUserDisabled(id: string, disabled: boolean): Promise { } /** - * Stands one account in another tier through the setUserRole mutation. - * @param id - The identifier of the user to restand. - * @param role - The tier the account should stand in. + * Writes the role an account holds through the setUserRole mutation. + * @param id - The identifier of the account to update. + * @param role - The role the account is to hold. */ -export async function setUserRole(id: string, role: Role): Promise { +export async function setUserRole(id: string, role: string): Promise { const result = await execute(setUserRoleMutation, { id, role }) if (firstCode(result) === 'UNAUTHENTICATED') { throw new UnauthorizedError('session expired') diff --git a/frontend/src/auth/operations.ts b/frontend/src/auth/operations.ts index 41b231f2..6e03ae94 100644 --- a/frontend/src/auth/operations.ts +++ b/frontend/src/auth/operations.ts @@ -9,6 +9,8 @@ export const meQuery = graphql(` email name role + capabilities + grantable } } `) @@ -21,6 +23,8 @@ export const loginMutation = graphql(` email name role + capabilities + grantable } } } diff --git a/frontend/src/test/users-route.test.tsx b/frontend/src/test/users-route.test.tsx index 913c4d27..d57b6422 100644 --- a/frontend/src/test/users-route.test.tsx +++ b/frontend/src/test/users-route.test.tsx @@ -151,49 +151,43 @@ test('shows the tier every account stands in', async () => { expect(within(row).getByText('Member')).toBeInTheDocument() }) -test('promotes an account and shows the new tier', async () => { - let role = 'member' +test('writes the role the reader picked for another account', async () => { let asked: unknown = null server.use( graphql.query('Users', () => - HttpResponse.json({ data: { users: [{ ...userNode(colleague, false), role }] } }), + HttpResponse.json({ data: { users: [{ ...userNode(colleague, false), role: 'member' }] } }), ), graphql.mutation('SetUserRole', ({ variables }) => { asked = { role: variables.role } - role = 'admin' return HttpResponse.json({ data: { setUserRole: true } }) }), ) renderAt('/users') - await userEvent.click(await screen.findByRole('button', { name: 'Promote Ada Lovelace' })) + await userEvent.click(await screen.findByRole('combobox', { name: 'Role of Ada Lovelace' })) + await userEvent.click(await screen.findByRole('option', { name: 'Admin' })) - expect(await screen.findByText('Admin')).toBeInTheDocument() - expect(asked).toEqual({ role: 'admin' }) + await waitFor(() => expect(asked).toEqual({ role: 'admin' })) }) -test('demotes an account that already stands as an admin', async () => { - let asked: unknown = null +test('offers only the roles the reader may grant', async () => { server.use( graphql.query('Users', () => - HttpResponse.json({ data: { users: [{ ...userNode(colleague, false), role: 'admin' }] } }), + HttpResponse.json({ data: { users: [{ ...userNode(colleague, false), role: 'member' }] } }), ), - graphql.mutation('SetUserRole', ({ variables }) => { - asked = { role: variables.role } - return HttpResponse.json({ data: { setUserRole: true } }) - }), ) renderAt('/users') - await userEvent.click(await screen.findByRole('button', { name: 'Demote Ada Lovelace' })) + await userEvent.click(await screen.findByRole('combobox', { name: 'Role of Ada Lovelace' })) - await waitFor(() => expect(asked).toEqual({ role: 'member' })) + const offered = (await screen.findAllByRole('option')).map((option) => option.textContent) + expect(offered).toEqual(['Admin', 'Member']) }) -test('reports when a tier cannot be changed', async () => { +test('reports when a role cannot be written', async () => { server.use( graphql.query('Users', () => - HttpResponse.json({ data: { users: [{ ...userNode(colleague, false), role: 'admin' }] } }), + HttpResponse.json({ data: { users: [{ ...userNode(colleague, false), role: 'member' }] } }), ), graphql.mutation('SetUserRole', () => HttpResponse.json({ @@ -204,7 +198,8 @@ test('reports when a tier cannot be changed', async () => { ) renderAt('/users') - await userEvent.click(await screen.findByRole('button', { name: 'Demote Ada Lovelace' })) + await userEvent.click(await screen.findByRole('combobox', { name: 'Role of Ada Lovelace' })) + await userEvent.click(await screen.findByRole('option', { name: 'Admin' })) expect(await screen.findByText('the last admin cannot be unseated')).toBeInTheDocument() }) @@ -220,8 +215,7 @@ test('offers a member no user management at all', async () => { await screen.findByRole('row', { name: /Ada Lovelace/ }) expect(screen.queryByRole('link', { name: 'New user' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /^Disable / })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /^Promote / })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /^Demote / })).not.toBeInTheDocument() + expect(screen.queryByRole('combobox', { name: /^Role of / })).not.toBeInTheDocument() }) test('still lists the colleagues a member works with', async () => { diff --git a/frontend/src/users/UsersScreen.tsx b/frontend/src/users/UsersScreen.tsx index db651f07..739b9a54 100644 --- a/frontend/src/users/UsersScreen.tsx +++ b/frontend/src/users/UsersScreen.tsx @@ -6,10 +6,13 @@ import { EmptyState, ErrorNotice, LoadingRows, + MANAGE_USERS, PageScreen, + SelectControl, Stack, Text, VisuallyHidden, + can, people, useSession, } from '@alphone/frontend-sdk' @@ -20,15 +23,38 @@ import { Link } from '@tanstack/react-router' import type { Account } from '../auth/graphTransport' import { setUserRole } from '../auth/graphTransport' +/** + * Returns the label a role reads as, falling back to what the server stored. + * @param role - The role the account holds. + * @returns The label. + */ +function roleLabel(role: string): string { + if (role === '') { + return 'No role' + } + return role.charAt(0).toUpperCase() + role.slice(1) +} + /** * Renders one account row with its status and tier, offering the controls only * to an admin looking at somebody else. * @param user - The account the row shows. * @param isSelf - Whether the row is the signed-in account, which gets no controls. * @param manages - Whether the caller may manage users at all. + * @param grantable - The roles the caller may give another account. * @returns The table row element. */ -function UserRow({ user, isSelf, manages }: { user: Account; isSelf: boolean; manages: boolean }) { +function UserRow({ + user, + isSelf, + manages, + grantable, +}: { + user: Account + isSelf: boolean + manages: boolean + grantable: string[] +}) { return ( {user.name} @@ -38,7 +64,13 @@ function UserRow({ user, isSelf, manages }: { user: Account; isSelf: boolean; ma {user.disabled ? 'Disabled' : 'Active'} - {user.role === 'admin' ? 'Admin' : 'Member'} + + {isSelf || !manages ? ( + roleLabel(user.role) + ) : ( + + )} + {isSelf || !manages ? null : } @@ -53,18 +85,11 @@ function UserRow({ user, isSelf, manages }: { user: Account; isSelf: boolean; ma */ function UserControls({ user }: { user: Account }) { const queryClient = useQueryClient() - const invalidate = { onSuccess: () => queryClient.invalidateQueries({ queryKey: usersQueryKey }) } const barred = user.disabled - const stands = user.role === 'admin' const toggle = useMutation({ mutationFn: () => setUserDisabled(user.id, !barred), - ...invalidate, + onSuccess: () => queryClient.invalidateQueries({ queryKey: usersQueryKey }), }) - const restand = useMutation({ - mutationFn: () => setUserRole(user.id, stands ? 'member' : 'admin'), - ...invalidate, - }) - const refused = toggle.error ?? restand.error return ( @@ -76,15 +101,37 @@ function UserControls({ user }: { user: Account }) { > {barred ? 'Enable' : 'Disable'} - - {refused ? {refused.message} : null} + {toggle.error ? {toggle.error.message} : null} + + ) +} + +/** + * Renders the control writing the role an account holds. + * @param user - The account the control acts on. + * @param grantable - The roles the reader may give another account. + * @returns The role control element. + */ +function UserRole({ user, grantable }: { user: Account; grantable: string[] }) { + const queryClient = useQueryClient() + const restand = useMutation({ + mutationFn: (role: string) => setUserRole(user.id, role), + onSuccess: () => queryClient.invalidateQueries({ queryKey: usersQueryKey }), + }) + const offered = grantable.map((role) => ({ value: role, label: roleLabel(role) })) + const held = offered.find((option) => option.value === user.role) + + return ( + + {held ? null : {roleLabel(user.role)}} + item?.value != null && restand.mutate(item.value)} + /> + {restand.error ? {restand.error.message} : null} ) } @@ -95,7 +142,7 @@ function UserControls({ user }: { user: Account }) { */ export function UsersScreen() { const session = useSession() - const manages = session?.role === 'admin' + const manages = can(session, MANAGE_USERS) const users = useQuery({ queryKey: usersQueryKey, queryFn: ({ signal }) => fetchUsers(signal) as Promise, @@ -117,7 +164,12 @@ export function UsersScreen() { } > - + ) } @@ -130,10 +182,12 @@ function UserRows({ users, currentUserId, manages, + grantable, }: { users: ReturnType> currentUserId: string | undefined manages: boolean + grantable: string[] }) { if (users.isPending) { return @@ -176,6 +230,7 @@ function UserRows({ user={user} isSelf={user.id === currentUserId} manages={manages} + grantable={grantable} /> ))} From 44932eed1af796f14a7c265d8b91a31e07767439 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:11:29 +0200 Subject: [PATCH 24/47] test(e2e): prove the role control in a real browser --- test/e2e/tests/users-member.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/e2e/tests/users-member.spec.ts b/test/e2e/tests/users-member.spec.ts index b0d40853..cc49ee49 100644 --- a/test/e2e/tests/users-member.spec.ts +++ b/test/e2e/tests/users-member.spec.ts @@ -18,7 +18,7 @@ test('a member reads the account list without managing it', async ({ page, brows await page.getByRole('button', { name: 'Create user' }).click() const created = page.getByRole('row').filter({ hasText: email }) - await expect(created.getByRole('cell', { name: 'Member', exact: true })).toBeVisible() + await expect(created.getByRole('combobox', { name: `Role of ${name}` })).toBeVisible() const member = await browser.newContext({ baseURL, @@ -33,10 +33,12 @@ test('a member reads the account list without managing it', async ({ page, brows await memberPage.goto('/users') - await expect(memberPage.getByRole('row').filter({ hasText: email })).toBeVisible() + const listed = memberPage.getByRole('row').filter({ hasText: email }) + await expect(listed).toBeVisible() + await expect(listed.getByRole('cell', { name: 'Member', exact: true })).toBeVisible() await expect(memberPage.getByRole('link', { name: 'New user' })).toBeHidden() await expect(memberPage.getByRole('button', { name: /^Disable / })).toBeHidden() - await expect(memberPage.getByRole('button', { name: /^Promote / })).toBeHidden() + await expect(memberPage.getByRole('combobox', { name: /^Role of / })).toBeHidden() await member.close() }) From d7904f85a7c0138c12fc40a217f441b7775da752 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:11:53 +0200 Subject: [PATCH 25/47] docs: teach plugin authors to ask a capability --- docs/src/content/docs/extending/screens.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/extending/screens.md b/docs/src/content/docs/extending/screens.md index 441fd3f0..0a11f5b0 100644 --- a/docs/src/content/docs/extending/screens.md +++ b/docs/src/content/docs/extending/screens.md @@ -55,22 +55,27 @@ sizes. ## Who is signed in A screen reads the signed-in account with `useSession`. It answers the -account's id, email, name, and role, or `null` when nobody is signed -in. +account's id, email, name, and role, what that role may do, and the +roles it may give another account, or `null` when nobody is signed in. + +Ask what the account may do, never which role it holds: ```tsx -import { useSession } from '@alphone/frontend-sdk' +import { MANAGE_USERS, can, useSession } from '@alphone/frontend-sdk' export function InvoicesScreen() { const session = useSession() - const manages = session?.role === 'admin' + const manages = can(session, MANAGE_USERS) … } ``` -A role is either `admin` or `member`. Anything else counts as -`member`, so a screen that gets an answer it does not recognise hides -the control rather than offering it. +A deployment names its own roles, and a plugin may add more, so a +screen comparing `session.role` to `'admin'` breaks the moment somebody +installs a plugin that declares a role of its own. Asking `can` keeps +working, because the server answers what the role holds rather than +what it is called. A session with no answer holds nothing, so a screen +that cannot tell hides the control rather than offering it. Hiding a control is presentation, not protection. The backend refuses an operation the caller may not run whether or not your screen showed From 8e56a84d70eba618b5e5c14fda8c7561036426fe Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:13:30 +0200 Subject: [PATCH 26/47] feat(graph): answer what the caller may do and may grant --- graph/model/models_gen.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/graph/model/models_gen.go b/graph/model/models_gen.go index e69b9add..920234fa 100644 --- a/graph/model/models_gen.go +++ b/graph/model/models_gen.go @@ -86,10 +86,12 @@ type FieldDefinition struct { } type Identity struct { - ID uuid.UUID `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Role string `json:"role"` + ID uuid.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Capabilities []string `json:"capabilities"` + Grantable []string `json:"grantable"` } type ImportAssignment struct { From 9b8f5b15a067e15c82d3faf17b3fa881028596f0 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:23:47 +0200 Subject: [PATCH 27/47] fix(graphres): speak the brick refusals in the deployment's own voice --- internal/graphres/errors.go | 22 ++++++++++++++++++++ internal/graphres/errors_test.go | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/internal/graphres/errors.go b/internal/graphres/errors.go index edfcdf24..1186bcca 100644 --- a/internal/graphres/errors.go +++ b/internal/graphres/errors.go @@ -63,9 +63,31 @@ var notFoundErrors = []error{ apitoken.ErrNotFound, } +// spokenAs names every brick refusal in the deployment's own voice. +var spokenAs = []struct { + sentinel error + message string +}{ + {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"}, +} + +// speak rewrites a brick refusal so no package name reaches a caller. +func speak(presented *gqlerror.Error, err error) { + for _, held := range spokenAs { + if errors.Is(err, held.sentinel) { + presented.Message = held.message + return + } + } +} + // PresentError maps resolver errors to client-facing GraphQL errors. func PresentError(ctx context.Context, err error) *gqlerror.Error { presented := graphql.DefaultErrorPresenter(ctx, err) + speak(presented, err) if applySpecialCode(presented, err) || applyListCode(presented, err) { return presented } diff --git a/internal/graphres/errors_test.go b/internal/graphres/errors_test.go index 7426da0f..178bb452 100644 --- a/internal/graphres/errors_test.go +++ b/internal/graphres/errors_test.go @@ -12,10 +12,14 @@ import ( "github.com/google/uuid" "github.com/vektah/gqlparser/v2/gqlerror" + "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit" + "github.com/gopherium/alphone/graph/scalar" "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" @@ -64,6 +68,37 @@ func TestPresentErrorMapsDomainErrors(t *testing.T) { } } +func TestPresentErrorSpeaksTheBrickRefusalsInItsOwnVoice(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + err error + want string + }{ + {"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"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + presented := graphres.PresentError(context.Background(), testCase.err) + if got := code(t, presented); got != "VALIDATION" { + t.Errorf("code = %q, want VALIDATION", got) + } + if presented.Message != testCase.want { + t.Errorf("message = %q, want %q", presented.Message, testCase.want) + } + if strings.Contains(presented.Message, "authkit") || + strings.Contains(presented.Message, "gouncer") { + t.Errorf("message = %q, want no package name reaching a caller", presented.Message) + } + }) + } +} + func TestPresentErrorCarriesTheConflictOwner(t *testing.T) { t.Parallel() From e12dec8bdffe1b546f21e047cc9a71395dbaefd9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:23:55 +0200 Subject: [PATCH 28/47] docs: describe the capability model the graph now answers --- .../src/content/docs/reference/graphql-api.md | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index ce8b7447..b79641e3 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -32,7 +32,7 @@ Authorization: Bearer a1_... ``` A token acts as the user who created it, and never reaches further than that -user does. See [Roles](#roles) for what each tier may do. Work it creates +user does. See [Roles](#roles) for what a role may do. Work it creates records `token:` in `originSource`, so automated work stays distinguishable from typed work. @@ -97,14 +97,25 @@ Always read `errors`. A 200 does not mean it worked. ## Roles -Every user stands in one of two tiers. An admin manages users. A member works -the product, which is contacts, tasks, and whatever your plugins add. +Every account holds one role, and the role decides what the account may do. A +stock deployment names two. An admin manages users. A member works the product, +which is contacts, tasks, and whatever your plugins add. A plugin may declare +roles of its own, so do not assume the list stops at two. -A user with no tier recorded is a member. So a new account starts without user -management, and gains it only when an admin says so. +What a role may do is a set of named capabilities. `me` answers the ones the +calling account holds, so a client asks what it may do rather than guessing from +the role's name: -Three operations are reserved to admins: `createUser`, `setUserDisabled` and -`setUserRole`. A member calling one is refused: +```graphql +query { me { role capabilities grantable } } +``` + +`capabilities` names what the account may do. `grantable` names the roles it may +give another account, which is every role whose capabilities it already holds +itself. An admin cannot grant a role that reaches further than its own. + +Three operations need the `manage_users` capability: `createUser`, +`setUserDisabled` and `setUserRole`. An account without it is refused: ```json { @@ -125,10 +136,18 @@ here. A token cannot carry more authority than the user it acts as. Listing users stays open to members. A member sees who its colleagues are, which is what assigning a task to one of them needs. -A deployment always keeps one admin. Demoting or disabling the last enabled -admin is refused with code `VALIDATION` and the message `the last admin cannot -be unseated`. A disabled admin does not count as cover, because its sessions -are already gone and its tokens answer `invalid token`. +Nobody changes its own role, and nobody disables its own account. Both are +refused with code `VALIDATION` and the messages `you cannot change your own +role` and `you cannot disable your own account`. Together they keep a +deployment from losing its last admin, since an admin can only ever demote +somebody else, and there is always itself left holding the authority. + +Writing a role the caller does not hold itself is refused the same way, with +`that role is beyond your own`. So an admin can neither grant a role reaching +further than admin nor touch an account already holding one. + +`createUser` takes an optional `role`. Leaving it out starts the account at the +narrowest role the deployment names, which is `member` in a stock install. ### Roles and scopes together @@ -137,10 +156,10 @@ authority. An operation runs only when both allow it. | The caller | What it holds | Reaching `createUser` | | ---------- | ------------- | --------------------- | -| An admin's session | the tier, and no token to narrow it | yes | +| An admin's session | the capability, and no token to narrow it | yes | | An admin's token scoped `users:write` | both | yes | -| An admin's token scoped `contacts:read` | the tier but not the scope | no, `scope required: users:write` | -| A member's token scoped `*` | the scope but not the tier | no, `admin required` | +| An admin's token scoped `contacts:read` | the capability but not the scope | no, `scope required: users:write` | +| A member's token scoped `*` | the scope but not the capability | no, `admin required` | The token is checked first. A caller holding neither is told about the scope, because that is the half it can fix on its own. @@ -239,7 +258,7 @@ Every error carries a `code` in its `extensions`. | Code | Meaning | | ---- | ------- | | `UNAUTHENTICATED` | No usable credential, or the operation is not `login` | -| `UNAUTHORIZED` | The caller does not reach the field. `scope required` means the token lacks the scope `scope` names, `admin required` means the user is not an admin | +| `UNAUTHORIZED` | The caller does not reach the field. `scope required` means the token lacks the scope `scope` names, `admin required` means the account's role holds no capability the field needs | | `VALIDATION` | The input was refused. `message` names the field or rule | | `NOT_FOUND` | The id names nothing | | `CONFLICT` | An identity is already claimed. `ownerContactId` names the owner | From 659e5c4f1789f6ad9392b30e95bacd2ccd04b9f0 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:51:18 +0200 Subject: [PATCH 29/47] fix(postgres): keep every role when the move rolls back --- .../postgres/migrations/00014_move_user_roles.sql | 4 ++-- internal/postgres/movedroles_test.go | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/postgres/migrations/00014_move_user_roles.sql b/internal/postgres/migrations/00014_move_user_roles.sql index b67ccf2e..782c3491 100644 --- a/internal/postgres/migrations/00014_move_user_roles.sql +++ b/internal/postgres/migrations/00014_move_user_roles.sql @@ -12,9 +12,9 @@ DROP TABLE core.user_roles; -- +goose Down CREATE TABLE core.user_roles ( user_id uuid PRIMARY KEY REFERENCES auth.users (id) ON DELETE CASCADE, - role text NOT NULL CHECK (role IN ('admin', 'member')), + role text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); INSERT INTO core.user_roles (user_id, role) -SELECT id, role FROM auth.users WHERE role IN ('admin', 'member'); +SELECT id, role FROM auth.users WHERE role <> ''; diff --git a/internal/postgres/movedroles_test.go b/internal/postgres/movedroles_test.go index fb3643d1..2a2d2b30 100644 --- a/internal/postgres/movedroles_test.go +++ b/internal/postgres/movedroles_test.go @@ -122,10 +122,15 @@ func TestMigrationRestoresTheRolesTableGoingDown(t *testing.T) { } defer func() { _ = db.Close() }() standing := seedUser(t, db, "admin@example.com") + declared := seedUser(t, db, "steward@example.com") if _, err := db.ExecContext(t.Context(), "UPDATE auth.users SET role = $1 WHERE id = $2", role.Admin.String(), standing); err != nil { t.Fatalf("standing the user in the admin tier: %v", err) } + if _, err := db.ExecContext(t.Context(), + "UPDATE auth.users SET role = 'steward' WHERE id = $1", declared); err != nil { + t.Fatalf("standing the user in a plugin declared role: %v", err) + } if _, err := coreProvider(t, db).DownTo(t.Context(), movedRolesVersion-1); err != nil { t.Fatalf("rolling the move back: %v", err) @@ -139,4 +144,12 @@ func TestMigrationRestoresTheRolesTableGoingDown(t *testing.T) { if held != role.Admin.String() { t.Errorf("restored role = %q, want %q", held, role.Admin.String()) } + var kept string + if err := db.QueryRowContext(t.Context(), + "SELECT role FROM core.user_roles WHERE user_id = $1", declared).Scan(&kept); err != nil { + t.Fatalf("reading the restored plugin role: %v", err) + } + if kept != "steward" { + t.Errorf("restored role = %q, want the plugin declared role kept rather than dropped", kept) + } } From 5a99060f60a319a86082e931145ad9c7e32c2907 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:51:25 +0200 Subject: [PATCH 30/47] fix(cmd): let a command name a role a plugin declared --- cmd/alphone/main.go | 15 +++++-- cmd/alphone/main_test.go | 4 +- cmd/alphone/roles_test.go | 82 +++++++++++++++++++++++++++++++++++++++ cmd/alphone/run.go | 17 ++++++++ 4 files changed, 113 insertions(+), 5 deletions(-) diff --git a/cmd/alphone/main.go b/cmd/alphone/main.go index 8a958c19..426ab076 100644 --- a/cmd/alphone/main.go +++ b/cmd/alphone/main.go @@ -12,6 +12,9 @@ import ( "syscall" "github.com/joho/godotenv" + + "github.com/gopherium/alphone/internal/role" + "github.com/gopherium/alphone/sdk" ) // errUnknownSubcommand reports a first argument naming no subcommand. @@ -37,21 +40,27 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() _ = godotenv.Load() - if err := dispatch(ctx, os.Args[1:]); err != nil { + if err := dispatch(ctx, os.Args[1:], registerPlugins); err != nil { fmt.Fprintln(os.Stderr, "alphone:", err) os.Exit(1) } } // dispatch runs the subcommand named by the first argument, or the server. -func dispatch(ctx context.Context, args []string) error { +func dispatch(ctx context.Context, args []string, plugins func(sdk.Deps) ([]sdk.Plugin, error)) error { if len(args) == 0 { - return run(ctx, os.Getenv, os.Stderr, registerPlugins) + return run(ctx, os.Getenv, os.Stderr, plugins) } switch args[0] { case "createadmin": + if err := declarePluginRoles(role.Default, os.Getenv, plugins); err != nil { + return err + } return createAdmin(ctx, os.Getenv, args[1:], os.Stdin, os.Stdout) case "grantrole": + if err := declarePluginRoles(role.Default, os.Getenv, plugins); err != nil { + return err + } return grantRole(ctx, os.Getenv, args[1:], os.Stdout) case "token": return token(ctx, os.Getenv, args[1:], os.Stdout) diff --git a/cmd/alphone/main_test.go b/cmd/alphone/main_test.go index 4caf0f3a..d66ed224 100644 --- a/cmd/alphone/main_test.go +++ b/cmd/alphone/main_test.go @@ -198,7 +198,7 @@ func waitForServer(t *testing.T, baseURL string) { func TestDispatchRefusesAnUnknownSubcommand(t *testing.T) { t.Parallel() - err := dispatch(t.Context(), []string{"not-a-subcommand"}) + err := dispatch(t.Context(), []string{"not-a-subcommand"}, registerPlugins) if !errors.Is(err, errUnknownSubcommand) { t.Fatalf("dispatch() error = %v, want the unknown subcommand refused", err) @@ -227,7 +227,7 @@ func TestDispatchAnswersHelp(t *testing.T) { t.Parallel() for _, arg := range []string{"help", "-h", "--help"} { - if err := dispatch(t.Context(), []string{arg}); err != nil { + if err := dispatch(t.Context(), []string{arg}, registerPlugins); err != nil { t.Errorf("dispatch(%q) error = %v, want nil", arg, err) } } diff --git a/cmd/alphone/roles_test.go b/cmd/alphone/roles_test.go index de9ba76a..d9ace81e 100644 --- a/cmd/alphone/roles_test.go +++ b/cmd/alphone/roles_test.go @@ -7,6 +7,8 @@ import ( "errors" "io" "slices" + "strings" + "sync/atomic" "testing" "github.com/gopherium/alphone/internal/role" @@ -39,6 +41,86 @@ func (p rolePlugin) Roles() []sdk.RoleDeclaration { return p.declared } +// stoppingPlugin records whether the host stopped it. +type stoppingPlugin struct { + silentPlugin + stopped *atomic.Bool +} + +// Stop records that the host stopped the plugin. +func (p stoppingPlugin) Stop(context.Context) error { + p.stopped.Store(true) + return nil +} + +func TestRunStopsThePluginHostWhenTheGraphCannotCompose(t *testing.T) { + t.Parallel() + + var stopped atomic.Bool + standing := func(sdk.Deps) ([]sdk.Plugin, error) { + return []sdk.Plugin{stoppingPlugin{stopped: &stopped}}, nil + } + + err := run(t.Context(), testGetenv(map[string]string{ + "ALPHONE_DATABASE_URL": testDatabaseURL(t), + }), io.Discard, standing) + + if err == nil || !strings.Contains(err.Error(), "compose graph root") { + t.Fatalf("run() error = %v, want the compose graph root failure", err) + } + if !stopped.Load() { + t.Error("the plugin host was left running, want it stopped before the failure returns") + } +} + +func TestDeclaringPluginRolesTeachesTheRegistryBeforeACommandParsesOne(t *testing.T) { + t.Parallel() + + registry := role.NewRegistry() + declaring := func(sdk.Deps) ([]sdk.Plugin, error) { + return []sdk.Plugin{ + rolePlugin{declared: []sdk.RoleDeclaration{ + {Name: "steward", Capabilities: []string{"manage_users"}}, + }}, + }, nil + } + + if err := declarePluginRoles(registry, testGetenv(nil), declaring); err != nil { + t.Fatalf("declarePluginRoles() error = %v, want nil", err) + } + + if _, err := registry.Parse("steward"); err != nil { + t.Errorf("Parse(steward) error = %v, want a command able to name a declared role", err) + } +} + +func TestEveryRoleWritingSubcommandRefusesAPluginItCannotRegister(t *testing.T) { + t.Parallel() + + failing := func(sdk.Deps) ([]sdk.Plugin, error) { return nil, errPluginMigrate } + + for _, name := range []string{"createadmin", "grantrole"} { + err := dispatch(t.Context(), []string{name, "-role", "admin"}, failing) + + if !errors.Is(err, errPluginMigrate) { + t.Errorf("dispatch(%q) error = %v, want the registrar failure refused before any role is parsed", + name, err) + } + } +} + +func TestDeclaringPluginRolesReportsARegistrarThatFails(t *testing.T) { + t.Parallel() + + failing := func(sdk.Deps) ([]sdk.Plugin, error) { return nil, errPluginMigrate } + + err := declarePluginRoles(role.NewRegistry(), testGetenv(nil), failing) + + if !errors.Is(err, errPluginMigrate) { + t.Errorf("declarePluginRoles() error = %v, want the registrar failure reported", err) + } +} + func TestDeclareRolesGrantsEveryDeclarationAPluginMakes(t *testing.T) { t.Parallel() diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index dcb89d4d..413a3d45 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -108,6 +108,7 @@ func run( LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}), }, registered) if err != nil { + _ = host.Stop(ctx) return fmt.Errorf("compose graph root: %w", err) } @@ -163,6 +164,22 @@ func adminConfig(store *authkitpg.UserStore) authkit.AdminConfig { return authkit.AdminConfig{Store: store, Privileged: role.Privileged()} } +// declarePluginRoles registers the plugins and grants the registry every role they declare. +func declarePluginRoles( + registry *role.Registry, + getenv func(string) string, + plugins func(sdk.Deps) ([]sdk.Plugin, error), +) error { + registered, err := plugins(sdk.Deps{ + DatabaseURL: getenv("ALPHONE_DATABASE_URL"), + Getenv: getenv, + }) + if err != nil { + return fmt.Errorf("register plugins: %w", err) + } + return declareRoles(registry, registered) +} + // declareRoles grants the registry every role a registered plugin declares. func declareRoles(registry *role.Registry, registered []sdk.Plugin) error { for _, plugin := range registered { From c4903f749a7c69e72d7aed6d7841d3615164648b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:51:32 +0200 Subject: [PATCH 31/47] feat(graphres): name the capability a refused caller lacked --- internal/graphres/scope.go | 14 +++++++++++++- internal/graphres/scope_test.go | 3 +++ internal/graphres/scopegate_test.go | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/graphres/scope.go b/internal/graphres/scope.go index 10092b08..e7823d45 100644 --- a/internal/graphres/scope.go +++ b/internal/graphres/scope.go @@ -153,7 +153,7 @@ func ScopeGate(scopes ScopeMap) graphql.OperationMiddleware { return scopeRefusal(scopes.Needed(kind, selected.Name)) } if needed := scopes.Capability(kind, selected.Name); needed != "" && !role.Can(tier, needed) { - return refusal("admin required", scopes.Needed(kind, selected.Name)) + return capabilityRefusal(scopes.Needed(kind, selected.Name), needed) } } return next(ctx) @@ -165,6 +165,18 @@ func scopeRefusal(needed string) graphql.ResponseHandler { return refusal("scope required: "+needed, needed) } +// capabilityRefusal answers one operation naming the scope and the capability the caller's role lacked. +func capabilityRefusal(needed string, lacked role.Capability) graphql.ResponseHandler { + return graphql.OneShot(&graphql.Response{Errors: gqlerror.List{&gqlerror.Error{ + Message: "admin required", + Extensions: map[string]any{ + "code": "UNAUTHORIZED", + "scope": needed, + "capability": string(lacked), + }, + }}}) +} + // refusal answers one operation with the message and the scope the refused field wanted. func refusal(message, needed string) graphql.ResponseHandler { return graphql.OneShot(&graphql.Response{Errors: gqlerror.List{&gqlerror.Error{ diff --git a/internal/graphres/scope_test.go b/internal/graphres/scope_test.go index 0d8ab590..63cda3bb 100644 --- a/internal/graphres/scope_test.go +++ b/internal/graphres/scope_test.go @@ -134,6 +134,9 @@ func TestScopeMapReadsTheCapabilityAFieldDeclares(t *testing.T) { if got := scopes.Capability(ast.Mutation, "setUserRole"); got != role.ManageUsers { t.Errorf("setUserRole needs %q, want %q", got, role.ManageUsers) } + if got := scopes.Capability(ast.Mutation, "setUserDisabled"); got != role.ManageUsers { + t.Errorf("setUserDisabled needs %q, want a bare admin flag read as %q", got, role.ManageUsers) + } if got := scopes.Capability(ast.Mutation, "createContact"); got != "" { t.Errorf("createContact needs %q, want no capability of an unmarked field", got) } diff --git a/internal/graphres/scopegate_test.go b/internal/graphres/scopegate_test.go index e4742c0b..e2f84ed9 100644 --- a/internal/graphres/scopegate_test.go +++ b/internal/graphres/scopegate_test.go @@ -108,6 +108,29 @@ func TestScopeGateRefusesAnAdminFieldToAMemberSession(t *testing.T) { if got, want := answered.Errors[0].Extensions["scope"], "users:write"; got != want { t.Errorf("scope = %v, want %q, a caller learns what the field wanted", got, want) } + if got, want := answered.Errors[0].Extensions["capability"], "manage_users"; got != want { + t.Errorf("capability = %v, want %q, a caller learns what its role lacked", got, want) + } +} + +func TestScopeGateNamesTheCapabilityARoleLacks(t *testing.T) { + t.Parallel() + + answered := gatedAsRole(t, `mutation { needsReports }`, role.Admin) + + if got, want := answered.Errors[0].Extensions["capability"], "manage_reports"; got != want { + t.Errorf("capability = %v, want %q", got, want) + } +} + +func TestAScopeRefusalNamesNoCapability(t *testing.T) { + t.Parallel() + + answered := gatedAsToken(t, `mutation { createContact }`, apitoken.ParseScopes("tasks:read")) + + if _, named := answered.Errors[0].Extensions["capability"]; named { + t.Error("a scope refusal named a capability, want the extension only where a role fell short") + } } func TestScopeGateRefusesEveryUserManagementFieldToAMember(t *testing.T) { From 2cdc0309fa88d610cae201abaa1af3f265d8829b Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:51:40 +0200 Subject: [PATCH 32/47] test: pin the spoken refusals and the privileged boundary --- internal/graphres/errors_test.go | 20 ++++++++++++++++++++ internal/graphres/roles_test.go | 22 ++++++++++++++++++++++ internal/server/graphql_test.go | 9 +++++++-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/internal/graphres/errors_test.go b/internal/graphres/errors_test.go index 178bb452..b9eb3a9d 100644 --- a/internal/graphres/errors_test.go +++ b/internal/graphres/errors_test.go @@ -99,6 +99,26 @@ func TestPresentErrorSpeaksTheBrickRefusalsInItsOwnVoice(t *testing.T) { } } +func TestEverySpokenRefusalKeepsItsMessage(t *testing.T) { + t.Parallel() + + for _, spoken := range []error{ + authkit.ErrSelfRole, + authkit.ErrSelfDisable, + gouncer.ErrLastPrivileged, + role.ErrBeyondReach, + } { + presented := graphres.PresentError(context.Background(), spoken) + + if presented.Message == spoken.Error() { + t.Errorf("message = %q, want the deployment's own words", presented.Message) + } + if presented.Message == "internal error" { + t.Errorf("%v was masked, want it classified so the spoken message survives", spoken) + } + } +} + func TestPresentErrorCarriesTheConflictOwner(t *testing.T) { t.Parallel() diff --git a/internal/graphres/roles_test.go b/internal/graphres/roles_test.go index 7b52d9c3..5ef6119f 100644 --- a/internal/graphres/roles_test.go +++ b/internal/graphres/roles_test.go @@ -242,6 +242,28 @@ func TestMeAnswersTheCapabilitiesTheRoleHolds(t *testing.T) { } } +func TestMeAnswersNothingForAMember(t *testing.T) { + t.Parallel() + + resolver, held := newRoledResolver(t, role.Member) + client := newActingClient(t, resolver, authkit.Identity{ID: held.ID, Role: held.Role}) + + var answered struct { + Me struct { + Capabilities []string `json:"capabilities"` + Grantable []string `json:"grantable"` + } `json:"me"` + } + client.MustPost(`{ me { capabilities grantable } }`, &answered) + + if len(answered.Me.Capabilities) != 0 { + t.Errorf("capabilities = %v, want none for a member", answered.Me.Capabilities) + } + if !slices.Equal(answered.Me.Grantable, []string{role.Member.String()}) { + t.Errorf("grantable = %v, want a member able to grant only its own role", answered.Me.Grantable) + } +} + func TestMeAnswersNothingForAnAccountHoldingNoRole(t *testing.T) { t.Parallel() diff --git a/internal/server/graphql_test.go b/internal/server/graphql_test.go index e4e28840..67a249ce 100644 --- a/internal/server/graphql_test.go +++ b/internal/server/graphql_test.go @@ -17,6 +17,7 @@ import ( "github.com/gopherium/alphone/internal/event" "github.com/gopherium/alphone/internal/graphres" "github.com/gopherium/alphone/internal/graphroot" + "github.com/gopherium/alphone/internal/role" "github.com/gopherium/alphone/internal/server" "github.com/gopherium/alphone/sdk" ) @@ -47,8 +48,12 @@ func newGraphServer(t *testing.T, cfg graphConfig) http.Handler { // newSubscribingGraphServer returns a graph server whose subscriptions read hub. func newSubscribingGraphServer(t *testing.T, cfg graphConfig, hub *event.Hub) http.Handler { t.Helper() - auth := authkit.New(authkit.Config{Store: cfg.Users, CookieName: server.SessionCookieName}) - admin := authkit.NewAdmin(authkit.AdminConfig{Store: cfg.Users}) + auth := authkit.New(authkit.Config{ + Store: cfg.Users, + CookieName: server.SessionCookieName, + Privileged: role.Privileged(), + }) + admin := authkit.NewAdmin(authkit.AdminConfig{Store: cfg.Users, Privileged: role.Privileged()}) 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) From ad2e5da7abf53ef1efe931774ab060377577b8b5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:51:48 +0200 Subject: [PATCH 33/47] fix(frontend): refuse a role change while one is in flight --- frontend/src/test/users-route.test.tsx | 26 +++++++++++++++++++++++++- frontend/src/users/UsersScreen.tsx | 1 + sdk/frontend/testing.tsx | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/frontend/src/test/users-route.test.tsx b/frontend/src/test/users-route.test.tsx index d57b6422..4e232913 100644 --- a/frontend/src/test/users-route.test.tsx +++ b/frontend/src/test/users-route.test.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { HttpResponse, graphql, memberSession, server } from '@alphone/frontend-sdk/testing' +import { HttpResponse, delay, graphql, memberSession, server } from '@alphone/frontend-sdk/testing' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, expect, test } from 'vitest' @@ -170,6 +170,30 @@ test('writes the role the reader picked for another account', async () => { await waitFor(() => expect(asked).toEqual({ role: 'admin' })) }) +test('refuses a second role while the first write is in flight', async () => { + let asked = 0 + server.use( + graphql.query('Users', () => + HttpResponse.json({ data: { users: [{ ...userNode(colleague, false), role: 'member' }] } }), + ), + graphql.mutation('SetUserRole', async () => { + asked += 1 + await delay(50) + return HttpResponse.json({ data: { setUserRole: true } }) + }), + ) + renderAt('/users') + + await userEvent.click(await screen.findByRole('combobox', { name: 'Role of Ada Lovelace' })) + await userEvent.click(await screen.findByRole('option', { name: 'Admin' })) + + const control = await screen.findByRole('combobox', { name: 'Role of Ada Lovelace' }) + expect(control).toBeDisabled() + await userEvent.click(control) + expect(screen.queryByRole('option')).toBeNull() + await waitFor(() => expect(asked).toBe(1)) +}) + test('offers only the roles the reader may grant', async () => { server.use( graphql.query('Users', () => diff --git a/frontend/src/users/UsersScreen.tsx b/frontend/src/users/UsersScreen.tsx index 739b9a54..e45b5887 100644 --- a/frontend/src/users/UsersScreen.tsx +++ b/frontend/src/users/UsersScreen.tsx @@ -127,6 +127,7 @@ function UserRole({ user, grantable }: { user: Account; grantable: string[] }) { item?.value != null && restand.mutate(item.value)} diff --git a/sdk/frontend/testing.tsx b/sdk/frontend/testing.tsx index 675a5f60..f64fb45b 100644 --- a/sdk/frontend/testing.tsx +++ b/sdk/frontend/testing.tsx @@ -27,7 +27,7 @@ import { GraphProvider } from './GraphProvider' import type { FrontendPlugin } from './index' export { HttpResponse, http, seedSession, server } from '@gopherium/react-auth/testing' -export { graphql } from 'msw' +export { delay, graphql } from 'msw' /** adminSession is the canned signed-in account holding the admin role. */ export const adminSession = { From c9e8d6fa5402bf21c152fd7cb9860f370083b862 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:51:56 +0200 Subject: [PATCH 34/47] docs(sdk): say what a role provider returns --- sdk/sdk.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/sdk.go b/sdk/sdk.go index bb0466da..3aed0e5a 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -71,6 +71,7 @@ type RoleDeclaration struct { // 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() []RoleDeclaration } From 13476ed9c16e9cfdf225dc07da190741d3395b3d Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 00:52:03 +0200 Subject: [PATCH 35/47] docs: name the capability extension and restore every role --- .../src/content/docs/reference/graphql-api.md | 13 ++++++++++--- .../docs/self-hosting/updates-and-backups.md | 19 ++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index b79641e3..97fb6f66 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -122,7 +122,11 @@ Three operations need the `manage_users` capability: `createUser`, "errors": [ { "message": "admin required", - "extensions": { "code": "UNAUTHORIZED", "scope": "users:write" } + "extensions": { + "code": "UNAUTHORIZED", + "scope": "users:write", + "capability": "manage_users" + } } ], "data": null @@ -130,8 +134,11 @@ Three operations need the `manage_users` capability: `createUser`, ``` The `scope` extension still names what the field wanted, so a caller always -learns which area an operation acts in. Minting a wider token does not help -here. A token cannot carry more authority than the user it acts as. +learns which area an operation acts in, and `capability` names what the +account's role fell short of. A refusal about a token's scopes carries no +`capability`, so the two halves stay distinguishable. Minting a wider token +does not help here. A token cannot carry more authority than the user it acts +as. Listing users stays open to members. A member sees who its colleagues are, which is what assigning a task to one of them needs. diff --git a/docs/src/content/docs/self-hosting/updates-and-backups.md b/docs/src/content/docs/self-hosting/updates-and-backups.md index 6c2c4f46..2be167a8 100644 --- a/docs/src/content/docs/self-hosting/updates-and-backups.md +++ b/docs/src/content/docs/self-hosting/updates-and-backups.md @@ -65,19 +65,24 @@ it on the way down, and every promotion and demotion goes with it. Save the roles first: ```sh -docker compose exec -T postgres psql -U alphone alphone -tAc \ - "SELECT email || ',' || role FROM auth.users" > roles.csv +docker compose exec -T postgres \ + pg_dump -U alphone --data-only --no-owner --table=auth.users alphone > roles.sql ``` -Put them back once the column exists again: +That keeps every role exactly as stored, including any a plugin declared. Put +them back once the column exists again, by loading the dump into a scratch +table and copying the column across: ```sh -while IFS=, read -r email held; do - docker compose exec -T postgres psql -U alphone alphone \ - -c "UPDATE auth.users SET role = '$held' WHERE email = '$email'" -done < roles.csv +docker compose exec -T postgres psql -U alphone alphone \ + -c 'CREATE TEMP TABLE restored (LIKE auth.users)' \ + -c "\\copy restored FROM PROGRAM 'cat roles.sql'" \ + -c 'UPDATE auth.users u SET role = r.role FROM restored r WHERE r.id = u.id' ``` +Taking a full `pg_dump` before any rollback is simpler still, and it is what the +backup section below sets up anyway. + Accounts that end up holding no role can do nothing until they are given one. `alphone grantrole -role member` gives a role to every account holding none, and says how many it changed. It leaves the From bf61d95760a70b93165515ad1c90930c5e9b19b5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 01:09:23 +0200 Subject: [PATCH 36/47] fix(graphres): read a null capability as none declared --- graph/scope_test.go | 10 ++++++++++ internal/graphres/scope.go | 3 ++- internal/graphres/scope_test.go | 23 +++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/graph/scope_test.go b/graph/scope_test.go index 09d3c0a4..13591c10 100644 --- a/graph/scope_test.go +++ b/graph/scope_test.go @@ -238,6 +238,16 @@ func TestScopeCheckingFlagsACapabilityTheTableDoesNotKnow(t *testing.T) { } } +func TestScopeCheckingFlagsANullCapability(t *testing.T) { + t.Parallel() + + synthetic := `type Mutation { one: String! @scope(area: "users", write: true, capability: null) }` + + if got := scopeProblemsIn(t, "synthetic", synthetic); len(got) == 0 { + t.Error("a null capability raised no problem, want a field naming one the table knows") + } +} + func TestScopeCheckingRefusesTheBareAdminFlag(t *testing.T) { t.Parallel() diff --git a/internal/graphres/scope.go b/internal/graphres/scope.go index e7823d45..12a72d38 100644 --- a/internal/graphres/scope.go +++ b/internal/graphres/scope.go @@ -84,7 +84,8 @@ func scopeOf(declared *ast.Directive) fieldScope { if adminOnly := declared.Arguments.ForName(adminArgument); adminOnly != nil { scope.admin = adminOnly.Value.Raw == "true" } - if needed := declared.Arguments.ForName(capabilityArgument); needed != nil { + if needed := declared.Arguments.ForName(capabilityArgument); needed != nil && + needed.Value.Kind != ast.NullValue { scope.capability = role.Capability(needed.Value.Raw) } return scope diff --git a/internal/graphres/scope_test.go b/internal/graphres/scope_test.go index 63cda3bb..6449dbc7 100644 --- a/internal/graphres/scope_test.go +++ b/internal/graphres/scope_test.go @@ -145,6 +145,29 @@ func TestScopeMapReadsTheCapabilityAFieldDeclares(t *testing.T) { } } +func TestScopeMapReadsANullCapabilityAsNoneDeclared(t *testing.T) { + t.Parallel() + + schema, err := gqlparser.LoadSchema(&ast.Source{Name: "null", Input: ` +directive @scope(area: String!, write: Boolean!, admin: Boolean = false, capability: String) on FIELD_DEFINITION +type Mutation { + bare: String! @scope(area: "users", write: true, capability: null) + flagged: String! @scope(area: "users", write: true, admin: true, capability: null) +} +`}) + if err != nil { + t.Fatalf("loading the schema: %v", err) + } + scopes := graphres.NewScopeMap(schema) + + if got := scopes.Capability(ast.Mutation, "bare"); got != "" { + t.Errorf("bare needs %q, want none, a null names no capability", got) + } + if got := scopes.Capability(ast.Mutation, "flagged"); got != role.ManageUsers { + t.Errorf("flagged needs %q, want the admin flag to still stand in", got) + } +} + func TestScopeMapReadsTheAdminFlagAFieldDeclares(t *testing.T) { t.Parallel() From 448174bd14e85b13ed8092fed7a20432d472d1c9 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 01:09:43 +0200 Subject: [PATCH 37/47] fix(cmd): stop the plugin host under its own bounded context --- cmd/alphone/run.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index 413a3d45..a1cf93e8 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -108,8 +108,9 @@ func run( LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}), }, registered) if err != nil { - _ = host.Stop(ctx) - return fmt.Errorf("compose graph root: %w", err) + stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return errors.Join(fmt.Errorf("compose graph root: %w", err), host.Stop(stopCtx)) } cfg := server.Config{ From c075052932199fd9e6a76b663d8f8ba392e44493 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 01:09:49 +0200 Subject: [PATCH 38/47] docs: stream the roles through psql rather than into the container --- .../docs/self-hosting/updates-and-backups.md | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/self-hosting/updates-and-backups.md b/docs/src/content/docs/self-hosting/updates-and-backups.md index 2be167a8..826a2ccc 100644 --- a/docs/src/content/docs/self-hosting/updates-and-backups.md +++ b/docs/src/content/docs/self-hosting/updates-and-backups.md @@ -65,23 +65,25 @@ it on the way down, and every promotion and demotion goes with it. Save the roles first: ```sh -docker compose exec -T postgres \ - pg_dump -U alphone --data-only --no-owner --table=auth.users alphone > roles.sql +docker compose exec -T postgres psql -U alphone alphone \ + -c "\\copy (SELECT id, role FROM auth.users) TO STDOUT WITH (FORMAT csv)" > roles.csv ``` -That keeps every role exactly as stored, including any a plugin declared. Put -them back once the column exists again, by loading the dump into a scratch -table and copying the column across: +That keeps every role exactly as stored, including any a plugin declared, and +CSV quoting handles whatever the values contain. Put them back once the column +exists again, reading the file on the machine you run the command from: ```sh docker compose exec -T postgres psql -U alphone alphone \ - -c 'CREATE TEMP TABLE restored (LIKE auth.users)' \ - -c "\\copy restored FROM PROGRAM 'cat roles.sql'" \ - -c 'UPDATE auth.users u SET role = r.role FROM restored r WHERE r.id = u.id' + -c 'CREATE TEMP TABLE restored (id uuid PRIMARY KEY, role text NOT NULL)' \ + -c "\\copy restored (id, role) FROM STDIN WITH (FORMAT csv)" \ + -c 'UPDATE auth.users u SET role = r.role FROM restored r WHERE r.id = u.id' \ + < roles.csv ``` -Taking a full `pg_dump` before any rollback is simpler still, and it is what the -backup section below sets up anyway. +Both commands stream through `psql`, so the file never has to exist inside the +container. Taking a full `pg_dump` before any rollback is simpler still, and it +is what the backup section below sets up anyway. Accounts that end up holding no role can do nothing until they are given one. `alphone grantrole -role member` gives a role to every From 342ae3c0544ce3fb29fb220561fd26e4871620b5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 01:09:55 +0200 Subject: [PATCH 39/47] docs: say an account may hold no role at all --- docs/src/content/docs/reference/graphql-api.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index 97fb6f66..e3ec3a2f 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -97,10 +97,15 @@ Always read `errors`. A 200 does not mean it worked. ## Roles -Every account holds one role, and the role decides what the account may do. A -stock deployment names two. An admin manages users. A member works the product, -which is contacts, tasks, and whatever your plugins add. A plugin may declare -roles of its own, so do not assume the list stops at two. +An account holds one role, and the role decides what the account may do. A stock +deployment names two. An admin manages users. A member works the product, which +is contacts, tasks, and whatever your plugins add. A plugin may declare roles of +its own, so do not assume the list stops at two. + +An account can also hold no role at all, which happens to accounts made before +roles existed. Such an account holds no capability, so it can do nothing until +somebody gives it a role. `me` answers an empty `role` and an empty +`capabilities` for it. What a role may do is a set of named capabilities. `me` answers the ones the calling account holds, so a client asks what it may do rather than guessing from From 931453bdd68ec1844ae63963d190c43c0fa583aa Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 01:23:11 +0200 Subject: [PATCH 40/47] test(graph): let a plugin name the capabilities it declares --- graph/scope_test.go | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/graph/scope_test.go b/graph/scope_test.go index 13591c10..a7e80e9e 100644 --- a/graph/scope_test.go +++ b/graph/scope_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "github.com/vektah/gqlparser/v2/ast" @@ -62,15 +63,20 @@ func rootFieldScopes(t *testing.T, name, source string) (int, []string) { continue } for _, field := range def.Fields { - problems = append(problems, scopeProblems(def.Name, field)...) + problems = append(problems, scopeProblems(def.Name, field, ownsItsCapabilities(name))...) counted++ } } return counted, problems } +// ownsItsCapabilities reports whether the SDL belongs to a plugin declaring its own capabilities. +func ownsItsCapabilities(name string) bool { + return strings.HasPrefix(name, "../plugins/") || strings.HasPrefix(name, "../enterprise/") +} + // scopeProblems reports how one root field's scope declaration falls short. -func scopeProblems(operation string, field *ast.FieldDefinition) []string { +func scopeProblems(operation string, field *ast.FieldDefinition, ownsCapabilities bool) []string { declared := field.Directives.ForName(scopeDirective) if declared == nil { return []string{fmt.Sprintf( @@ -81,12 +87,17 @@ func scopeProblems(operation string, field *ast.FieldDefinition) []string { problems = append(problems, fmt.Sprintf("%s.%s declares an empty area", operation, field.Name)) } problems = append(problems, accessProblems(operation, field, declared)...) - problems = append(problems, capabilityProblems(operation, field, declared)...) + problems = append(problems, capabilityProblems(operation, field, declared, ownsCapabilities)...) return problems } // capabilityProblems reports how one root field's capability declaration falls short. -func capabilityProblems(operation string, field *ast.FieldDefinition, declared *ast.Directive) []string { +func capabilityProblems( + operation string, + field *ast.FieldDefinition, + declared *ast.Directive, + ownsCapabilities bool, +) []string { if admin := declared.Arguments.ForName("admin"); admin != nil && admin.Value.Raw == "true" { return []string{fmt.Sprintf( "%s.%s reserves itself with admin: true, want a capability the role table knows", @@ -96,6 +107,12 @@ func capabilityProblems(operation string, field *ast.FieldDefinition, declared * if needed == nil { return nil } + if needed.Value.Kind == ast.NullValue || needed.Value.Raw == "" { + return []string{fmt.Sprintf("%s.%s declares an empty capability", operation, field.Name)} + } + if ownsCapabilities { + return nil + } if !slices.Contains(role.Capabilities(), role.Capability(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)} @@ -238,6 +255,16 @@ func TestScopeCheckingFlagsACapabilityTheTableDoesNotKnow(t *testing.T) { } } +func TestScopeCheckingAcceptsACapabilityAPluginOwns(t *testing.T) { + t.Parallel() + + synthetic := `type Mutation { one: String! @scope(area: "tenants", write: true, capability: "manage_tenants") }` + + if got := scopeProblemsIn(t, "../enterprise/tenancy/graph/schema.graphqls", synthetic); len(got) != 0 { + t.Errorf("problems = %v, want a plugin free to name a capability it declares itself", got) + } +} + func TestScopeCheckingFlagsANullCapability(t *testing.T) { t.Parallel() From 86af0e0dabd1e5c657dbd626b09170d63c7e49e2 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 01:23:17 +0200 Subject: [PATCH 41/47] docs: state the role rules without promising an invariant --- docs/src/content/docs/reference/graphql-api.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index e3ec3a2f..c8f828e1 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -150,13 +150,17 @@ which is what assigning a task to one of them needs. Nobody changes its own role, and nobody disables its own account. Both are refused with code `VALIDATION` and the messages `you cannot change your own -role` and `you cannot disable your own account`. Together they keep a -deployment from losing its last admin, since an admin can only ever demote -somebody else, and there is always itself left holding the authority. +role` and `you cannot disable your own account`. Writing a role the caller does not hold itself is refused the same way, with -`that role is beyond your own`. So an admin can neither grant a role reaching -further than admin nor touch an account already holding one. +`that role is beyond your own`. A caller may only write a role whose +capabilities it already holds, and may only touch an account whose current role +it likewise holds. So an admin can neither grant a role reaching further than +admin nor demote or disable an account already holding one, whether that role +came with the product or with a plugin. + +A write that would leave no enabled account able to manage users is refused with +`the last admin cannot be unseated`. `createUser` takes an optional `role`. Leaving it out starts the account at the narrowest role the deployment names, which is `member` in a stock install. From 65fd2180984e7ccde909958513c3b60e99af9cfc Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 01:23:24 +0200 Subject: [PATCH 42/47] docs: stop the role export on the first error --- .../docs/self-hosting/updates-and-backups.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/self-hosting/updates-and-backups.md b/docs/src/content/docs/self-hosting/updates-and-backups.md index 826a2ccc..8abf0915 100644 --- a/docs/src/content/docs/self-hosting/updates-and-backups.md +++ b/docs/src/content/docs/self-hosting/updates-and-backups.md @@ -65,16 +65,19 @@ it on the way down, and every promotion and demotion goes with it. Save the roles first: ```sh -docker compose exec -T postgres psql -U alphone alphone \ +docker compose exec -T postgres psql -U alphone alphone -v ON_ERROR_STOP=1 \ -c "\\copy (SELECT id, role FROM auth.users) TO STDOUT WITH (FORMAT csv)" > roles.csv ``` -That keeps every role exactly as stored, including any a plugin declared, and -CSV quoting handles whatever the values contain. Put them back once the column -exists again, reading the file on the machine you run the command from: +That keeps every role exactly as stored, including any role a plugin declared, +and CSV quoting handles whatever the values contain. `ON_ERROR_STOP=1` matters +in both commands, because without it `psql` carries on after a failed statement +and leaves you an incomplete file that looks like a good one. Put the roles back +once the column exists again, reading the file on the machine you run the +command from: ```sh -docker compose exec -T postgres psql -U alphone alphone \ +docker compose exec -T postgres psql -U alphone alphone -v ON_ERROR_STOP=1 \ -c 'CREATE TEMP TABLE restored (id uuid PRIMARY KEY, role text NOT NULL)' \ -c "\\copy restored (id, role) FROM STDIN WITH (FORMAT csv)" \ -c 'UPDATE auth.users u SET role = r.role FROM restored r WHERE r.id = u.id' \ From 9f98ab87e503e465f6dfe25c308094767b47dc4a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 11:26:05 +0200 Subject: [PATCH 43/47] test(graphres): pin what an account holding no role may reach --- internal/graphres/scopegate_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/graphres/scopegate_test.go b/internal/graphres/scopegate_test.go index e2f84ed9..0f44596b 100644 --- a/internal/graphres/scopegate_test.go +++ b/internal/graphres/scopegate_test.go @@ -195,6 +195,26 @@ func TestScopeGateLetsAMemberSessionWorkTheProduct(t *testing.T) { } } +func TestScopeGateLetsAnAccountHoldingNoRoleWorkTheProduct(t *testing.T) { + t.Parallel() + + answered := gatedAsRole(t, `mutation { createContact createTask }`, "") + + if len(answered.Errors) != 0 { + t.Errorf("errors = %v, want none, no field of the product declares a capability", answered.Errors) + } +} + +func TestScopeGateRefusesUserManagementToAnAccountHoldingNoRole(t *testing.T) { + t.Parallel() + + answered := gatedAsRole(t, `mutation { createUser }`, "") + + if got, want := refusalOf(t, answered), "admin required"; got != want { + t.Errorf("refusal = %q, want %q", got, want) + } +} + func TestScopeGateLetsAMemberSessionManageItsOwnTokens(t *testing.T) { t.Parallel() From f71d6d6555f5c6422a379167859a9e5b1c6b8d46 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 11:26:17 +0200 Subject: [PATCH 44/47] test(graph): recognise a plugin path whichever separator globbed it --- graph/scope_test.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/graph/scope_test.go b/graph/scope_test.go index a7e80e9e..cffb9042 100644 --- a/graph/scope_test.go +++ b/graph/scope_test.go @@ -72,7 +72,8 @@ func rootFieldScopes(t *testing.T, name, source string) (int, []string) { // ownsItsCapabilities reports whether the SDL belongs to a plugin declaring its own capabilities. func ownsItsCapabilities(name string) bool { - return strings.HasPrefix(name, "../plugins/") || strings.HasPrefix(name, "../enterprise/") + slashed := strings.ReplaceAll(name, "\\", "/") + return strings.HasPrefix(slashed, "../plugins/") || strings.HasPrefix(slashed, "../enterprise/") } // scopeProblems reports how one root field's scope declaration falls short. @@ -265,6 +266,16 @@ func TestScopeCheckingAcceptsACapabilityAPluginOwns(t *testing.T) { } } +func TestScopeCheckingAcceptsAPluginPathSeparatedByBackslashes(t *testing.T) { + t.Parallel() + + synthetic := `type Mutation { one: String! @scope(area: "tenants", write: true, capability: "manage_tenants") }` + + if got := scopeProblemsIn(t, `..\enterprise\tenancy\graph\schema.graphqls`, synthetic); len(got) != 0 { + t.Errorf("problems = %v, want a plugin recognised whichever separator the host globs with", got) + } +} + func TestScopeCheckingFlagsANullCapability(t *testing.T) { t.Parallel() From d00c7b3df2ce5f3d2ca337703be0c6e4b0232bc5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 11:26:24 +0200 Subject: [PATCH 45/47] docs: correct what an account holding no role can still do --- .../src/content/docs/reference/graphql-api.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index c8f828e1..71d058a6 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -103,9 +103,11 @@ is contacts, tasks, and whatever your plugins add. A plugin may declare roles of its own, so do not assume the list stops at two. An account can also hold no role at all, which happens to accounts made before -roles existed. Such an account holds no capability, so it can do nothing until -somebody gives it a role. `me` answers an empty `role` and an empty -`capabilities` for it. +roles existed. `me` answers an empty `role` and an empty `capabilities` for it. +Such an account still signs in, still reads `me` and `logout`, and still works +every field that names no capability, which today is the whole product. What it +cannot reach is the fields a capability guards, which is user management. Give +it a role and it gains whatever that role holds. What a role may do is a set of named capabilities. `me` answers the ones the calling account holds, so a client asks what it may do rather than guessing from @@ -138,10 +140,15 @@ Three operations need the `manage_users` capability: `createUser`, } ``` +The message reads `admin required` whichever capability was missing, because it +has said that since before capabilities existed and clients match on it. Read +`capability` rather than the message to learn what the account's role actually +fell short of. Holding the admin role is not what the field asks for, holding +that capability is, and a plugin declared role holding it passes just as well. + The `scope` extension still names what the field wanted, so a caller always -learns which area an operation acts in, and `capability` names what the -account's role fell short of. A refusal about a token's scopes carries no -`capability`, so the two halves stay distinguishable. Minting a wider token +learns which area an operation acts in. A refusal about a token's scopes carries +no `capability`, so the two halves stay distinguishable. Minting a wider token does not help here. A token cannot carry more authority than the user it acts as. From f8c8c1e110a3a912aa7ae601bce49f84c2d4fb69 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 11:26:38 +0200 Subject: [PATCH 46/47] docs: keep a failed role export from replacing a good file --- .../docs/self-hosting/updates-and-backups.md | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/src/content/docs/self-hosting/updates-and-backups.md b/docs/src/content/docs/self-hosting/updates-and-backups.md index 8abf0915..aa20b25a 100644 --- a/docs/src/content/docs/self-hosting/updates-and-backups.md +++ b/docs/src/content/docs/self-hosting/updates-and-backups.md @@ -66,13 +66,18 @@ Save the roles first: ```sh docker compose exec -T postgres psql -U alphone alphone -v ON_ERROR_STOP=1 \ - -c "\\copy (SELECT id, role FROM auth.users) TO STDOUT WITH (FORMAT csv)" > roles.csv + -c "\\copy (SELECT id, role FROM auth.users) TO STDOUT WITH (FORMAT csv)" \ + > roles.csv.part && mv roles.csv.part roles.csv ``` That keeps every role exactly as stored, including any role a plugin declared, and CSV quoting handles whatever the values contain. `ON_ERROR_STOP=1` matters in both commands, because without it `psql` carries on after a failed statement -and leaves you an incomplete file that looks like a good one. Put the roles back +and leaves you an incomplete file that looks like a good one. The export writes +`roles.csv.part` and renames it only once the command succeeds, because your +shell creates the file it redirects into before `psql` even starts, so a failure +halfway through would otherwise hand you a truncated `roles.csv` in place of the +one you were counting on. Put the roles back once the column exists again, reading the file on the machine you run the command from: @@ -88,11 +93,14 @@ Both commands stream through `psql`, so the file never has to exist inside the container. Taking a full `pg_dump` before any rollback is simpler still, and it is what the backup section below sets up anyway. -Accounts that end up holding no role can do nothing until they are -given one. `alphone grantrole -role member` gives a role to every -account holding none, and says how many it changed. It leaves the -accounts that already hold one alone, so running it twice changes -nothing the second time. +An account that ends up holding no role still works contacts and tasks, +because no field of the product asks for a capability. What it loses is +user management, so a rollback that strips every role can leave nobody +able to promote anyone back. `alphone grantrole -role member` gives a +role to every account holding none, and says how many it changed. It +leaves the accounts that already hold one alone, so running it twice +changes nothing the second time. Choose the role with care, because it +goes to every account holding none rather than to one you pick. ## Backup scenario From 38d34ebd87d5e316ffa65ef0655cbaa2419ba950 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 23 Aug 2026 11:38:35 +0200 Subject: [PATCH 47/47] docs: hyphenate the plugin-declared role compound --- docs/src/content/docs/reference/graphql-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index 71d058a6..fda59546 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -144,7 +144,7 @@ The message reads `admin required` whichever capability was missing, because it has said that since before capabilities existed and clients match on it. Read `capability` rather than the message to learn what the account's role actually fell short of. Holding the admin role is not what the field asks for, holding -that capability is, and a plugin declared role holding it passes just as well. +that capability is, and a plugin-declared role holding it passes just as well. The `scope` extension still names what the field wanted, so a caller always learns which area an operation acts in. A refusal about a token's scopes carries