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/createadmin.go b/cmd/alphone/createadmin.go index c536116b..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" ) @@ -32,6 +30,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 +42,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 +56,5 @@ func createAdmin( } users := authkitpg.NewUserStore(pool) - if err := authkit.CreateAdmin(ctx, users, *email, *name, stdin, stdout); err != nil { - return err - } - return grantAdmin(ctx, pool, users, *email) -} - -// grantAdmin puts the named user in the admin tier. -func grantAdmin(ctx context.Context, pool *pgxpool.Pool, users *authkitpg.UserStore, email string) 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 authkit.CreateAdmin(ctx, users, *email, *name, held.String(), stdin, stdout) } 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() 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..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. @@ -23,6 +26,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 @@ -36,20 +40,28 @@ 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) case "seed": @@ -58,7 +70,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_exec_test.go b/cmd/alphone/main_exec_test.go index 6a887e82..c79cd6de 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") @@ -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"` @@ -423,7 +427,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 +572,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 +644,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/main_test.go b/cmd/alphone/main_test.go index f38bbe06..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) @@ -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) } @@ -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) } } @@ -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", 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/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/roles_exec_test.go b/cmd/alphone/roles_exec_test.go index b0490341..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 = grantAdmin(t.Context(), pool, authkitpg.NewUserStore(pool), "nobody@example.com") - - if err == nil { - t.Error("grantAdmin() error = nil, want a refusal for a user that does not exist") - } -} - func TestCreateAdminProvisionsAnAdminOnABareDatabase(t *testing.T) { t.Parallel() @@ -48,7 +29,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 { @@ -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/roles_test.go b/cmd/alphone/roles_test.go new file mode 100644 index 00000000..d9ace81e --- /dev/null +++ b/cmd/alphone/roles_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package main + +import ( + "context" + "errors" + "io" + "slices" + "strings" + "sync/atomic" + "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 +} + +// 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() + + 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..a1cf93e8 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" @@ -59,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) @@ -82,6 +82,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...) @@ -89,8 +92,8 @@ 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(authConfig(userStore)) + admin := authkit.NewAdmin(adminConfig(userStore)) graphRoot, err := graphroot.FromPlugins(&graphres.Resolver{ Version: version.Version(), Contacts: contacts, @@ -98,7 +101,6 @@ func run( Webhooks: webhooks, Tenants: postgres.NewTenantStore(pool), Tokens: tokens, - Roles: roles, Events: events, Live: hub, Auth: auth, @@ -106,7 +108,9 @@ func run( LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}), }, registered) if err != nil { - 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{ @@ -115,7 +119,6 @@ func run( Auth: auth, GraphRoot: graphRoot, Tokens: tokens, - Roles: roles, Plugins: host.Routes(), PluginPublicPaths: host.PublicPaths(), PluginAreas: pluginAreas(registered), @@ -148,6 +151,56 @@ 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()} +} + +// 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 { + 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/cmd/alphone/seed.go b/cmd/alphone/seed.go index 3361bfb0..8511e4a5 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,15 +89,14 @@ 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 { - return nil, err - } return created, nil } @@ -105,7 +105,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..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) { @@ -154,7 +150,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 +458,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(), @@ -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/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, ) 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 diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index ce8b7447..fda59546 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,38 +97,80 @@ 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. +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. -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. +An account can also hold no role at all, which happens to accounts made before +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. -Three operations are reserved to admins: `createUser`, `setUserDisabled` and -`setUserRole`. A member calling one is refused: +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: + +```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 { "errors": [ { "message": "admin required", - "extensions": { "code": "UNAUTHORIZED", "scope": "users:write" } + "extensions": { + "code": "UNAUTHORIZED", + "scope": "users:write", + "capability": "manage_users" + } } ], "data": null } ``` +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. 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. 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. -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`. + +Writing a role the caller does not hold itself is refused the same way, with +`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. ### Roles and scopes together @@ -137,10 +179,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 +281,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 | 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..aa20b25a 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,50 @@ 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 -v ON_ERROR_STOP=1 \ + -c "\\copy (SELECT id, role FROM auth.users) TO STDOUT WITH (FORMAT csv)" \ + > roles.csv.part && mv roles.csv.part roles.csv ``` -Applying the migration again refills that table with one admin row per -user, so put your own rows back over the top: +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. 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: ```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 +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' \ + < roles.csv ``` +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. + +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 A nightly `pg_dump` covers a single-server install. Save this as 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/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/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/frontend/src/test/users-route.test.tsx b/frontend/src/test/users-route.test.tsx index 913c4d27..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' @@ -151,49 +151,67 @@ 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('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: 'admin' }] } }), + HttpResponse.json({ data: { users: [{ ...userNode(colleague, false), role: 'member' }] } }), ), - graphql.mutation('SetUserRole', ({ variables }) => { - asked = { role: variables.role } + graphql.mutation('SetUserRole', async () => { + asked += 1 + await delay(50) 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 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', () => + HttpResponse.json({ data: { users: [{ ...userNode(colleague, false), role: 'member' }] } }), + ), + ) + renderAt('/users') + + 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 +222,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 +239,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..e45b5887 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,38 @@ 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 +143,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 +165,12 @@ export function UsersScreen() { } > - + ) } @@ -130,10 +183,12 @@ function UserRows({ users, currentUserId, manages, + grantable, }: { users: ReturnType> currentUserId: string | undefined manages: boolean + grantable: string[] }) { if (users.isPending) { return @@ -176,6 +231,7 @@ function UserRows({ user={user} isSelf={user.id === currentUserId} manages={manages} + grantable={grantable} /> ))} 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/graph/generated.go b/graph/generated.go index f7f1af4d..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 { @@ -171,7 +173,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 +323,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) @@ -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 @@ -903,7 +917,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 @@ -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) } @@ -2568,6 +2586,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 } @@ -4075,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, @@ -5112,7 +5184,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 { @@ -9805,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/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 { diff --git a/graph/schema.graphql b/graph/schema.graphql index 4eb0f338..f384b37e 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! @@ -77,6 +77,8 @@ type Identity { email: String! name: String! role: String! + capabilities: [String!]! + grantable: [String!]! } type ImportAssignment { column: Int! @@ -136,9 +138,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..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 { @@ -26,7 +28,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 diff --git a/graph/scope_test.go b/graph/scope_test.go index 25b7f088..cffb9042 100644 --- a/graph/scope_test.go +++ b/graph/scope_test.go @@ -6,10 +6,14 @@ import ( "fmt" "os" "path/filepath" + "slices" + "strings" "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. @@ -59,15 +63,21 @@ 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 { + slashed := strings.ReplaceAll(name, "\\", "/") + return strings.HasPrefix(slashed, "../plugins/") || strings.HasPrefix(slashed, "../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( @@ -78,13 +88,39 @@ 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, ownsCapabilities)...) return problems } +// capabilityProblems reports how one root field's capability declaration falls short. +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", + operation, field.Name)} + } + needed := declared.Arguments.ForName("capability") + 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)} + } + 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 +134,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 +172,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 +201,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 +237,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 +246,52 @@ 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 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 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() + + 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() - 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") } } 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/graphres/auth.go b/internal/graphres/auth.go index 3173be6d..f991e3d3 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" ) @@ -30,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. @@ -47,7 +58,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 +68,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 +119,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. @@ -140,13 +140,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) +// 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. @@ -155,11 +171,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 +186,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 a80c5dc4..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(store), - Roles: standingRoleStore{tier: role.Member}, + Admin: authkit.NewAdmin(authkit.AdminConfig{Store: store}), LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{Limit: 2, Window: time.Minute}), } } diff --git a/internal/graphres/errors.go b/internal/graphres/errors.go index 233987f8..1186bcca 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, @@ -59,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..b9eb3a9d 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,57 @@ 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 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/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..5ef6119f 100644 --- a/internal/graphres/roles_test.go +++ b/internal/graphres/roles_test.go @@ -6,271 +6,316 @@ import ( "context" "errors" "fmt" + "slices" "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 -} +// errWriting reports a store that cannot store a role. +var errWriting = errors.New("role unwritable") -// RolesOf refuses to answer many tiers. -func (failingRoleStore) RolesOf(context.Context, []uuid.UUID) (map[uuid.UUID]role.Role, error) { - return nil, errRoleBackend +// 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) + } } -// Grant refuses to store a tier. -func (failingRoleStore) Grant(context.Context, uuid.UUID, role.Role) error { - return errRoleBackend +// 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 refuses to bar a user. -func (failingRoleStore) Disable(context.Context, uuid.UUID) error { - return errRoleBackend +// 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 } -// standingRoleStore answers one fixed tier for everybody. -type standingRoleStore struct { - tier role.Role +// 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() + return newDecoratedGraphClient(t, resolver, func(ctx context.Context) context.Context { + return authkit.WithIdentity(ctx, actor) + }) } -// RoleOf answers the fixed tier. -func (s standingRoleStore) RoleOf(context.Context, uuid.UUID) (role.Role, error) { - return s.tier, nil +// 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) } -// 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 - } - return tiers, nil +// 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) } -// Grant stores nothing and reports success. -func (standingRoleStore) Grant(context.Context, uuid.UUID, role.Role) error { - return nil -} +func TestUsersCarriesEachAccountsTier(t *testing.T) { + t.Parallel() -// Disable bars nobody and reports success. -func (standingRoleStore) Disable(context.Context, uuid.UUID) error { - return nil -} + resolver, held := newRoledResolver(t, role.Admin) + client := newGraphClient(t, resolver, held.ID) -// grantingRoleStore remembers the last tier it was asked to store. -type grantingRoleStore struct { - standingRoleStore - granted role.Role - to uuid.UUID -} + var listed struct { + Users []struct { + Role string `json:"role"` + } `json:"users"` + } + client.MustPost(`{ users { role } }`, &listed) -// 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 + if len(listed.Users) == 0 { + t.Fatal("users answered nothing, want the seeded account") + } + if listed.Users[0].Role != role.Admin.String() { + t.Errorf("role = %q, want %q", listed.Users[0].Role, role.Admin.String()) + } } -// unseatingRoleStore refuses to unseat the last admin. -type unseatingRoleStore struct { - standingRoleStore -} +func TestUsersReportsAnAccountHoldingNoRoleAsHoldingNone(t *testing.T) { + t.Parallel() -// Grant refuses every write as the last admin refusal. -func (unseatingRoleStore) Grant(context.Context, uuid.UUID, role.Role) error { - return role.ErrLastAdmin -} + resolver, held := newRoledResolver(t, "") + client := newGraphClient(t, resolver, held.ID) -// Disable refuses to bar the last admin. -func (unseatingRoleStore) Disable(context.Context, uuid.UUID) error { - return role.ErrLastAdmin -} + var listed struct { + Users []struct { + Role string `json:"role"` + } `json:"users"` + } + client.MustPost(`{ users { role } }`, &listed) -// missingRoleStore knows no user at all. -type missingRoleStore struct { - standingRoleStore + if len(listed.Users) == 0 { + t.Fatal("users answered nothing, want the seeded account") + } + 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) + } } -// Disable reports that no such user exists. -func (missingRoleStore) Disable(context.Context, uuid.UUID) error { - return gouncer.ErrUserNotFound -} +func TestMeAnswersTheCallersTier(t *testing.T) { + t.Parallel() -// newRoledResolver returns an auth resolver holding one account whose tiers come from roles. -func newRoledResolver(t *testing.T, roles graphres.RoleStore) *graphres.Resolver { - t.Helper() - store := testkit.NewStore() - store.AddUser(t, "grace@example.com", "Grace Hopper", testPassword) - resolver := newAuthResolver(store) - resolver.Roles = roles - return resolver + resolver, held := newRoledResolver(t, role.Admin) + client := newActingClient(t, resolver, authkit.Identity{ID: held.ID, Role: held.Role}) + + var answered struct { + Me struct { + Role string `json:"role"` + } `json:"me"` + } + 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 TestUsersReportsAFailingRoleStore(t *testing.T) { +func TestCreateUserStartsAtTheNarrowestRoleWhenTheInputNamesNone(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, failingRoleStore{}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) - - answered, err := client.RawPost(`{ users { id role } }`) + 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) - if err != nil { - t.Fatalf("RawPost() error = %v, want nil", err) + var answered struct { + CreateUser struct { + Role string `json:"role"` + } `json:"createUser"` } - if len(answered.Errors) == 0 { - t.Error("users answered no error while the role store refuses, want one") + 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 TestUsersCarriesEachAccountsTier(t *testing.T) { +func TestCreateUserStartsAtTheRoleTheInputNames(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, standingRoleStore{tier: role.Admin}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + 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 listed struct { - Users []struct { + var answered struct { + CreateUser struct { Role string `json:"role"` - } `json:"users"` + } `json:"createUser"` } - client.MustPost(`{ users { role } }`, &listed) + client.MustPost(`mutation { createUser(`+ + `email: "maria@example.com", name: "Maria Perez", password: "correct horse battery", role: "admin"`+ + `) { role } }`, &answered) - if len(listed.Users) == 0 { - t.Fatal("users answered nothing, want the seeded account") - } - if listed.Users[0].Role != role.Admin.String() { - t.Errorf("role = %q, want %q", listed.Users[0].Role, role.Admin.String()) + if answered.CreateUser.Role != role.Admin.String() { + t.Errorf("role = %q, want %q", answered.CreateUser.Role, role.Admin.String()) } } -func TestUsersLeavesEverybodyAMemberWithNoStoreWired(t *testing.T) { +func TestCreateUserRefusesARoleTheRegistryDoesNotKnow(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, nil) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + 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 listed struct { - Users []struct { - Role string `json:"role"` - } `json:"users"` - } - client.MustPost(`{ users { role } }`, &listed) + answered, err := client.RawPost(`mutation { createUser(` + + `email: "maria@example.com", name: "Maria Perez", password: "correct horse battery", role: "root"` + + `) { role } }`) - if len(listed.Users) == 0 { - t.Fatal("users answered nothing, want the seeded account") + if err != nil { + t.Fatalf("RawPost() error = %v, want nil", err) } - 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 got := firstErrorCode(t, answered.Errors); got != "VALIDATION" { + t.Errorf("code = %q, want VALIDATION", got) } } -func TestLoginReportsAFailingRoleStore(t *testing.T) { +func TestCreateUserRefusesARoleBeyondTheCallersReach(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, failingRoleStore{}) - client := newHTTPGraphClient(t, resolver, uuid.Nil) + 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 { login(email: "grace@example.com", password: "` + testPassword + `") { me { role } } }`) + 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("login answered no error while the role store refuses, want one") + t.Error("createUser answered no error, want an admin refused a role it does not hold") } } -func TestLoginLeavesTheCallerAMemberWithNoStoreWired(t *testing.T) { +func TestMeAnswersTheCapabilitiesTheRoleHolds(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, nil) - client := newHTTPGraphClient(t, resolver, uuid.Nil) + resolver, held := newRoledResolver(t, role.Admin) + client := newActingClient(t, resolver, authkit.Identity{ID: held.ID, Role: held.Role}) var answered struct { - Login struct { - Me struct { - Role string `json:"role"` - } `json:"me"` - } `json:"login"` + Me struct { + Capabilities []string `json:"capabilities"` + Grantable []string `json:"grantable"` + } `json:"me"` } - client.MustPost( - `mutation { login(email: "grace@example.com", password: "`+testPassword+`") { me { role } } }`, &answered) + client.MustPost(`{ me { capabilities grantable } }`, &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 !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 TestLoginAnswersTheCallersTier(t *testing.T) { +func TestMeAnswersNothingForAMember(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, standingRoleStore{tier: role.Admin}) - client := newHTTPGraphClient(t, resolver, uuid.Nil) + resolver, held := newRoledResolver(t, role.Member) + client := newActingClient(t, resolver, authkit.Identity{ID: held.ID, Role: held.Role}) var answered struct { - Login struct { - Me struct { - Role string `json:"role"` - } `json:"me"` - } `json:"login"` + Me struct { + Capabilities []string `json:"capabilities"` + Grantable []string `json:"grantable"` + } `json:"me"` } - client.MustPost( - `mutation { login(email: "grace@example.com", password: "`+testPassword+`") { me { role } } }`, &answered) + client.MustPost(`{ me { capabilities grantable } }`, &answered) - if answered.Login.Me.Role != role.Admin.String() { - t.Errorf("role = %q, want %q", answered.Login.Me.Role, role.Admin.String()) + 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) } } -// 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 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() - roles := &grantingRoleStore{} - resolver := newRoledResolver(t, roles) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) - promoted := uuid.Must(uuid.NewV7()) + 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"` } - client.MustPost(settingRole(promoted, role.Admin.String()), &answered) + client.MustPost(settingRole(held.ID, role.Admin.String()), &answered) if !answered.SetUserRole { t.Error("setUserRole 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 got := store.Users[held.ID].Role; got != role.Admin.String() { + t.Errorf("stored role = %q, want %q", got, role.Admin.String()) } } func TestSetUserRoleRefusesATierNoDeploymentKnows(t *testing.T) { t.Parallel() - roles := &grantingRoleStore{} - resolver := newRoledResolver(t, roles) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + 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(uuid.Must(uuid.NewV7()), "root")) + answered, err := client.RawPost(settingRole(held.ID, "root")) if err != nil { t.Fatalf("RawPost() error = %v, want nil", err) @@ -278,112 +323,218 @@ func TestSetUserRoleRefusesATierNoDeploymentKnows(t *testing.T) { if got := firstErrorCode(t, answered.Errors); got != "VALIDATION" { t.Errorf("code = %q, want VALIDATION", got) } - if roles.granted != "" { - t.Errorf("granted %v, want nothing stored for a tier nobody knows", roles.granted) + 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 TestSetUserRoleRefusesToUnseatTheLastAdmin(t *testing.T) { +func TestSetUserRoleRefusesGrantingBeyondTheCallersReach(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, unseatingRoleStore{}) - client := newGraphClient(t, resolver, uuid.Must(uuid.NewV7())) + 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(uuid.Must(uuid.NewV7()), role.Member.String())) + answered, err := client.RawPost(settingRole(held.ID, "steward")) 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, 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) } } -func TestSetUserDisabledRefusesToBarTheLastAdmin(t *testing.T) { +func TestSetUserRoleRefusesTouchingAnAccountBeyondTheCallersReach(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, unseatingRoleStore{}) - 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( - fmt.Sprintf(`mutation { setUserDisabled(id: %q, disabled: true) }`, uuid.Must(uuid.NewV7()))) + answered, err := client.RawPost(settingRole(held.ID, role.Member.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, 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 TestSetUserDisabledEnablesThroughTheAccountSeam(t *testing.T) { +func TestSetUserDisabledRefusesBarringItself(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.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) - 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) + client.MustPost(settingDisabled(held.ID, true), &answered) if !answered.SetUserDisabled { - t.Error("setUserDisabled answered false, want enabling reported, it only adds cover") + t.Error("setUserDisabled answered false, want the change reported") + } + if !store.Users[held.ID].Disabled { + t.Error("the account is enabled, want the guarded write to bar it") } - restored, err := store.UserByID(t.Context(), account.ID) +} + +func TestSetUserDisabledRefusesAnAccountBeyondTheCallersReach(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(settingDisabled(held.ID, true)) + 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, want an admin refused an account holding more") + } + if store.Users[held.ID].Disabled { + t.Error("the account is barred, want the refused write to leave it") + } +} + +func TestSetUserRoleReportsAStoreThatCannotList(t *testing.T) { + t.Parallel() + + 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(held.ID, 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 while the store refuses to list, want one") } } -func TestSetUserDisabledReportsAUserTheStoreCannotFind(t *testing.T) { +func TestSetUserRoleReportsAStoreThatCannotWrite(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, missingRoleStore{}) - 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 != "NOT_FOUND" { - t.Errorf("code = %q, want NOT_FOUND", got) + if len(answered.Errors) == 0 { + t.Error("setUserRole answered no error while the store refuses the write, want one") } } -func TestSetUserDisabledLeavesEnablingUnguarded(t *testing.T) { +func TestSetUserDisabledReportsAStoreThatCannotList(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( - 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 to list, want one") } } -func TestSetUserRoleReportsAFailingRoleStore(t *testing.T) { +func TestSetUserDisabledAdmitsAnAccountItBarred(t *testing.T) { t.Parallel() - resolver := newRoledResolver(t, failingRoleStore{}) - 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) + + var answered struct { + SetUserDisabled bool `json:"setUserDisabled"` + } + client.MustPost(settingDisabled(held.ID, false), &answered) + + if !answered.SetUserDisabled { + t.Error("setUserDisabled answered false, want the change reported") + } + if store.Users[held.ID].Disabled { + t.Error("the account is still barred, want it admitted") + } +} + +func TestSetUserDisabledReportsAStoreThatCannotWrite(t *testing.T) { + t.Parallel() + + 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(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 while the store refuses the write, want one") + } +} + +func TestSetUserRoleReportsAnAccountNobodyHolds(t *testing.T) { + t.Parallel() + + 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 +542,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..12a72d38 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. @@ -34,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. @@ -77,6 +84,10 @@ 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 && + needed.Value.Kind != ast.NullValue { + scope.capability = role.Capability(needed.Value.Raw) + } return scope } @@ -85,6 +96,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) { @@ -120,7 +143,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.Role(authkit.IdentityFromContext(ctx).Role) operation := graphql.GetOperationContext(ctx) if operation.Operation == nil { return scopeRefusal("the operation") @@ -130,8 +153,8 @@ 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)) { - return refusal("admin required", scopes.Needed(kind, selected.Name)) + if needed := scopes.Capability(kind, selected.Name); needed != "" && !role.Can(tier, needed) { + return capabilityRefusal(scopes.Needed(kind, selected.Name), needed) } } return next(ctx) @@ -143,6 +166,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 6f9a647a..6449dbc7 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,58 @@ 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, "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) + } + if got := scopes.Capability(ast.Query, "users"); got != "" { + t.Errorf("the users listing needs %q, want a member reading its colleagues", got) + } +} + +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() 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 7d477cb8..0f44596b 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})) } @@ -101,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) { @@ -115,6 +145,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() @@ -145,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() 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..782c3491 --- /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, + created_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO core.user_roles (user_id, role) +SELECT id, role FROM auth.users WHERE role <> ''; diff --git a/internal/postgres/movedroles_test.go b/internal/postgres/movedroles_test.go new file mode 100644 index 00000000..2a2d2b30 --- /dev/null +++ b/internal/postgres/movedroles_test.go @@ -0,0 +1,155 @@ +// 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") + 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) + } + + 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()) + } + 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) + } +} 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 { diff --git a/internal/role/capability_test.go b/internal/role/capability_test.go new file mode 100644 index 00000000..5a7962d0 --- /dev/null +++ b/internal/role/capability_test.go @@ -0,0 +1,229 @@ +// 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 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() + + 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..2055943b 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,16 @@ 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") + +// 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 -// 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 +35,174 @@ 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 +} + +// Capabilities returns every capability any role in the registry holds, in name order. +func (r *Registry) Capabilities() []Capability { + r.mu.RLock() + defer r.mu.RUnlock() + var held []Capability + for _, carried := range r.carried { + for _, capability := range carried { + if !slices.Contains(held, capability) { + held = append(held, capability) + } + } + } + slices.Sort(held) + return held +} + +// Parse returns the role the text names, refusing any role the registry does not hold. +func (r *Registry) Parse(text string) (Role, error) { + roles := r.Roles() + if slices.Contains(roles, Role(text)) { + return Role(text), nil + } + named := make([]string, 0, len(roles)) + for _, held := range roles { + named = append(named, string(held)) + } + return "", fmt.Errorf("%w: %q, want one of %s", ErrUnknownTier, text, strings.Join(named, " or ")) +} + +// Privileged returns the stored form of every role that administers accounts. +func (r *Registry) Privileged() []string { + var named []string + for _, held := range r.Roles() { + if r.Can(held, ManageUsers) { + named = append(named, string(held)) + } + } + return named +} + +// Outranks reports whether the caller holds every capability the target holds. +func (r *Registry) Outranks(caller, target Role) bool { + r.mu.RLock() + defer r.mu.RUnlock() + for _, capability := range r.carried[target] { + if !slices.Contains(r.carried[caller], capability) { + return false + } + } + return true +} + +// Grantable returns the roles the caller outranks, the one holding most first. +func (r *Registry) Grantable(caller Role) []Role { + var granted []Role + for _, held := range r.Roles() { + if r.Outranks(caller, held) { + granted = append(granted, held) + } + } + slices.SortStableFunc(granted, func(a, b Role) int { + return cmp.Compare(len(r.CapabilitiesOf(b)), len(r.CapabilitiesOf(a))) + }) + return granted +} + +// Grant gives a role the capabilities in the default registry, declaring it when absent. +func Grant(held Role, capabilities ...Capability) error { + return Default.Grant(held, capabilities...) +} + +// Can reports whether a role holds the capability in the default registry. +func Can(held Role, capability Capability) bool { + return Default.Can(held, capability) +} + +// CapabilitiesOf returns the capabilities a role carries in the default registry. +func CapabilitiesOf(held Role) []string { + return Default.CapabilitiesOf(held) +} + +// Privileged returns every role administering accounts in the default registry. +func Privileged() []string { + return Default.Privileged() +} + +// Capabilities returns every capability any role in the default registry holds. +func Capabilities() []Capability { + return Default.Capabilities() +} + +// Outranks reports whether the caller holds every capability the target holds in the default registry. +func Outranks(caller, target Role) bool { + return Default.Outranks(caller, target) +} + +// Grantable returns the roles the caller outranks in the default registry. +func Grantable(caller Role) []Role { + return Default.Grantable(caller) +} + // Of returns the tier the stored text names, member for anything it cannot read. func Of(stored string) Role { if Role(stored) == Admin { @@ -34,19 +211,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/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 4302e361..67a249ce 100644 --- a/internal/server/graphql_test.go +++ b/internal/server/graphql_test.go @@ -11,8 +11,6 @@ import ( "testing" "time" - "github.com/google/uuid" - "github.com/gopherium/gouncer/authkit" "github.com/gopherium/gouncer/authkit/ratelimit" @@ -24,21 +22,12 @@ import ( "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 @@ -59,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(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) @@ -68,17 +61,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 +81,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 } 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/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/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", 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..f64fb45b 100644 --- a/sdk/frontend/testing.tsx +++ b/sdk/frontend/testing.tsx @@ -27,13 +27,23 @@ 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 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 { diff --git a/sdk/sdk.go b/sdk/sdk.go index 33fe1695..3aed0e5a 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -61,6 +61,20 @@ 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 returns every role the plugin declares and every capability it adds to one. + 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. 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() }) 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 45cd9939..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,25 +123,23 @@ 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(users), + Admin: authkit.NewAdmin(authkit.AdminConfig{Store: users, Privileged: role.Privileged()}), 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) 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) @@ -156,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", })) @@ -294,7 +290,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)