Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions gonsole/auth/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions gonsole/auth/checkedwrite_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
32 changes: 32 additions & 0 deletions gonsole/auth/declaredroles.go
Original file line number Diff line number Diff line change
@@ -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
}
}
181 changes: 181 additions & 0 deletions gonsole/auth/declaredroles_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading