From 7a50a439bed926b4da61f3edd59732e586d83f46 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 18 Oct 2026 22:58:18 +0200 Subject: [PATCH 1/2] feat(gonsole/auth): build the account roles hook from a role registry --- gonsole/auth/declaredroles.go | 32 +++++ gonsole/auth/declaredroles_test.go | 181 +++++++++++++++++++++++++++++ gonsole/auth/rolesfrom_test.go | 11 ++ 3 files changed, 224 insertions(+) create mode 100644 gonsole/auth/declaredroles.go create mode 100644 gonsole/auth/declaredroles_test.go diff --git a/gonsole/auth/declaredroles.go b/gonsole/auth/declaredroles.go new file mode 100644 index 0000000..7b8f7b6 --- /dev/null +++ b/gonsole/auth/declaredroles.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "fmt" + + "github.com/gopherium/framework/gonsole" +) + +// DeclaredRoles returns the Roles hook answering registry after the plugins, the roles carrying capability privileged. +func DeclaredRoles(registry interface { + Roles() []string + CapabilitiesOf(role string) []string + HoldersOf(capability string) []string +}, capability string) func(ctx context.Context, call gonsole.Call) (Roles, error) { + return func(ctx context.Context, call gonsole.Call) (Roles, error) { + loaded, err := call.Plugins(ctx) + if err != nil { + return Roles{}, err + } + if loaded.Failed != nil { + return Roles{}, fmt.Errorf("register plugins: %w", loaded.Failed) + } + holders := registry.HoldersOf(capability) + if len(holders) == 0 { + return Roles{}, fmt.Errorf("no role carries the capability %q", capability) + } + return RolesFrom(registry, holders), nil + } +} diff --git a/gonsole/auth/declaredroles_test.go b/gonsole/auth/declaredroles_test.go new file mode 100644 index 0000000..c43bf1a --- /dev/null +++ b/gonsole/auth/declaredroles_test.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "context" + "errors" + "fmt" + "maps" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" + "github.com/gopherium/framework/gonsole/testkit" +) + +// pluginsHook is what a program runs to register its plugins. +type pluginsHook = func(ctx context.Context, call gonsole.Call) (gonsole.Loaded, error) + +// reporting returns roles:report, which prints what hook answers for the call. +func reporting(hook func(context.Context, gonsole.Call) (auth.Roles, error)) gonsole.Command { + return gonsole.Command{ + Name: "roles:report", + Summary: "print the roles", + Run: func(ctx context.Context, call gonsole.Call) error { + roles, err := hook(ctx, call) + if err != nil { + return err + } + _, err = fmt.Fprintf(call.Stdout, "known %v, privileged %v, capabilities %v\n", + roles.Known, roles.Privileged, roles.Capabilities) + return err + }, + } +} + +// declaring returns the plugins hook that declares archivist carrying manage_users into table. +func declaring(table registry) pluginsHook { + return func(context.Context, gonsole.Call) (gonsole.Loaded, error) { + table["archivist"] = []string{"manage_users"} + return gonsole.Loaded{}, nil + } +} + +func TestDeclaredRolesAnswersTheRegistryOnceThePluginsRegistered(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + plugins func(table registry) pluginsHook + capability string + want string + }{ + {"a program without plugins", nil, "manage_users", "known [admin author editor], privileged [admin], " + + "capabilities map[admin:[manage_users change_others_work] author:[] editor:[change_others_work]]\n"}, + {"plugins declaring a role", declaring, "manage_users", "known [admin archivist author editor], " + + "privileged [admin archivist], capabilities map[admin:[manage_users change_others_work] " + + "archivist:[manage_users] author:[] editor:[change_others_work]]\n"}, + {"a capability two roles carry", nil, "change_others_work", "known [admin author editor], " + + "privileged [admin editor], " + + "capabilities map[admin:[manage_users change_others_work] author:[] editor:[change_others_work]]\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + table := maps.Clone(capableRegistry) + p := program(unreachable, reporting(auth.DeclaredRoles(table, tt.capability))) + if tt.plugins != nil { + p.Plugins = tt.plugins(table) + } + + got := testkit.Run(t, p, "", "roles:report") + + if want := (testkit.Result{Code: gonsole.ExitDone, Stdout: tt.want}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } + }) + } +} + +func TestDeclaredRolesRefusesPluginsThatFailedToLoad(t *testing.T) { + t.Parallel() + + locked := errors.New("the plugin table is locked") + tests := []struct { + name string + loaded gonsole.Loaded + err error + want string + }{ + {"a plugin that failed to register", gonsole.Loaded{Failed: locked}, nil, + "myapp: register plugins: the plugin table is locked\n"}, + {"a registration that failed", gonsole.Loaded{}, locked, "myapp: the plugin table is locked\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + p := program(unreachable, reporting(auth.DeclaredRoles(maps.Clone(capableRegistry), "manage_users"))) + p.Plugins = func(context.Context, gonsole.Call) (gonsole.Loaded, error) { return tt.loaded, tt.err } + + got := testkit.Run(t, p, "", "roles:report") + + if want := (testkit.Result{Code: gonsole.ExitFailed, Stderr: tt.want}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } + }) + } +} + +func TestDeclaredRolesRefusesACapabilityNoRoleCarries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + plugins func(table registry) pluginsHook + capability string + want string + }{ + {"a capability no role carries", nil, "publish_reports", + "myapp: no role carries the capability \"publish_reports\"\n"}, + {"a capability no plugin role carries either", declaring, "publish_reports", + "myapp: no role carries the capability \"publish_reports\"\n"}, + {"no capability named", nil, "", "myapp: no role carries the capability \"\"\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + table := maps.Clone(capableRegistry) + p := program(unreachable, reporting(auth.DeclaredRoles(table, tt.capability))) + if tt.plugins != nil { + p.Plugins = tt.plugins(table) + } + + got := testkit.Run(t, p, "", "roles:report") + + if want := (testkit.Result{Code: gonsole.ExitFailed, Stderr: tt.want}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } + }) + } +} + +func TestDeclaredRolesKeepsTheLastPrivilegedAccountOfACapabilityNoRoleCarries(t *testing.T) { + t.Parallel() + + address := seeded(t) + p := program(address, auth.SetRole(auth.Config{Roles: auth.DeclaredRoles(maps.Clone(capableRegistry), + "publish_reports"), Stores: memoryStores})) + + got := testkit.Run(t, p, "", "account:role", "admin@example.com", "author", "-yes") + + want := testkit.Result{Code: gonsole.ExitFailed, Stderr: "myapp: no role carries the capability \"publish_reports\"\n"} + if got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } + if held := account(t, storeAt(t, address), "admin@example.com"); held.Role != "admin" { + t.Errorf("role = %q, want admin", held.Role) + } +} + +func TestDeclaredRolesServesAsTheRolesOfTheAccountCommands(t *testing.T) { + t.Parallel() + + address := seeded(t) + table := maps.Clone(capableRegistry) + p := program(address, auth.SetRole(auth.Config{Roles: auth.DeclaredRoles(table, "manage_users"), + Stores: memoryStores})) + p.Plugins = declaring(table) + + got := testkit.Run(t, p, "", "account:role", "editor@example.com", "archivist", "-yes") + + if want := (testkit.Result{Code: gonsole.ExitDone, Stdout: "set editor@example.com to archivist\n"}); got != want { + t.Errorf("Run() = %+v, want %+v", got, want) + } + if held := account(t, storeAt(t, address), "editor@example.com"); held.Role != "archivist" { + t.Errorf("role = %q, want archivist", held.Role) + } +} diff --git a/gonsole/auth/rolesfrom_test.go b/gonsole/auth/rolesfrom_test.go index b4a39db..2415437 100644 --- a/gonsole/auth/rolesfrom_test.go +++ b/gonsole/auth/rolesfrom_test.go @@ -28,6 +28,17 @@ func (r registry) CapabilitiesOf(role string) []string { return slices.Clone(r[role]) } +// HoldersOf returns every role the table holds that carries capability, sorted. +func (r registry) HoldersOf(capability string) []string { + var holders []string + for _, role := range r.Roles() { + if slices.Contains(r[role], capability) { + holders = append(holders, role) + } + } + return holders +} + // capableRegistry holds the table of capable, with author declared carrying nothing. var capableRegistry = registry{ "admin": capable.Capabilities["admin"], From 431e551f8eede3ca6463176eedd0870001c0a0f5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 18 Oct 2026 22:58:19 +0200 Subject: [PATCH 2/2] feat(gonsole/auth): refuse a lost role re-enable and a moved role in the account commands --- gonsole/auth/CHANGELOG.md | 11 ++++ gonsole/auth/checkedwrite_test.go | 76 ++++++++++++++++++++++++++ gonsole/auth/lostrole_test.go | 90 +++++++++++++++++++++++++++++++ gonsole/auth/reach.go | 16 ++++++ gonsole/auth/reach_test.go | 5 ++ gonsole/auth/role.go | 13 ++++- gonsole/auth/standing.go | 9 ++-- 7 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 gonsole/auth/checkedwrite_test.go create mode 100644 gonsole/auth/lostrole_test.go diff --git a/gonsole/auth/CHANGELOG.md b/gonsole/auth/CHANGELOG.md index 0795794..0270f46 100644 --- a/gonsole/auth/CHANGELOG.md +++ b/gonsole/auth/CHANGELOG.md @@ -8,6 +8,17 @@ v0.x, minor releases may contain breaking changes. Releases of this module are tagged `gonsole/auth/vX.Y.Z`. +## [Unreleased] + +### Added + +- `DeclaredRoles`, the `Config.Roles` hook that registers the plugins, then answers a registry's roles, privileged where they carry one capability, and fails when no role carries it. + +### Changed + +- **Breaking.** `account:enable` refuses an account holding no role or one the roles do not declare, until it holds a declared role. +- `account:role`, `account:disable` and `account:enable` fail when the account's role changed after their check. + ## [0.8.0] - 2026-10-13 ### Changed diff --git a/gonsole/auth/checkedwrite_test.go b/gonsole/auth/checkedwrite_test.go new file mode 100644 index 0000000..b46fef1 --- /dev/null +++ b/gonsole/auth/checkedwrite_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "context" + "fmt" + "slices" + "testing" + + "github.com/gopherium/gouncer" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/auth" +) + +// roleChangingStore is an account store that gives the account at email the role right after it reads that account. +type roleChangingStore struct { + auth.Accounts + email, role string +} + +// UserByEmail returns the account at email as read, then gives it the store's role when it is the store's account. +func (s roleChangingStore) UserByEmail(ctx context.Context, email string) (gouncer.User, error) { + held, err := s.Accounts.UserByEmail(ctx, email) + if err != nil || email != s.email { + return held, err + } + return held, s.SetUserRole(ctx, held.ID, s.role, nil) +} + +// withRoleChangedAfterRead returns a program like overseen whose stores give the account at email the role once read. +func withRoleChangedAfterRead(address, email, role string) gonsole.Program { + cfg := reaching() + cfg.Stores = func(ctx context.Context, call gonsole.Call) (auth.Stores, func(context.Context) error, error) { + stores, release, err := memoryStores(ctx, call) + stores.Accounts = roleChangingStore{Accounts: stores.Accounts, email: email, role: role} + return stores, release, err + } + return authorizing(address, cfg, nil, auth.Commands(cfg)...) +} + +func TestAccountWritesRefuseAnAccountWhoseRoleChangedAfterTheCheck(t *testing.T) { + t.Parallel() + + const changed = "myapp: the role of author@example.com changed after the check, run the command again\n" + tests := []struct { + name string + setup []change + args []string + }{ + {"a role change", nil, []string{"account:role", "author@example.com", "manager"}}, + {"a disable", nil, []string{"account:disable", "author@example.com"}}, + {"an enable", []change{disabling("author@example.com")}, []string{"account:enable", "author@example.com"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + address := managed(t) + run(t, address, tt.setup...) + disabled := account(t, storeAt(t, address), "author@example.com").Disabled + + refuses(t, withRoleChangedAfterRead(address, "author@example.com", "admin"), changed, + slices.Concat(tt.args, []string{"-yes"}, asManager)...) + + if held := records(t, address); len(held) != 0 { + t.Errorf("records = %v, want none", held) + } + want := fmt.Sprintf("role %q, disabled %t", "admin", disabled) + if after := standingOf(t, address, "author@example.com"); after != want { + t.Errorf("author@example.com holds %s, want %s, the changed role and the standing kept", after, want) + } + }) + } +} diff --git a/gonsole/auth/lostrole_test.go b/gonsole/auth/lostrole_test.go new file mode 100644 index 0000000..ceabc80 --- /dev/null +++ b/gonsole/auth/lostrole_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "slices" + "testing" + + "github.com/gopherium/framework/gonsole" + "github.com/gopherium/framework/gonsole/testkit" +) + +// unchecked returns a program over the world at address whose account writes name no capability. +func unchecked(address string) gonsole.Program { + var reads rolesRead + return program(address, standingCommands(config(&reads, ""))...) +} + +// asManager is the acting account the overseen runs name. +var asManager = []string{"-as", "manager@example.com"} + +func TestEnableRefusesAnAccountWhoseRoleNobodyDeclares(t *testing.T) { + t.Parallel() + + const ( + undeclared = "the account author@example.com holds the undeclared role guest, " + + "give it a declared role before enabling it" + roleless = "the account author@example.com holds no role, give it a declared role before enabling it" + ) + tests := []struct { + name string + setup []change + over func(address string) gonsole.Program + actor []string + want string + }{ + {"a disabled account under a role the roles leave out", + []change{holding("author@example.com", "guest"), disabling("author@example.com")}, overseen, asManager, + undeclared}, + {"a disabled account with no role", + []change{holding("author@example.com", ""), disabling("author@example.com")}, overseen, asManager, roleless}, + {"an enabled account under a role the roles leave out", []change{holding("author@example.com", "guest")}, + overseen, asManager, undeclared}, + {"a disabled account under a role the roles leave out where no capability is named", + []change{holding("author@example.com", "guest"), disabling("author@example.com")}, unchecked, nil, + undeclared}, + {"a disabled account with no role where no capability is named", + []change{holding("author@example.com", ""), disabling("author@example.com")}, unchecked, nil, roleless}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + address := managed(t) + run(t, address, tt.setup...) + before := standingOf(t, address, "author@example.com") + line := slices.Concat([]string{"account:enable", "author@example.com"}, tt.actor) + + refuses(t, tt.over(address), "myapp: "+tt.want+"\n", line...) + refuses(t, tt.over(address), "myapp: "+tt.want+"\n", append(line, "-yes")...) + + if held := records(t, address); len(held) != 0 { + t.Errorf("records = %v, want none", held) + } + if after := standingOf(t, address, "author@example.com"); after != before { + t.Errorf("author@example.com holds %s, want %s kept", after, before) + } + }) + } +} + +func TestEnableLetsAnAccountBackOnceItHoldsADeclaredRole(t *testing.T) { + t.Parallel() + + address := managed(t) + run(t, address, holding("author@example.com", "guest"), disabling("author@example.com")) + p := overseen(address) + + moved := testkit.Run(t, p, "", slices.Concat([]string{"account:role", "author@example.com", "author", "-yes"}, + asManager)...) + got := testkit.Run(t, p, "", slices.Concat([]string{"account:enable", "author@example.com", "-yes"}, asManager)...) + + if moved.Code != gonsole.ExitDone || got != (testkit.Result{Code: gonsole.ExitDone, + Stdout: "enabled author@example.com\n"}) { + t.Errorf("Run() = %+v, then %+v, want the role set, then the account enabled", moved, got) + } + if after := standingOf(t, address, "author@example.com"); after != `role "author", disabled false` { + t.Errorf("author@example.com holds %s, want an enabled author", after) + } +} diff --git a/gonsole/auth/reach.go b/gonsole/auth/reach.go index a08ffc6..ad277dd 100644 --- a/gonsole/auth/reach.go +++ b/gonsole/auth/reach.go @@ -64,6 +64,22 @@ func (r reach) sets(held gouncer.User, disabled bool) error { return r.changes(held) } +// reenabling is what an account holding an undeclared role awaits before it may be enabled. +const reenabling = "give it a declared role before enabling it" + +// lost refuses enabling an account whose stored role roles does not declare, an empty role included. +func lost(roles Roles, held gouncer.User, disabled bool) error { + switch { + case disabled: + return nil + case held.Role == "": + return fmt.Errorf("the account %s holds no role, %s", held.Email, reenabling) + case !slices.Contains(roles.Known, held.Role): + return fmt.Errorf("the account %s holds the undeclared role %s, %s", held.Email, held.Role, reenabling) + } + return nil +} + // lacking returns the error naming the first capability of role the acting account's role lacks, nil for none. func (r reach) lacking(subject, role string) error { for _, capability := range r.carried[role] { diff --git a/gonsole/auth/reach_test.go b/gonsole/auth/reach_test.go index 150fed5..74ba7e4 100644 --- a/gonsole/auth/reach_test.go +++ b/gonsole/auth/reach_test.go @@ -104,6 +104,11 @@ func TestAccountCommandsApplyAChangeWithinTheReachOfTheActingAccount(t *testing. {"the manager changes an account whose stored role the roles leave out", []change{holding("editor@example.com", "guest")}, overseen, "manager@example.com", []string{"account:disable", "editor@example.com"}, "disabled editor@example.com\n"}, + {"the manager sets an account whose stored role the roles leave out", + []change{holding("editor@example.com", "guest")}, overseen, "manager@example.com", + []string{"account:role", "editor@example.com", "author"}, "set editor@example.com to author\n"}, + {"the manager disables an account with no role", []change{holding("editor@example.com", "")}, overseen, + "manager@example.com", []string{"account:disable", "editor@example.com"}, "disabled editor@example.com\n"}, {"the manager disables a second manager", []change{holding("author@example.com", "manager")}, overseen, "manager@example.com", []string{"account:disable", "author@example.com"}, "disabled author@example.com\n"}, {"the admin sets the editor to admin", nil, overseen, "admin@example.com", diff --git a/gonsole/auth/role.go b/gonsole/auth/role.go index 358005e..65b7f36 100644 --- a/gonsole/auth/role.go +++ b/gonsole/auth/role.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/gopherium/gouncer" + "github.com/gopherium/gouncer/authkit" "github.com/gopherium/framework/gonsole" ) @@ -52,8 +53,8 @@ func setRole(ctx context.Context, call gonsole.Call, cfg Config) error { _, err := fmt.Fprintf(call.Stdout, "would set %s to %s\n", held.Email, role) return err } - if err := store.SetUserRole(ctx, held.ID, role, roles.Privileged); err != nil { - return uncovered(held.Email, err) + if err := store.SetUserRoleFrom(ctx, held.ID, held.Role, role, roles.Privileged); err != nil { + return written(held.Email, err) } _, err = fmt.Fprintf(call.Stdout, "set %s to %s\n", held.Email, role) return err @@ -65,6 +66,14 @@ func address(typed string) string { return strings.ToLower(strings.TrimSpace(typed)) } +// written returns err, worded for an operator when the role moved since the check or no privileged account remains. +func written(email string, err error) error { + if errors.Is(err, authkit.ErrRoleChanged) { + return fmt.Errorf("the role of %s changed after the check, run the command again", email) + } + return uncovered(email, err) +} + // uncovered returns err, worded for an operator when it refuses to remove the last privileged account. func uncovered(email string, err error) error { if errors.Is(err, gouncer.ErrLastPrivileged) { diff --git a/gonsole/auth/standing.go b/gonsole/auth/standing.go index b4c1313..d112ff9 100644 --- a/gonsole/auth/standing.go +++ b/gonsole/auth/standing.go @@ -33,7 +33,7 @@ func standing(cfg Config, name, summary, verb string, disabled bool) gonsole.Com } } -// setStanding sets whether the named account within reach is disabled, never leaving no privileged account enabled. +// setStanding sets whether the named account within reach is disabled, never enabling one holding an undeclared role. func setStanding(ctx context.Context, call gonsole.Call, cfg Config, verb string, disabled bool) error { email := address(call.Args[0]) roles, err := cfg.Roles(ctx, call) @@ -48,12 +48,15 @@ func setStanding(ctx context.Context, call gonsole.Call, cfg Config, verb string if err := within.sets(held, disabled); err != nil { return err } + if err := lost(roles, held, disabled); err != nil { + return err + } if !call.Apply { _, err := fmt.Fprintf(call.Stdout, "would %s %s\n", verb, held.Email) return err } - if err := store.SetUserDisabledUnderCover(ctx, held.ID, disabled, roles.Privileged); err != nil { - return uncovered(held.Email, err) + if err := store.SetUserDisabledFrom(ctx, held.ID, held.Role, disabled, roles.Privileged); err != nil { + return written(held.Email, err) } _, err = fmt.Fprintf(call.Stdout, "%sd %s\n", verb, held.Email) return err