feat: account menu, database roles, and single logout

Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.

The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.

Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.

Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.

Two bugs found while verifying in a browser:

  - A profile picture never rendered. avatarURL built an absolute URL
    from the configured base URL, and the page's own img-src 'self'
    blocked it. The console's own pages now take a root-relative path;
    only /connect/userinfo, read from another origin, needs an absolute
    one, and it follows the request.
  - absoluteURL preferred the configuration over the request while its
    comment claimed the opposite, which also misdirected the logout
    redirect.

Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
This commit is contained in:
Daniel Noulet 2026-10-05 19:18:07 +02:00
parent 3fdc8cb489
commit c7c81fb65c
43 changed files with 2248 additions and 298 deletions

View file

@ -108,14 +108,18 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
return ManagedUser{}, err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
if err != nil {
return ManagedUser{}, err
}
if !within([]string{string(role.ManageUsers)}, held) {
if !permitted {
return ManagedUser{}, ErrNotFound
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return ManagedUser{}, err
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable)
if err != nil {
return ManagedUser{}, err
}
@ -161,14 +165,18 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
return err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
permitted, err := hasPermission(ctx, tx, administratorID, tenantID, role.ManageUsers)
if err != nil {
return err
}
if !within([]string{string(role.ManageUsers)}, held) {
if !permitted {
return ErrNotFound
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
grantable, err := grantablePermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return err
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, grantable)
if err != nil {
return err
}
@ -410,3 +418,48 @@ func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error
}
return nil
}
// RevokeUserClientSessions ends every hosted-login session belonging to a user and
// reports how many were live.
//
// Go Loose issues the bearer tokens that tenant applications authenticate with, so
// this is what makes signing out here sign you out there: the tokens stop resolving
// immediately, without the other application having to be told or even to ask. It is
// the part of single logout that is enforced here rather than at the identity
// provider.
//
// A user with no sessions is not an error, so the count is returned rather than
// ErrNotFound: logging out twice, or from a session that never used a client
// application, must still succeed.
func (s *Store) RevokeUserClientSessions(ctx context.Context, userID string) (int, error) {
if userID == "" {
return 0, nil
}
result, err := s.db.ExecContext(ctx, `
UPDATE client_user_sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL`, userID)
if err != nil {
return 0, err
}
count, err := result.RowsAffected()
if err != nil {
return 0, err
}
return int(count), nil
}
// CountActiveClientSessions reports how many hosted-login sessions a user holds.
// The account menu uses it to say whether signing out will reach other applications.
func (s *Store) CountActiveClientSessions(ctx context.Context, userID string) (int, error) {
if userID == "" {
return 0, nil
}
var count int
err := s.db.QueryRowContext(ctx, `
SELECT count(*) FROM client_user_sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now()`, userID).Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}

View file

@ -12,6 +12,7 @@ type Installation struct {
OIDCIssuer string
OIDCClientID string
OIDCSecretCiphertext []byte
OIDCRedirectURL string
SeededDemo bool
}
@ -25,9 +26,9 @@ type DemoUser struct {
func (s *Store) Installation(ctx context.Context) (Installation, error) {
var installation Installation
err := s.db.QueryRowContext(ctx, `
SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo
SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, oidc_redirect_url, seeded_demo
FROM installation_settings WHERE singleton = true`).
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.SeededDemo)
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.OIDCRedirectURL, &installation.SeededDemo)
if errors.Is(err, sql.ErrNoRows) {
return Installation{}, ErrNotFound
}
@ -41,9 +42,9 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo)
VALUES (true, $1, $2, $3, $4)`,
installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.SeededDemo); err != nil {
INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, oidc_redirect_url, seeded_demo)
VALUES (true, $1, $2, $3, $4, $5)`,
installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.OIDCRedirectURL, installation.SeededDemo); err != nil {
return err
}
if len(demoUsers) == 0 {
@ -118,14 +119,14 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT id, $1, r.id FROM tenants, roles r
SELECT t.id, $1, r.id FROM tenants t, roles r
WHERE r.slug = 'owner' AND r.tenant_id IS NULL
ON CONFLICT DO NOTHING`, userID); err != nil {
return false, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_application_access(application_id, user_id, granted_by)
SELECT id, $1, $1 FROM applications ON CONFLICT DO NOTHING`, userID); err != nil {
SELECT a.id, $1, $1 FROM applications a ON CONFLICT DO NOTHING`, userID); err != nil {
return false, err
}
return true, tx.Commit()

View file

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"net/url"
"os"
"strings"
"testing"
@ -24,6 +25,7 @@ func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) {
if url == "" {
t.Skip("set GO_LOOSE_TEST_DATABASE_URL to run PostgreSQL integration tests")
}
requireScratchDatabase(t, url)
ctx := context.Background()
db, err := database.Open(ctx, url)
if err != nil {
@ -39,6 +41,27 @@ func integrationStore(t *testing.T) (*store.Store, *sql.DB, context.Context) {
return store.New(db), db, ctx
}
// requireScratchDatabase refuses to run against anything that looks like a real
// database. resetSchema drops every table in the public schema, so pointing
// these tests at a development database destroys it. The database name must
// contain "test" unless the operator opts out explicitly.
func requireScratchDatabase(t *testing.T, rawURL string) {
t.Helper()
if os.Getenv("GO_LOOSE_TEST_DATABASE_DESTRUCTIVE") == "1" {
return
}
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("parse GO_LOOSE_TEST_DATABASE_URL: %v", err)
}
name := strings.TrimPrefix(parsed.Path, "/")
if !strings.Contains(strings.ToLower(name), "test") {
t.Fatalf("refusing to run integration tests against database %q: resetSchema drops every table. "+
"Point GO_LOOSE_TEST_DATABASE_URL at a scratch database whose name contains \"test\" "+
"(run `make integration-db` to create one), or set GO_LOOSE_TEST_DATABASE_DESTRUCTIVE=1 to override.", name)
}
}
// grantApplicationAccess inserts an application grant directly so that tests can
// build states the management API intentionally refuses to create, such as a
// grant for a user with no membership.

View file

@ -6,6 +6,16 @@ import (
"errors"
)
// EnsurePasswordUser creates or updates the local account behind
// GO_LOOSE_LOCAL_ADMIN_PASSWORD and bootstraps it into the installation.
//
// The local administrator is operator-supplied rather than created by a visitor,
// so when the installation has no system administrator yet it claims the role.
// Without that, a database-only deployment would have nobody able to create
// tenants or approve waiting-room users, because both require the role that only
// a first OIDC sign-in otherwise grants. The claim is a no-op once any system
// administrator exists, so an installation already owned by a Google account is
// left alone.
func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, passwordHash, tenantSlug, appSlug string) error {
user, err := s.UpsertUser(ctx, "local:"+email, email, displayName)
if err != nil {
@ -17,6 +27,9 @@ func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, pass
WHERE id = $1 AND password_hash IS NULL`, user.ID, passwordHash); err != nil {
return err
}
if _, err := s.ClaimFirstSystemAdministrator(ctx, user.ID); err != nil {
return err
}
return s.Bootstrap(ctx, user.ID, tenantSlug, appSlug)
}

View file

@ -0,0 +1,224 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"github.com/danyel/go-loose/internal/role"
)
// Permission is one entry in the installation-wide catalog. The nine seeded by
// migration 0007 are the capabilities the console enforces; anything added later
// is grantable and reported to client applications but is not checked here,
// because nothing in the request path tests for it.
type Permission struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
System bool `json:"system"`
// RoleCount is how many roles currently grant it. A permission that no role
// grants can be removed again.
RoleCount int `json:"role_count"`
// Enforced reports whether the application itself checks for this
// permission. The console surfaces this so an operator is not misled into
// believing a custom capability is enforced.
Enforced bool `json:"enforced"`
}
var (
// ErrPermissionInUse is returned when a permission a role still grants is
// deleted.
ErrPermissionInUse = errors.New("permission is still granted by a role")
// ErrPermissionTaken is returned when a permission name already exists.
ErrPermissionTaken = errors.New("permission name is already in use")
// ErrPermissionSystem is returned when a built-in permission would be edited
// or deleted.
ErrPermissionSystem = errors.New("built-in permissions cannot be changed")
)
// permissionName constrains a name to lowercase dotted words such as
// "reports.export". The database enforces the same shape.
var permissionName = regexp.MustCompile(`^[a-z0-9][a-z0-9]*(?:[.-][a-z0-9]+)*$`)
// ValidPermissionName reports whether a name is well formed.
func ValidPermissionName(name string) bool {
return len(name) >= 3 && len(name) <= 100 && permissionName.MatchString(name)
}
// ListPermissions returns the whole catalog, most sensitive first for the
// built-ins and alphabetically after them.
func (s *Store) ListPermissions(ctx context.Context) ([]Permission, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT p.name, p.label, p.description, p.is_system,
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = p.name)
FROM permissions p
ORDER BY array_position(`+permissionOrder+`, p.name) NULLS LAST, p.name`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]Permission, 0)
for rows.Next() {
var item Permission
if err := rows.Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount); err != nil {
return nil, err
}
item.Enforced = role.Permission(item.Name).Valid()
result = append(result, item)
}
return result, rows.Err()
}
// CreatePermission adds a name to the catalog so that roles can grant it.
// Requires either a system administrator or tenants.manage, because the catalog
// is shared by every tenant rather than scoped to one.
func (s *Store) CreatePermission(ctx context.Context, actorID, name, label, description string) (Permission, error) {
name = strings.TrimSpace(name)
label = strings.TrimSpace(label)
description = strings.TrimSpace(description)
if !ValidPermissionName(name) {
return Permission{}, fmt.Errorf("%w: use lowercase words separated by dots", ErrUnknownPermission)
}
if label == "" || len(label) > 100 {
return Permission{}, fmt.Errorf("%w: label must be between 1 and 100 characters", ErrUnknownPermission)
}
if len(description) > 500 {
return Permission{}, fmt.Errorf("%w: description must be 500 characters or fewer", ErrUnknownPermission)
}
if role.Permission(name).Valid() {
// The name is one the application enforces; it is already seeded, so this
// can only be a duplicate.
return Permission{}, ErrPermissionTaken
}
permitted, err := s.mayAdministerCatalog(ctx, actorID)
if err != nil {
return Permission{}, err
}
if !permitted {
return Permission{}, ErrNotFound
}
item := Permission{Name: name, Label: label, Description: description, Enforced: false}
err = s.db.QueryRowContext(ctx, `
INSERT INTO permissions(name, label, description)
VALUES ($1, $2, $3)
RETURNING name, label, description, is_system,
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name)`,
name, label, description).
Scan(&item.Name, &item.Label, &item.Description, &item.System, &item.RoleCount)
if isUniqueViolation(err) {
return Permission{}, ErrPermissionTaken
}
if err != nil {
return Permission{}, err
}
return item, nil
}
// DeletePermission removes a catalog entry that no role grants. Built-in
// permissions are kept: the console enforces them and dropping one would leave a
// membership holding a capability nothing recognizes.
func (s *Store) DeletePermission(ctx context.Context, actorID, name string) error {
permitted, err := s.mayAdministerCatalog(ctx, actorID)
if err != nil {
return err
}
if !permitted {
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var (
label string
system bool
grants int
)
err = tx.QueryRowContext(ctx, `
SELECT label, is_system,
(SELECT count(*) FROM role_permissions rp WHERE rp.permission = permissions.name)
FROM permissions WHERE name = $1 FOR UPDATE`, name).
Scan(&label, &system, &grants)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if system {
return ErrPermissionSystem
}
if grants > 0 {
return ErrPermissionInUse
}
if _, err := tx.ExecContext(ctx, `DELETE FROM permissions WHERE name = $1`, name); err != nil {
return err
}
return tx.Commit()
}
// mayAdministerCatalog reports whether the actor may change the shared catalog.
// The catalog is installation-wide, so this is a system administrator or an owner
// of some tenant, which is the permission that already means "administer the
// installation".
func (s *Store) mayAdministerCatalog(ctx context.Context, actorID string) (bool, error) {
return mayAdministerCatalog(ctx, s.db, actorID)
}
func mayAdministerCatalog(ctx context.Context, q querier, actorID string) (bool, error) {
var permitted bool
err := q.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"),
actorID, string(role.ManageTenants)).Scan(&permitted)
return permitted, err
}
// grantablePermissions returns what an actor may hand to somebody: the
// permissions they hold, plus -- for whoever administers the shared catalog --
// every custom permission.
//
// The second half is what makes the catalog usable. A permission nobody holds
// yet could not otherwise be granted, because the escalation guard refuses to
// hand out a capability the granter does not have. Letting the author of a
// permission pass it on resolves that without weakening the guard for enforced
// permissions, which are never in the custom set.
func grantablePermissions(ctx context.Context, q querier, actorID, tenantID string) ([]string, error) {
held, err := heldPermissions(ctx, q, actorID, tenantID)
if err != nil {
return nil, err
}
administers, err := mayAdministerCatalog(ctx, q, actorID)
if err != nil || !administers {
return held, err
}
rows, err := q.QueryContext(ctx, `SELECT name FROM permissions WHERE NOT is_system`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
if !slices.Contains(held, name) {
held = append(held, name)
}
}
return held, rows.Err()
}
// permissionOrder lists the enforced capabilities so the catalog keeps the same
// most-sensitive-first order the role editor used before it was a table.
var permissionOrder = `ARRAY[
'tenants.manage', 'roles.manage', 'users.manage', 'applications.manage',
'keys.manage', 'contracts.manage', 'users.read', 'console.read', 'profile.edit'
]`

View file

@ -0,0 +1,224 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
func TestCatalogSeedsTheEnforcedPermissions(t *testing.T) {
s, _, ctx := integrationStore(t)
catalog, err := s.ListPermissions(ctx)
if err != nil {
t.Fatalf("list permissions: %v", err)
}
if len(catalog) != len(role.Permissions()) {
t.Fatalf("catalog has %d entries, want the %d seeded permissions", len(catalog), len(role.Permissions()))
}
for _, item := range catalog {
if !item.System {
t.Errorf("%q was not marked built in", item.Name)
}
if !item.Enforced {
t.Errorf("%q is seeded but not reported as enforced", item.Name)
}
if item.Label == "" {
t.Errorf("%q has no label", item.Name)
}
}
// The order the role editor renders is most sensitive first.
if catalog[0].Name != string(role.ManageTenants) {
t.Errorf("catalog starts with %q, want %q", catalog[0].Name, role.ManageTenants)
}
}
func TestTenantCanAddAPermissionAndGrantIt(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs").ID
created, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", "Lets an app export reports.")
if err != nil {
t.Fatalf("create permission: %v", err)
}
if created.System {
t.Error("a permission added through the API must not be marked built in")
}
if created.Enforced {
t.Error("a custom permission cannot be enforced by the console")
}
// It is immediately grantable, exactly like a built-in one.
if _, err := s.CreateRole(ctx, owner, tenant, "reporter", "Reporter", "",
[]string{string(role.ReadConsole), "reports.export"}); err != nil {
t.Fatalf("grant a custom permission: %v", err)
}
catalog, err := s.ListPermissions(ctx)
if err != nil {
t.Fatalf("list permissions: %v", err)
}
found := false
for _, item := range catalog {
if item.Name == "reports.export" {
found = true
if item.RoleCount != 1 {
t.Errorf("role_count = %d, want 1", item.RoleCount)
}
}
}
if !found {
t.Fatal("the new permission is missing from the catalog")
}
}
func TestDeletingAPermissionInUseIsRefused(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs").ID
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", ""); err != nil {
t.Fatalf("create permission: %v", err)
}
reporter, err := s.CreateRole(ctx, owner, tenant, "reporter", "Reporter", "",
[]string{string(role.ReadConsole), "reports.export"})
if err != nil {
t.Fatalf("create role: %v", err)
}
if err := s.DeletePermission(ctx, owner, "reports.export"); !errors.Is(err, store.ErrPermissionInUse) {
t.Fatalf("delete a granted permission = %v, want ErrPermissionInUse", err)
}
// Once no role grants it, the delete goes through.
if _, err := s.UpdateRole(ctx, owner, reporter.ID, "Reporter", "",
[]string{string(role.ReadConsole)}); err != nil {
t.Fatalf("drop the custom permission: %v", err)
}
if err := s.DeletePermission(ctx, owner, "reports.export"); err != nil {
t.Fatalf("delete an unused permission: %v", err)
}
}
func TestBuiltInPermissionsCannotBeDeleted(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
newTenant(t, s, ctx, owner, "nmbs")
if err := s.DeletePermission(ctx, owner, string(role.ManageUsers)); !errors.Is(err, store.ErrPermissionSystem) {
t.Fatalf("delete a built-in permission = %v, want ErrPermissionSystem", err)
}
}
func TestDuplicateAndMalformedPermissionsAreRejected(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
newTenant(t, s, ctx, owner, "nmbs")
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export", ""); err != nil {
t.Fatalf("create permission: %v", err)
}
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export again", ""); !errors.Is(err, store.ErrPermissionTaken) {
t.Fatalf("duplicate permission = %v, want ErrPermissionTaken", err)
}
// A name the console already enforces cannot be redefined.
if _, err := s.CreatePermission(ctx, owner, string(role.ManageUsers), "Manage", ""); !errors.Is(err, store.ErrPermissionTaken) {
t.Fatalf("redefining a built-in = %v, want ErrPermissionTaken", err)
}
for _, name := range []string{"Reports.Export", "reports export", ".reports", "reports.", "", "ab"} {
if _, err := s.CreatePermission(ctx, owner, name, "Label", ""); err == nil {
t.Errorf("CreatePermission(%q) was accepted", name)
}
}
}
// TestOnlyOwnersMayChangeTheSharedCatalog guards the fact that the catalog is
// installation-wide: a developer who may maintain their own tenant's members
// must not be able to add a capability every tenant can then grant.
func TestOnlyOwnersMayChangeTheSharedCatalog(t *testing.T) {
s, ctx, _, developer, _ := rolesFixture(t)
if _, err := s.CreatePermission(ctx, developer, "reports.export", "Export reports", ""); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer adding a permission = %v, want ErrNotFound", err)
}
if err := s.DeletePermission(ctx, developer, string(role.ReadConsole)); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer deleting a permission = %v, want ErrNotFound", err)
}
}
func TestARoleCannotGrantAPermissionThatIsNotInTheCatalog(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
_, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "",
[]string{string(role.ReadConsole), "not.in.the.catalog"})
if !errors.Is(err, store.ErrUnknownPermission) {
t.Fatalf("granting an unknown permission = %v, want ErrUnknownPermission", err)
}
// Nor can an existing role be edited to hold one.
if _, err := s.UpdateRole(ctx, owner, mustSystemRole(t, s, ctx, "viewer"), "Viewer", "",
[]string{string(role.ReadConsole), "not.in.the.catalog"}); !errors.Is(err, store.ErrUnknownPermission) {
t.Fatalf("editing in an unknown permission = %v, want ErrUnknownPermission", err)
}
}
// TestGrantingACustomPermissionNeedsTheRightAuthor documents the rule that makes
// the catalog usable without weakening the escalation guard. Whoever administers
// the catalog authored the permission, so they may pass it on even though nobody
// holds it yet; anybody else still needs to hold it first.
func TestGrantingACustomPermissionNeedsTheRightAuthor(t *testing.T) {
s, ctx, owner, developer, tenantID := rolesFixture(t)
if _, err := s.CreatePermission(ctx, owner, "reports.export", "Export reports", ""); err != nil {
t.Fatalf("create permission: %v", err)
}
// The owner administers the catalog, so they can define a role granting it.
// The role also carries users.manage so that whoever ends up holding it is
// actually able to assign roles to somebody else.
created, err := s.CreateRole(ctx, owner, tenantID, "reporter", "Reporter", "",
[]string{string(role.ReadConsole), string(role.ManageUsers), "reports.export"})
if err != nil {
t.Fatalf("owner granting a custom permission: %v", err)
}
// A developer administers members but not the catalog, and does not hold the
// permission, so they may not hand the role out.
member := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer")
if err := s.SetUserAccess(ctx, developer, tenantID, member, created.ID, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer assigning a role carrying an unheld custom permission = %v, want ErrNotFound", err)
}
// Nor may they mint an equivalent role of their own.
if _, err := s.CreateRole(ctx, developer, tenantID, "rogue", "Rogue", "",
[]string{string(role.ReadConsole), string(role.ManageUsers), "reports.export"}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer minting a custom permission = %v, want ErrNotFound", err)
}
// A member who does hold it, and may manage members, may pass it on.
reporter := addMember(t, s, ctx, owner, tenantID, "reporter@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenantID, reporter, created.ID, nil); err != nil {
t.Fatalf("owner assigning the role: %v", err)
}
if err := s.SetUserAccess(ctx, reporter, tenantID, member, created.ID, nil); err != nil {
t.Fatalf("holder passing the permission on: %v", err)
}
// Holding the permission is not enough on its own: a role without
// users.manage cannot assign anything.
readonly, err := s.CreateRole(ctx, owner, tenantID, "readonly-reporter", "Read only reporter", "",
[]string{string(role.ReadConsole), "reports.export"})
if err != nil {
t.Fatalf("create read-only role: %v", err)
}
if err := s.SetUserAccess(ctx, owner, tenantID, reporter, mustSystemRole(t, s, ctx, "viewer"), nil); err != nil {
t.Fatalf("move the reporter aside: %v", err)
}
if err := s.SetUserAccess(ctx, reporter, tenantID, member, readonly.ID, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("assigning without users.manage = %v, want ErrNotFound", err)
}
}
func mustSystemRole(t *testing.T, s *store.Store, ctx context.Context, slug string) string {
t.Helper()
id, err := s.SystemRoleID(ctx, slug)
if err != nil {
t.Fatalf("look up %q: %v", slug, err)
}
return id
}

View file

@ -36,9 +36,9 @@ var (
ErrRoleInUse = errors.New("role is still assigned to members")
// ErrRoleSlugTaken is returned when a tenant already uses the requested slug.
ErrRoleSlugTaken = errors.New("role slug is already in use")
// ErrUnknownPermission is returned when a role is defined with a permission
// outside the vocabulary. It is a caller mistake rather than a server fault,
// so callers report it as a bad request.
// ErrUnknownPermission is returned when a role or catalog entry names a
// permission that is not in the catalog. It is a caller mistake rather than a
// server fault, so callers report it as a bad request.
ErrUnknownPermission = errors.New("unknown permission")
)
@ -143,25 +143,54 @@ func within(granted, held []string) bool {
return true
}
// normalizePermissions validates submitted permission names and returns them
// deduplicated in vocabulary order, so a role cannot be stored with an invented
// capability or an unstable order.
func normalizePermissions(permissions []string) ([]string, error) {
// normalizePermissions checks that every submitted name is in the catalog and
// returns them deduplicated in a stable order. Validation moved from the Go
// vocabulary to the database when permissions became rows, so a tenant-defined
// permission can be granted like any other while an invented one still cannot be
// stored.
func normalizePermissions(ctx context.Context, q querier, permissions []string) ([]string, error) {
requested := make(map[string]bool, len(permissions))
for _, value := range permissions {
parsed, ok := role.Parse(value)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, value)
name := strings.TrimSpace(value)
if name == "" {
continue
}
requested[string(parsed)] = true
var known bool
if err := q.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM permissions WHERE name = $1)`, name).Scan(&known); err != nil {
return nil, err
}
if !known {
return nil, fmt.Errorf("%w: %q", ErrUnknownPermission, name)
}
requested[name] = true
}
rows, err := q.QueryContext(ctx, `
SELECT name FROM permissions
WHERE name = ANY($1)
ORDER BY array_position(`+permissionOrder+`, name) NULLS LAST, name`, keys(requested))
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0, len(requested))
for _, permission := range role.Permissions() {
if requested[string(permission)] {
result = append(result, string(permission))
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, name)
}
return result, nil
return result, rows.Err()
}
// keys returns the members of a set as a slice, for use as a SQL array parameter.
func keys(set map[string]bool) []string {
result := make([]string, 0, len(set))
for name := range set {
result = append(result, name)
}
return result
}
// roleGrants reads a role's permissions.
@ -280,21 +309,28 @@ func roleByID(ctx context.Context, q querier, roleID string) (Role, error) {
// CreateRole adds a tenant's own role. The caller must hold roles.manage and may
// not mint a role more powerful than one they already hold.
func (s *Store) CreateRole(ctx context.Context, actorID, tenantID, slug, name, description string, permissions []string) (Role, error) {
granted, err := normalizePermissions(permissions)
if err != nil {
return Role{}, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Role{}, err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, actorID, tenantID)
granted, err := normalizePermissions(ctx, tx, permissions)
if err != nil {
return Role{}, err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
permitted, err := hasPermission(ctx, tx, actorID, tenantID, role.ManageRoles)
if err != nil {
return Role{}, err
}
if !permitted {
return Role{}, ErrNotFound
}
grantable, err := grantablePermissions(ctx, tx, actorID, tenantID)
if err != nil {
return Role{}, err
}
if !within(granted, grantable) {
return Role{}, ErrNotFound
}
created, err := insertRole(ctx, tx, tenantID, slug, name, description)
@ -316,15 +352,15 @@ func (s *Store) CreateRole(ctx context.Context, actorID, tenantID, slug, name, d
// UpdateRole renames a role and replaces its permissions. Built-in roles are
// immutable and a tenant may only edit a role it owns.
func (s *Store) UpdateRole(ctx context.Context, actorID, roleID, name, description string, permissions []string) (Role, error) {
granted, err := normalizePermissions(permissions)
if err != nil {
return Role{}, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Role{}, err
}
defer tx.Rollback()
granted, err := normalizePermissions(ctx, tx, permissions)
if err != nil {
return Role{}, err
}
existing, err := lockRole(ctx, tx, roleID)
if err != nil {
@ -333,11 +369,18 @@ func (s *Store) UpdateRole(ctx context.Context, actorID, roleID, name, descripti
if existing.System || existing.TenantID == nil {
return Role{}, ErrNotFound
}
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles)
if err != nil {
return Role{}, err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
if !permitted {
return Role{}, ErrNotFound
}
grantable, err := grantablePermissions(ctx, tx, actorID, *existing.TenantID)
if err != nil {
return Role{}, err
}
if !within(granted, grantable) {
return Role{}, ErrNotFound
}
if _, err := tx.ExecContext(ctx, `
@ -379,11 +422,18 @@ func (s *Store) DeleteRole(ctx context.Context, actorID, roleID string) error {
if err != nil {
return err
}
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
permitted, err := hasPermission(ctx, tx, actorID, *existing.TenantID, role.ManageRoles)
if err != nil {
return err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(permissions, held) {
if !permitted {
return ErrNotFound
}
grantable, err := grantablePermissions(ctx, tx, actorID, *existing.TenantID)
if err != nil {
return err
}
if !within(permissions, grantable) {
return ErrNotFound
}
if existing.MemberCount > 0 {

View file

@ -15,6 +15,9 @@ import (
var ErrNotFound = errors.New("not found")
// ErrTenantSlugTaken is returned when a tenant already uses the requested slug.
var ErrTenantSlugTaken = errors.New("tenant slug is already in use")
type Store struct {
db *sql.DB
}
@ -129,13 +132,21 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
if err != nil {
return err
}
// Ownership is granted only while the tenant still has none. The condition is
// about owners rather than about members: the installer's demonstration seed
// creates tenants that already hold administrators, and refusing ownership
// there would leave a local administrator bootstrapped afterwards with an
// account that can never reach the console. A tenant that already has an owner
// is still never taken over by a later bootstrap.
if _, err = tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT $1, $2, r.id
FROM roles r
WHERE r.slug = $3 AND r.tenant_id IS NULL
AND (NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2))
AND NOT EXISTS (
SELECT 1 FROM memberships m
JOIN roles held ON held.id = m.role_id
WHERE m.tenant_id = $1 AND held.slug = 'owner')
ON CONFLICT DO NOTHING`, tenantID, userID, "owner"); err != nil {
return err
}
@ -198,6 +209,11 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te
var tenant Tenant
err = tx.QueryRowContext(ctx, `INSERT INTO tenants(slug, name) VALUES ($1, $2) RETURNING id, slug, name`, slug, name).
Scan(&tenant.ID, &tenant.Slug, &tenant.Name)
if isUniqueViolation(err) {
// Reporting this as a generic failure would surface a 500 for what is an
// ordinary mistake, and the console has no way to explain it.
return Tenant{}, ErrTenantSlugTaken
}
if err != nil {
return Tenant{}, err
}