This commit is contained in:
Daniel Noulet 2026-10-04 16:57:30 +02:00
parent 6319da650c
commit 3fdc8cb489
26 changed files with 2245 additions and 488 deletions

View file

@ -16,6 +16,7 @@ type ManagedUser struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Permissions []string `json:"permissions"`
Status string `json:"status"`
ApplicationIDs []string `json:"application_ids"`
@ -46,7 +47,9 @@ type ClientUser struct {
// Role is the membership role in the tenant owning the application. A user
// without a membership is reported as User so clients always see the least
// privileged role.
Role string `json:"role"`
Role string `json:"role"`
// Permissions is read from the role's own rows. A user without a membership
// gets the permissions of the built-in User role.
Permissions []string `json:"permissions"`
// AvatarKey is the stored locator; the server expands it into AvatarURL.
AvatarKey *string `json:"-"`
@ -55,9 +58,13 @@ type ClientUser struct {
func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, m.tenant_id, u.email, u.display_name, m.role,
SELECT u.id, m.tenant_id, u.email, u.display_name, r.slug, m.role_id,
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
u.avatar_key,
COALESCE((
SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp WHERE rp.role_id = m.role_id
), '[]'::json),
COALESCE((
SELECT json_agg(ua.application_id ORDER BY ua.application_id)
FROM user_application_access ua
@ -66,12 +73,9 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
), '[]'::json)
FROM users u
JOIN memberships m ON m.user_id = u.id
WHERE EXISTS (
SELECT 1 FROM memberships own
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1
AND own.role = ANY($2)
)
ORDER BY u.email, m.tenant_id`, administratorID, role.Grants(role.ManageUsers))
JOIN roles r ON r.id = m.role_id
WHERE `+permissionPredicate("m.tenant_id", "$1", "$2")+`
ORDER BY u.email, m.tenant_id`, administratorID, string(role.ManageUsers))
if err != nil {
return nil, err
}
@ -79,37 +83,42 @@ func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([
result := make([]ManagedUser, 0)
for rows.Next() {
var user ManagedUser
var applicationIDs []byte
var permissions, applicationIDs []byte
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role,
&user.Status, &user.AvatarKey, &applicationIDs); err != nil {
&user.RoleID, &user.Status, &user.AvatarKey, &permissions, &applicationIDs); err != nil {
return nil, err
}
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
return nil, err
}
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
return nil, err
}
user.Permissions = role.Role(user.Role).Permissions()
result = append(result, user)
}
return result, rows.Err()
}
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, membershipRole, passwordHash string) (ManagedUser, error) {
// InviteUser adds a member to a tenant or, when the email already exists,
// re-invites them. The caller must hold users.manage in that tenant and may only
// hand out a role whose permissions they hold themselves.
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, roleID string, passwordHash string) (ManagedUser, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return ManagedUser{}, err
}
defer tx.Rollback()
var permitted bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM memberships
WHERE tenant_id = $1 AND user_id = $2 AND role = ANY($3)
)`, tenantID, administratorID, role.Grants(role.ManageUsers)).Scan(&permitted); err != nil {
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return ManagedUser{}, err
}
if !permitted {
if !within([]string{string(role.ManageUsers)}, held) {
return ManagedUser{}, ErrNotFound
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
if err != nil {
return ManagedUser{}, err
}
var user ManagedUser
err = tx.QueryRowContext(ctx, `
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
@ -125,37 +134,65 @@ func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email
return ManagedUser{}, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, user.ID, membershipRole); err != nil {
INSERT INTO memberships(tenant_id, user_id, role_id) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
tenantID, user.ID, resolved); err != nil {
return ManagedUser{}, err
}
if err := tx.Commit(); err != nil {
return ManagedUser{}, err
}
user.TenantID, user.Role, user.ApplicationIDs = tenantID, membershipRole, []string{}
user.Permissions = role.Role(membershipRole).Permissions()
granted, err := roleGrants(ctx, s.db, resolved)
if err != nil {
return ManagedUser{}, err
}
user.TenantID, user.RoleID, user.ApplicationIDs = tenantID, resolved, []string{}
if err := s.db.QueryRowContext(ctx, `SELECT slug FROM roles WHERE id = $1`, resolved).Scan(&user.Role); err != nil {
return ManagedUser{}, err
}
user.Permissions = granted
return user, nil
}
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, membershipRole string, applicationIDs []string) error {
// SetUserAccess changes a member's role and replaces their application grants.
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
held, err := heldPermissions(ctx, tx, administratorID, tenantID)
if err != nil {
return err
}
if !within([]string{string(role.ManageUsers)}, held) {
return ErrNotFound
}
resolved, err := resolveRoleID(ctx, tx, tenantID, roleID, held)
if err != nil {
return err
}
if err := ensureTenantKeepsAnOwner(ctx, tx, tenantID, userID, resolved); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `
UPDATE memberships target SET role = $4
FROM memberships administrator
WHERE target.tenant_id = $1 AND target.user_id = $2
AND administrator.tenant_id = target.tenant_id
AND administrator.user_id = $3 AND administrator.role = ANY($5)`,
tenantID, userID, administratorID, membershipRole, role.Grants(role.ManageUsers))
UPDATE memberships SET role_id = $3
WHERE tenant_id = $1 AND user_id = $2 AND role_id IS DISTINCT FROM $3`,
tenantID, userID, resolved)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
// Either the membership does not exist or the role is unchanged.
var exists bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)`,
tenantID, userID).Scan(&exists); err != nil {
return err
}
if !exists {
return ErrNotFound
}
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM user_application_access ua
@ -179,17 +216,49 @@ func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, us
return tx.Commit()
}
// ErrLastOwner is returned when a change would leave a tenant without an owner.
var ErrLastOwner = errors.New("a tenant must keep at least one owner")
// ensureTenantKeepsAnOwner blocks demoting or moving away the only membership
// that still holds the owner role. Without this a tenant administrator could
// lock every administrator out of their own tenant with a single edit.
func ensureTenantKeepsAnOwner(ctx context.Context, tx *sql.Tx, tenantID, userID, newRoleID string) error {
var losesOwner bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM memberships m
JOIN roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND m.user_id = $2 AND r.slug = 'owner' AND m.role_id <> $3
)`, tenantID, userID, newRoleID).Scan(&losesOwner); err != nil {
return err
}
if !losesOwner {
return nil
}
var remaining int
if err := tx.QueryRowContext(ctx, `
SELECT count(*)
FROM memberships m
JOIN roles r ON r.id = m.role_id
WHERE m.tenant_id = $1 AND r.slug = 'owner' AND m.user_id <> $2`,
tenantID, userID).Scan(&remaining); err != nil {
return err
}
if remaining == 0 {
return ErrLastOwner
}
return nil
}
func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicationID string, redirectURIs []string, secretHash []byte) (Application, error) {
var app Application
var encoded []byte
err := s.db.QueryRowContext(ctx, `
UPDATE applications a
SET redirect_uris = $4, client_secret_hash = $5, updated_at = now()
FROM memberships m
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2
AND m.role = ANY($3)
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$2", "$3")+`
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
applicationID, administratorID, role.Grants(role.ManageApplications), redirectURIs, secretHash).
applicationID, administratorID, string(role.ManageApplications), redirectURIs, secretHash).
Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrNotFound
@ -263,6 +332,7 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
}
defer tx.Rollback()
var user ClientUser
var permissions []byte
err = tx.QueryRowContext(ctx, `
UPDATE client_authorization_codes c SET used_at = now()
FROM applications a
@ -270,21 +340,25 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
JOIN user_application_access ua ON ua.application_id = a.id
JOIN users u ON u.id = ua.user_id
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
LEFT JOIN roles r ON r.id = m.role_id
WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now()
AND c.redirect_uri = $2 AND a.id = c.application_id
AND ua.user_id = c.user_id AND ua.application_id = a.id
AND a.client_id = $3 AND a.client_secret_hash = $4
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
COALESCE(m.role, $5), u.avatar_key`,
codeHash, redirectURI, clientID, secretHash, role.User).
COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`,
codeHash, redirectURI, clientID, secretHash).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
&user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, time.Time{}, ErrNotFound
}
if err != nil {
return ClientUser{}, time.Time{}, err
}
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
return ClientUser{}, time.Time{}, err
}
expiresAt := time.Now().UTC().Add(12 * time.Hour)
if _, err := tx.ExecContext(ctx, `
INSERT INTO client_user_sessions(token_hash, application_id, user_id, expires_at)
@ -296,6 +370,7 @@ func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirec
func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (ClientUser, error) {
var user ClientUser
var permissions []byte
err := s.db.QueryRowContext(ctx, `
UPDATE client_user_sessions s SET last_used_at = now()
FROM applications a
@ -303,17 +378,24 @@ func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (Client
JOIN user_application_access ua ON ua.application_id = a.id
JOIN users u ON u.id = ua.user_id
LEFT JOIN memberships m ON m.tenant_id = t.id AND m.user_id = u.id
LEFT JOIN roles r ON r.id = m.role_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
AND a.id = s.application_id AND ua.user_id = s.user_id
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug,
COALESCE(m.role, $2), u.avatar_key`,
tokenHash, role.User).
COALESCE(r.slug, 'user'), `+clientPermissions+`, u.avatar_key`,
tokenHash).
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug,
&user.ApplicationID, &user.Application, &user.Role, &user.AvatarKey)
&user.ApplicationID, &user.Application, &user.Role, &permissions, &user.AvatarKey)
if errors.Is(err, sql.ErrNoRows) {
return ClientUser{}, ErrNotFound
}
return user, err
if err != nil {
return ClientUser{}, err
}
if err := json.Unmarshal(permissions, &user.Permissions); err != nil {
return ClientUser{}, err
}
return user, nil
}
func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error {

View file

@ -80,9 +80,11 @@ func (s *Store) Install(ctx context.Context, installation Installation, demoUser
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, userID, role.Admin); err != nil {
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
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
tenantID, userID, "admin"); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
@ -115,8 +117,10 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
return false, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT id, $1, $2 FROM tenants ON CONFLICT DO NOTHING`, userID, role.Owner); err != nil {
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT id, $1, r.id FROM tenants, 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, `
@ -128,14 +132,14 @@ func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string
}
// HasConsoleAccess reports whether the user may read the management console.
// System administrators always qualify; otherwise the membership role must
// System administrators always qualify; otherwise one of their memberships must
// grant console.read, which excludes self-service User members.
func (s *Store) HasConsoleAccess(ctx context.Context, userID string) (bool, error) {
var allowed bool
err := s.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1 AND role = ANY($2))`,
userID, role.Grants(role.ReadConsole)).Scan(&allowed)
OR `+permissionPredicate("scoped.tenant_id", "$1", "$2"),
userID, string(role.ReadConsole)).Scan(&allowed)
return allowed, err
}
@ -183,7 +187,11 @@ func (s *Store) ListPendingUsers(ctx context.Context, administratorID string) ([
return result, rows.Err()
}
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
// ApprovePendingUser gives a waiting-room visitor their first membership.
// Only a system administrator may do this, so the escalation guard that
// normally limits role assignment does not apply: the administrator is acting
// across tenants rather than inside one.
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, roleID string, applicationIDs []string) error {
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
if err != nil {
return err
@ -196,11 +204,17 @@ func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantI
return err
}
defer tx.Rollback()
if _, err := resolveRoleID(ctx, tx, tenantID, roleID, role.Values()); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT $1, id, $3 FROM users WHERE id = $2
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
tenantID, userID, role)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role_id = EXCLUDED.role_id`,
tenantID, userID, roleID)
if err != nil {
return err
}
if err != nil {
return err
}

View file

@ -107,10 +107,14 @@ func newTenant(t *testing.T, s *store.Store, ctx context.Context, owner, slug st
return tenant
}
// addMember invites a member into the tenant with the given role.
// addMember invites a member into the tenant with the given built-in role.
func addMember(t *testing.T, s *store.Store, ctx context.Context, owner string, tenantID, email, membershipRole string) string {
t.Helper()
member, err := s.InviteUser(ctx, owner, tenantID, email, email, membershipRole, "not-a-real-hash")
roleID, err := s.SystemRoleID(ctx, membershipRole)
if err != nil {
t.Fatalf("look up role %q: %v", membershipRole, err)
}
member, err := s.InviteUser(ctx, owner, tenantID, email, email, roleID, "not-a-real-hash")
if err != nil {
t.Fatalf("invite %s as %s: %v", email, membershipRole, err)
}
@ -124,21 +128,21 @@ func TestMigrationsApplyAndRolesAreAccepted(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
for _, item := range []role.Role{role.Developer, role.Operator, role.User} {
addMember(t, s, ctx, owner, tenant.ID, string(item)+"@example.test", string(item))
for _, item := range []string{"developer", "operator", "user"} {
addMember(t, s, ctx, owner, tenant.ID, item+"@example.test", item)
}
if _, err := s.ListTenants(ctx, owner); err != nil {
t.Fatalf("list tenants: %v", err)
}
}
func TestMembershipRoleConstraintRejectsUnknownRole(t *testing.T) {
func TestUnknownRoleIDIsRefused(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Viewer))
if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "superuser", nil); err == nil {
t.Fatal("the database must reject a role outside the permission matrix")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenant.ID, member, "00000000-0000-0000-0000-000000000000", nil); err == nil {
t.Fatal("assigning a role that does not exist must be refused")
}
}
@ -153,24 +157,26 @@ func TestPermissionMatrixIsEnforcedInQueries(t *testing.T) {
t.Fatalf("owner cannot create an application: %v", err)
}
tests := []struct {
role role.Role
role string
canCreateApp bool
canIssueKey bool
canSaveContract bool
canListManaged bool
canConfigureClien bool
}{
{role: role.Owner, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: role.Admin, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: role.Developer, canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: true},
{role: role.Operator, canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: false, canConfigureClien: false},
{role: role.Viewer, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
{role: role.User, canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
// owner and admin administer members; developer and operator may also
// maintain their own tenant's members, which is why they can list them.
{role: "owner", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: "admin", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: "developer", canCreateApp: true, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: true},
{role: "operator", canCreateApp: false, canIssueKey: true, canSaveContract: true, canListManaged: true, canConfigureClien: false},
{role: "viewer", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
{role: "user", canCreateApp: false, canIssueKey: false, canSaveContract: false, canListManaged: false, canConfigureClien: false},
}
for _, test := range tests {
t.Run(string(test.role), func(t *testing.T) {
member := addMember(t, s, ctx, owner, tenant.ID, string(test.role)+"@example.test", string(test.role))
slug := strings.ReplaceAll(string(test.role), "owner", "own")
t.Run(test.role, func(t *testing.T) {
member := addMember(t, s, ctx, owner, tenant.ID, test.role+"@example.test", test.role)
slug := strings.ReplaceAll(test.role, "owner", "own")
_, err := s.CreateApplication(ctx, member, store.Application{
TenantID: tenant.ID, Slug: slug, Name: slug, AllowedHosts: []string{},
@ -201,8 +207,8 @@ func TestConsoleAccessExcludesSelfServiceMembers(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
selfService := addMember(t, s, ctx, owner, tenant.ID, "enduser@example.test", string(role.User))
operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", string(role.Operator))
selfService := addMember(t, s, ctx, owner, tenant.ID, "enduser@example.test", "user")
operator := addMember(t, s, ctx, owner, tenant.ID, "operator@example.test", "operator")
waiting := newUser(t, s, ctx, "waiting@example.test")
for _, test := range []struct {
@ -239,20 +245,25 @@ func TestTenantPermissionsComeFromTheRoleMatrix(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
developer := addMember(t, s, ctx, owner, tenant.ID, "dev@example.test", string(role.Developer))
developer := addMember(t, s, ctx, owner, tenant.ID, "dev@example.test", "developer")
tenants, err := s.ListTenants(ctx, developer)
if err != nil {
t.Fatalf("list tenants: %v", err)
}
if len(tenants) != 1 || tenants[0].Role != string(role.Developer) {
if len(tenants) != 1 || tenants[0].Role != "developer" {
t.Fatalf("tenants = %+v", tenants)
}
if !contains(tenants[0].Permissions, string(role.ManageApplications)) {
t.Fatalf("developer permissions = %v", tenants[0].Permissions)
}
if contains(tenants[0].Permissions, string(role.ManageUsers)) {
t.Fatalf("developer permissions = %v, must not manage users", tenants[0].Permissions)
// developer administers members but must not mint roles, which is what
// defines privilege in the first place.
if !contains(tenants[0].Permissions, string(role.ManageUsers)) {
t.Fatalf("developer permissions = %v, want users.manage", tenants[0].Permissions)
}
if contains(tenants[0].Permissions, string(role.ManageRoles)) {
t.Fatalf("developer permissions = %v, must not manage roles", tenants[0].Permissions)
}
}
@ -260,7 +271,7 @@ func TestProfileRoundTrip(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Operator))
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator")
before, err := s.Profile(ctx, member)
if err != nil {
@ -269,7 +280,7 @@ func TestProfileRoundTrip(t *testing.T) {
if before.AvatarKey != nil || before.NameCustomized {
t.Fatalf("fresh profile = %+v", before)
}
if len(before.Memberships) != 1 || before.Memberships[0].Role != string(role.Operator) {
if len(before.Memberships) != 1 || before.Memberships[0].Role != "operator" {
t.Fatalf("memberships = %+v", before.Memberships)
}
if !contains(before.Memberships[0].Permissions, string(role.ManageKeys)) {
@ -329,7 +340,7 @@ func TestPictureIsStoredAndReadableByKey(t *testing.T) {
s, _, ctx := integrationStore(t)
owner := newUser(t, s, ctx, "owner@example.test")
tenant := newTenant(t, s, ctx, owner, "nmbs")
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Viewer))
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "viewer")
key := "Zq1Yb3l0Rk9pT2h3ZjQ4eXh3YjFkNGQ3OTFhMmNkYThl"
payload := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4}
@ -425,7 +436,7 @@ func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) {
if err != nil {
t.Fatalf("create application: %v", err)
}
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", string(role.Operator))
member := addMember(t, s, ctx, owner, tenant.ID, "member@example.test", "operator")
if _, err := s.ConfigureClient(ctx, owner, application.ID, []string{"https://app.example.test/callback"}, []byte("hash")); err != nil {
t.Fatalf("configure client: %v", err)
}
@ -443,7 +454,7 @@ func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) {
if err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
if user.Role != string(role.Operator) {
if user.Role != "operator" {
t.Fatalf("role = %q, want operator", user.Role)
}
if user.AvatarKey == nil {
@ -453,7 +464,7 @@ func TestClientSessionReportsMembershipRoleAndPicture(t *testing.T) {
if err != nil {
t.Fatalf("client user by token: %v", err)
}
if byToken.Role != string(role.Operator) || byToken.AvatarKey == nil {
if byToken.Role != "operator" || byToken.AvatarKey == nil {
t.Fatalf("client user = %+v", byToken)
}
}
@ -480,8 +491,8 @@ func TestClientSessionWithoutMembershipFallsBackToUserRole(t *testing.T) {
if err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
if user.Role != string(role.User) {
t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, role.User)
if user.Role != "user" {
t.Fatalf("role = %q, want the least privileged fallback %q", user.Role, "user")
}
}

View file

@ -3,10 +3,9 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/danyel/go-loose/internal/role"
)
// Membership is one tenant role held by a user.
@ -15,6 +14,7 @@ type Membership struct {
TenantSlug string `json:"tenant_slug"`
TenantName string `json:"tenant_name"`
Role string `json:"role"`
RoleID string `json:"role_id"`
Permissions []string `json:"permissions"`
}
@ -77,9 +77,14 @@ func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) {
}
profile.Memberships = []Membership{}
rows, err := s.db.QueryContext(ctx, `
SELECT m.tenant_id, t.slug, t.name, m.role
SELECT m.tenant_id, t.slug, t.name, r.slug, m.role_id,
COALESCE((
SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp WHERE rp.role_id = m.role_id
), '[]'::json)
FROM memberships m
JOIN tenants t ON t.id = m.tenant_id
JOIN roles r ON r.id = m.role_id
WHERE m.user_id = $1
ORDER BY t.name`, userID)
if err != nil {
@ -88,10 +93,14 @@ func (s *Store) Profile(ctx context.Context, userID string) (Profile, error) {
defer rows.Close()
for rows.Next() {
var item Membership
if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role); err != nil {
var permissions []byte
if err := rows.Scan(&item.TenantID, &item.TenantSlug, &item.TenantName, &item.Role,
&item.RoleID, &permissions); err != nil {
return Profile{}, err
}
if err := json.Unmarshal(permissions, &item.Permissions); err != nil {
return Profile{}, err
}
item.Permissions = role.Role(item.Role).Permissions()
profile.Memberships = append(profile.Memberships, item)
}
return profile, rows.Err()

455
internal/store/roles.go Normal file
View file

@ -0,0 +1,455 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/danyel/go-loose/internal/role"
)
// Role is a named bundle of permissions. A role with no TenantID is built in:
// every tenant sees it and it cannot be edited or deleted. A role with a
// TenantID belongs to that tenant alone.
type Role struct {
ID string `json:"id"`
TenantID *string `json:"tenant_id,omitempty"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
System bool `json:"system"`
// Permissions is sorted so that the console can compare two roles without
// worrying about the order they were stored in.
Permissions []string `json:"permissions"`
MemberCount int `json:"member_count"`
// Assignable reports whether the requesting administrator may hand this role
// to somebody. A role is assignable only when every permission it grants is
// one the administrator already holds, so managing members can never be used
// to grant more authority than the administrator has.
Assignable bool `json:"assignable"`
}
var (
// ErrRoleInUse is returned when a role that still has members is deleted.
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 = errors.New("unknown permission")
)
// querier is satisfied by both *sql.DB and *sql.Tx so that the authorization
// helpers can run inside or outside a transaction.
type querier interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// scanner is satisfied by both *sql.Row and *sql.Rows.
type scanner interface {
Scan(dest ...any) error
}
// permissionPredicate renders the one authorization check used by every
// tenant-scoped statement: it holds when the user holds permission in the
// tenant. The arguments are SQL expressions rather than placeholders because the
// queries that need it have already joined memberships under a name of their
// own. Resolving the grant through role_permissions is what lets a tenant define
// its own roles without any Go code having to know they exist.
func permissionPredicate(tenantExpr, userExpr, permissionExpr string) string {
return fmt.Sprintf(`EXISTS (
SELECT 1
FROM memberships scoped
JOIN role_permissions granted ON granted.role_id = scoped.role_id
WHERE scoped.tenant_id = %s AND scoped.user_id = %s
AND granted.permission = %s
)`, tenantExpr, userExpr, permissionExpr)
}
// SystemRoleID returns the id of a built-in role. Callers that seed memberships
// outside a tenant context -- the installer, the bootstrapper -- need it because
// a membership is a role id rather than a name.
func (s *Store) SystemRoleID(ctx context.Context, slug string) (string, error) {
var id string
err := s.db.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = $1 AND tenant_id IS NULL`, slug).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return id, err
}
// clientPermissions is the permission aggregate reported to client applications
// through /connect/userinfo. A user with no membership in the owning tenant is
// described by the built-in User role, which is the least privileged one.
const clientPermissions = `COALESCE(
(SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp WHERE rp.role_id = m.role_id),
(SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp
JOIN roles fallback ON fallback.id = rp.role_id
WHERE fallback.slug = 'user' AND fallback.tenant_id IS NULL),
'[]'::json)`
// heldPermissions returns every permission the actor holds in a tenant, sorted.
// It is the basis for both the per-action checks and the escalation guard: an
// actor may never hand out a capability they do not have themselves.
func heldPermissions(ctx context.Context, q querier, actorID, tenantID string) ([]string, error) {
rows, err := q.QueryContext(ctx, `
SELECT DISTINCT granted.permission
FROM memberships scoped
JOIN role_permissions granted ON granted.role_id = scoped.role_id
WHERE scoped.tenant_id = $1 AND scoped.user_id = $2
ORDER BY granted.permission`, tenantID, actorID)
if err != nil {
return nil, err
}
defer rows.Close()
held := make([]string, 0)
for rows.Next() {
var permission string
if err := rows.Scan(&permission); err != nil {
return nil, err
}
held = append(held, permission)
}
return held, rows.Err()
}
// hasPermission reports whether the actor holds one permission in a tenant.
func hasPermission(ctx context.Context, q querier, actorID, tenantID string, permission role.Permission) (bool, error) {
var permitted bool
err := q.QueryRowContext(ctx,
`SELECT `+permissionPredicate("$2", "$1", "$3"),
actorID, tenantID, string(permission)).Scan(&permitted)
return permitted, err
}
// within reports whether every permission in granted also appears in held.
func within(granted, held []string) bool {
available := make(map[string]bool, len(held))
for _, permission := range held {
available[permission] = true
}
for _, permission := range granted {
if !available[permission] {
return false
}
}
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) {
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)
}
requested[string(parsed)] = true
}
result := make([]string, 0, len(requested))
for _, permission := range role.Permissions() {
if requested[string(permission)] {
result = append(result, string(permission))
}
}
return result, nil
}
// roleGrants reads a role's permissions.
func roleGrants(ctx context.Context, q querier, roleID string) ([]string, error) {
rows, err := q.QueryContext(ctx, `
SELECT permission FROM role_permissions WHERE role_id = $1 ORDER BY permission`, roleID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0)
for rows.Next() {
var permission string
if err := rows.Scan(&permission); err != nil {
return nil, err
}
result = append(result, permission)
}
return result, rows.Err()
}
func scanRole(row scanner) (Role, error) {
var item Role
var tenantID sql.NullString
if err := row.Scan(&item.ID, &tenantID, &item.Slug, &item.Name, &item.Description,
&item.System, &item.MemberCount); err != nil {
return Role{}, err
}
if tenantID.Valid {
item.TenantID = &tenantID.String
}
return item, nil
}
const roleSelect = `
SELECT r.id, r.tenant_id, r.slug, r.name, r.description, r.is_system,
(SELECT count(*) FROM memberships m WHERE m.role_id = r.id)
FROM roles r `
// ListRoles returns the roles available in a tenant: every built-in role plus
// that tenant's own. Reading the catalog needs users.read so the role screen can
// describe a role before anybody is assigned it.
func (s *Store) ListRoles(ctx context.Context, actorID, tenantID string) ([]Role, error) {
permitted, err := hasPermission(ctx, s.db, actorID, tenantID, role.ReadUsers)
if err != nil {
return nil, err
}
if !permitted {
return nil, ErrNotFound
}
roles, err := s.rolesIn(ctx, s.db, actorID, tenantID)
if err != nil {
return nil, err
}
return roles, nil
}
// AssignableRoles returns only the roles the administrator may hand to a member.
func (s *Store) AssignableRoles(ctx context.Context, actorID, tenantID string) ([]Role, error) {
roles, err := s.ListRoles(ctx, actorID, tenantID)
if err != nil {
return nil, err
}
result := make([]Role, 0, len(roles))
for _, item := range roles {
if item.Assignable {
result = append(result, item)
}
}
return result, nil
}
// rolesIn loads the tenant's catalog and marks what the actor may assign.
func (s *Store) rolesIn(ctx context.Context, q querier, actorID, tenantID string) ([]Role, error) {
rows, err := q.QueryContext(ctx, roleSelect+`
WHERE r.tenant_id IS NULL OR r.tenant_id = $1
ORDER BY r.is_system DESC,
array_position(ARRAY['owner','admin','developer','operator','viewer','user'], r.slug) NULLS LAST,
r.name`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
held, err := heldPermissions(ctx, q, actorID, tenantID)
if err != nil {
return nil, err
}
result := make([]Role, 0)
for rows.Next() {
item, err := scanRole(rows)
if err != nil {
return nil, err
}
permissions, err := roleGrants(ctx, q, item.ID)
if err != nil {
return nil, err
}
item.Permissions = permissions
item.Assignable = within(permissions, held)
result = append(result, item)
}
return result, rows.Err()
}
func roleByID(ctx context.Context, q querier, roleID string) (Role, error) {
item, err := scanRole(q.QueryRowContext(ctx, roleSelect+`WHERE r.id = $1`, roleID))
if errors.Is(err, sql.ErrNoRows) {
return Role{}, ErrNotFound
}
if err != nil {
return Role{}, err
}
return item, nil
}
// 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)
if err != nil {
return Role{}, err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
return Role{}, ErrNotFound
}
created, err := insertRole(ctx, tx, tenantID, slug, name, description)
if err != nil {
return Role{}, err
}
for _, permission := range granted {
if _, err := tx.ExecContext(ctx, `
INSERT INTO role_permissions(role_id, permission) VALUES ($1, $2)`,
created.ID, permission); err != nil {
return Role{}, err
}
}
created.Permissions = granted
created.Assignable = true
return created, tx.Commit()
}
// 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()
existing, err := lockRole(ctx, tx, roleID)
if err != nil {
return Role{}, err
}
if existing.System || existing.TenantID == nil {
return Role{}, ErrNotFound
}
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
if err != nil {
return Role{}, err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(granted, held) {
return Role{}, ErrNotFound
}
if _, err := tx.ExecContext(ctx, `
UPDATE roles SET name = $2, description = $3, updated_at = now() WHERE id = $1`,
roleID, name, description); err != nil {
return Role{}, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = $1`, roleID); err != nil {
return Role{}, err
}
for _, permission := range granted {
if _, err := tx.ExecContext(ctx, `
INSERT INTO role_permissions(role_id, permission) VALUES ($1, $2)`,
roleID, permission); err != nil {
return Role{}, err
}
}
existing.Name, existing.Description, existing.Permissions = name, description, granted
return existing, tx.Commit()
}
// DeleteRole removes a tenant's own role. Built-in roles and roles that still
// have members cannot be deleted, so nobody is left pointing at nothing.
func (s *Store) DeleteRole(ctx context.Context, actorID, roleID string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
existing, err := lockRole(ctx, tx, roleID)
if err != nil {
return err
}
if existing.System || existing.TenantID == nil {
return ErrNotFound
}
permissions, err := roleGrants(ctx, tx, roleID)
if err != nil {
return err
}
held, err := heldPermissions(ctx, tx, actorID, *existing.TenantID)
if err != nil {
return err
}
if !within([]string{string(role.ManageRoles)}, held) || !within(permissions, held) {
return ErrNotFound
}
if existing.MemberCount > 0 {
return ErrRoleInUse
}
if _, err := tx.ExecContext(ctx, `DELETE FROM roles WHERE id = $1`, roleID); err != nil {
return err
}
return tx.Commit()
}
// resolveRoleID validates that a role may be assigned to a member of the tenant
// and returns its id. A role belonging to another tenant is reported as not
// found so that role ids cannot be probed across tenants.
func resolveRoleID(ctx context.Context, tx *sql.Tx, tenantID, roleID string, held []string) (string, error) {
item, err := roleByID(ctx, tx, roleID)
if err != nil {
return "", err
}
if item.TenantID != nil && *item.TenantID != tenantID {
return "", ErrNotFound
}
granted, err := roleGrants(ctx, tx, roleID)
if err != nil {
return "", err
}
if !within(granted, held) {
return "", ErrNotFound
}
return roleID, nil
}
// lockRole loads a role and holds the row for the rest of the transaction.
func lockRole(ctx context.Context, tx *sql.Tx, roleID string) (Role, error) {
item, err := scanRole(tx.QueryRowContext(ctx, roleSelect+`WHERE r.id = $1 FOR UPDATE`, roleID))
if errors.Is(err, sql.ErrNoRows) {
return Role{}, ErrNotFound
}
if err != nil {
return Role{}, err
}
permissions, err := roleGrants(ctx, tx, roleID)
if err != nil {
return Role{}, err
}
item.Permissions = permissions
return item, nil
}
// insertRole writes the role row, translating a slug collision into a stable
// error rather than a driver-specific constraint violation.
func insertRole(ctx context.Context, tx *sql.Tx, tenantID, slug, name, description string) (Role, error) {
item, err := scanRole(tx.QueryRowContext(ctx, `
INSERT INTO roles(tenant_id, slug, name, description)
VALUES ($1, $2, $3, $4)
RETURNING id, tenant_id, slug, name, description, is_system, 0`,
tenantID, slug, name, description))
if err != nil {
if isUniqueViolation(err) {
return Role{}, ErrRoleSlugTaken
}
return Role{}, err
}
return item, nil
}
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "duplicate key value")
}

View file

@ -0,0 +1,242 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/store"
)
// rolesFixture creates a tenant owned by owner plus a developer member. The
// developer is the interesting actor: it administers members but does not hold
// roles.manage, so it is the boundary the escalation guards are tested against.
func rolesFixture(t *testing.T) (s *store.Store, ctx context.Context, owner, developer, tenantID string) {
t.Helper()
s, _, ctx = integrationStore(t)
owner = newUser(t, s, ctx, "owner@example.test")
tenantID = newTenant(t, s, ctx, owner, "nmbs").ID
developer = addMember(t, s, ctx, owner, tenantID, "developer@example.test", "developer")
return s, ctx, owner, developer, tenantID
}
func systemRole(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 built-in role %q: %v", slug, err)
}
return id
}
func TestTenantCanDefineItsOwnRoleAndItIsEnforced(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
application, err := s.CreateApplication(ctx, owner, store.Application{
TenantID: tenantID, Slug: "guess", Name: "Guess", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
created, err := s.CreateRole(ctx, owner, tenantID, "release-manager",
"Release manager", "Prepares releases.",
[]string{string(role.ReadConsole), string(role.ManageContracts), string(role.ReadUsers)})
if err != nil {
t.Fatalf("create role: %v", err)
}
if created.System {
t.Error("a tenant role must not be marked as built in")
}
if created.TenantID == nil || *created.TenantID != tenantID {
t.Errorf("tenant id = %v, want %s", created.TenantID, tenantID)
}
if !contains(created.Permissions, string(role.ManageContracts)) {
t.Errorf("permissions = %v, want contracts.manage", created.Permissions)
}
// The role grants exactly what it says and nothing more.
member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil {
t.Fatalf("assign the custom role: %v", err)
}
_, err = s.CreateAPIKey(ctx, member, application.ID, "key", "gl_prefix", secretHash("custom-denied"), nil)
assertPermission(t, "custom role issuing a key", err, false)
_, err = s.SaveContract(ctx, member, application.ID, contractDocument(), nil)
assertPermission(t, "custom role importing a contract", err, true)
}
func TestCustomRoleIsScopedToItsOwnTenant(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
other := newUser(t, s, ctx, "other-owner@example.test")
otherTenant := newTenant(t, s, ctx, other, "ypto").ID
created, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "", []string{string(role.ReadConsole)})
if err != nil {
t.Fatalf("create role: %v", err)
}
roles, err := s.ListRoles(ctx, other, otherTenant)
if err != nil {
t.Fatalf("list roles: %v", err)
}
for _, item := range roles {
if item.ID == created.ID {
t.Fatal("a role leaked across tenants")
}
}
if err := s.DeleteRole(ctx, other, created.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("cross-tenant delete = %v, want ErrNotFound", err)
}
if _, err := s.UpdateRole(ctx, other, created.ID, "Hijacked", "", nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("cross-tenant update = %v, want ErrNotFound", err)
}
}
func TestBuiltInRolesCannotBeEditedOrDeleted(t *testing.T) {
s, ctx, owner, _, _ := rolesFixture(t)
ownerRole := systemRole(t, s, ctx, "owner")
if _, err := s.UpdateRole(ctx, owner, ownerRole, "Supreme", "", nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("editing a built-in role = %v, want ErrNotFound", err)
}
if err := s.DeleteRole(ctx, owner, ownerRole); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("deleting a built-in role = %v, want ErrNotFound", err)
}
}
func TestRoleInUseCannotBeDeleted(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
created, err := s.CreateRole(ctx, owner, tenantID, "support", "Support", "", []string{string(role.ReadConsole)})
if err != nil {
t.Fatalf("create role: %v", err)
}
member := addMember(t, s, ctx, owner, tenantID, "member@example.test", "viewer")
if err := s.SetUserAccess(ctx, owner, tenantID, member, created.ID, nil); err != nil {
t.Fatalf("assign role: %v", err)
}
if err := s.DeleteRole(ctx, owner, created.ID); !errors.Is(err, store.ErrRoleInUse) {
t.Fatalf("delete an assigned role = %v, want ErrRoleInUse", err)
}
// Once nobody holds it the delete goes through.
if err := s.SetUserAccess(ctx, owner, tenantID, member, systemRole(t, s, ctx, "viewer"), nil); err != nil {
t.Fatalf("move the member off the role: %v", err)
}
if err := s.DeleteRole(ctx, owner, created.ID); err != nil {
t.Fatalf("delete an unused role: %v", err)
}
}
// TestRoleAssignmentCannotEscalate is the guard that matters most now that
// developer and operator may manage members: they must not be able to hand
// somebody a role more powerful than their own, which would otherwise be a
// straightforward route to owner.
func TestRoleAssignmentCannotEscalate(t *testing.T) {
s, ctx, owner, developer, tenantID := rolesFixture(t)
victim := addMember(t, s, ctx, owner, tenantID, "victim@example.test", "viewer")
ownerRole := systemRole(t, s, ctx, "owner")
if err := s.SetUserAccess(ctx, developer, tenantID, victim, ownerRole, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer promoting a member to owner = %v, want ErrNotFound", err)
}
if _, err := s.InviteUser(ctx, developer, tenantID, "sneaky@example.test", "Sneaky", ownerRole, "hash"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer inviting as owner = %v, want ErrNotFound", err)
}
if _, err := s.CreateRole(ctx, developer, tenantID, "shadow-owner", "Shadow", "",
[]string{string(role.ReadConsole), string(role.ManageTenants)}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer creating an owner-grade role = %v, want ErrNotFound", err)
}
// Nor may they define any role at all: roles.manage is not theirs to hold,
// however weak the role they were trying to mint.
if _, err := s.CreateRole(ctx, developer, tenantID, "teammate", "Teammate", "",
[]string{string(role.ReadConsole), string(role.ReadUsers)}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer creating any role = %v, want ErrNotFound", err)
}
// The developer can still maintain members using the roles already offered.
if _, err := s.InviteUser(ctx, developer, tenantID, "teammate@example.test", "Teammate",
systemRole(t, s, ctx, "viewer"), "hash"); err != nil {
t.Fatalf("developer inviting a member: %v", err)
}
}
func TestRolesManageIsRequiredToDefineRoles(t *testing.T) {
s, ctx, _, developer, tenantID := rolesFixture(t)
if _, err := s.ListRoles(ctx, developer, tenantID); err != nil {
t.Fatalf("a developer may read the role catalog: %v", err)
}
if _, err := s.CreateRole(ctx, developer, tenantID, "nope", "Nope", "",
[]string{string(role.ReadConsole)}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("developer creating a role = %v, want ErrNotFound", err)
}
}
func TestAssignableRolesExcludeAnythingAboveTheActor(t *testing.T) {
s, ctx, _, developer, tenantID := rolesFixture(t)
assignable, err := s.AssignableRoles(ctx, developer, tenantID)
if err != nil {
t.Fatalf("assignable roles: %v", err)
}
ownerRole := systemRole(t, s, ctx, "owner")
found := 0
for _, item := range assignable {
if !item.Assignable {
t.Errorf("role %q was returned but is not marked assignable", item.Slug)
}
if contains(item.Permissions, string(role.ManageTenants)) {
t.Errorf("developer may assign %q, which grants tenants.manage", item.Slug)
}
if item.ID == ownerRole {
t.Error("a developer must not be offered the owner role")
}
found++
}
if found == 0 {
t.Fatal("a developer should still be offered the roles within their authority")
}
}
func TestUnknownPermissionIsRejectedWhenDefiningARole(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
_, err := s.CreateRole(ctx, owner, tenantID, "sneaky", "Sneaky", "",
[]string{string(role.ReadConsole), "superpower"})
if err == nil {
t.Fatal("a role must not be creatable with an unknown permission")
}
}
func TestDuplicateRoleSlugIsRejected(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
if _, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Auditor", "",
[]string{string(role.ReadConsole)}); err != nil {
t.Fatalf("create role: %v", err)
}
_, err := s.CreateRole(ctx, owner, tenantID, "auditor", "Another", "",
[]string{string(role.ReadConsole)})
if !errors.Is(err, store.ErrRoleSlugTaken) {
t.Fatalf("duplicate slug = %v, want ErrRoleSlugTaken", err)
}
}
// TestTenantKeepsAnOwner guards the demotion path: the last owner of a tenant
// must not be able to walk away from it and lock every administrator out.
func TestTenantKeepsAnOwner(t *testing.T) {
s, ctx, owner, _, tenantID := rolesFixture(t)
admin := addMember(t, s, ctx, owner, tenantID, "admin@example.test", "admin")
adminRole := systemRole(t, s, ctx, "admin")
ownerRole := systemRole(t, s, ctx, "owner")
// The owner cannot simply step down while nobody else holds the role.
if err := s.SetUserAccess(ctx, owner, tenantID, owner, adminRole, nil); !errors.Is(err, store.ErrLastOwner) {
t.Fatalf("owner stepping down alone = %v, want ErrLastOwner", err)
}
// Handing the role over first makes the step down legal.
if err := s.SetUserAccess(ctx, owner, tenantID, admin, ownerRole, nil); err != nil {
t.Fatalf("promote the admin to owner: %v", err)
}
if err := s.SetUserAccess(ctx, admin, tenantID, owner, adminRole, nil); err != nil {
t.Fatalf("step down once a second owner exists: %v", err)
}
// The admin is now the only owner and must not be able to walk away from it.
if err := s.SetUserAccess(ctx, admin, tenantID, admin, adminRole, nil); !errors.Is(err, store.ErrLastOwner) {
t.Fatalf("demoting the last owner = %v, want ErrLastOwner", err)
}
}

View file

@ -30,8 +30,8 @@ type Tenant struct {
Slug string `json:"slug"`
Name string `json:"name"`
Role string `json:"role"`
// Permissions is derived from Role so that the console and API clients read
// capabilities instead of re-implementing the role matrix.
// Permissions is read from the role's own rows rather than from a Go matrix,
// so a tenant that defines its own roles is described accurately.
Permissions []string `json:"permissions"`
}
@ -130,11 +130,13 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
return err
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role)
SELECT $1, $2, $3
WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)
ON CONFLICT DO NOTHING`, tenantID, userID, role.Owner); err != nil {
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))
ON CONFLICT DO NOTHING`, tenantID, userID, "owner"); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `
@ -157,9 +159,15 @@ func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug strin
func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT t.id, t.slug, t.name, m.role
SELECT t.id, t.slug, t.name, r.slug,
COALESCE((
SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp
WHERE rp.role_id = m.role_id
), '[]'::json)
FROM tenants t
JOIN memberships m ON m.tenant_id = t.id
JOIN roles r ON r.id = m.role_id
WHERE m.user_id = $1
ORDER BY t.name`, userID)
if err != nil {
@ -169,10 +177,13 @@ func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error
result := make([]Tenant, 0)
for rows.Next() {
var item Tenant
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
var permissions []byte
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role, &permissions); err != nil {
return nil, err
}
if err := json.Unmarshal(permissions, &item.Permissions); err != nil {
return nil, err
}
item.Permissions = role.Role(item.Role).Permissions()
result = append(result, item)
}
return result, rows.Err()
@ -190,11 +201,26 @@ func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Te
if err != nil {
return Tenant{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil {
// Whoever creates a tenant owns it.
var permissions []byte
err = tx.QueryRowContext(ctx, `
INSERT INTO memberships(tenant_id, user_id, role_id)
SELECT $1, $2, r.id FROM roles r
WHERE r.slug = 'owner' AND r.tenant_id IS NULL
RETURNING (SELECT slug FROM roles WHERE slug = 'owner' AND tenant_id IS NULL),
COALESCE((
SELECT json_agg(rp.permission ORDER BY rp.permission)
FROM role_permissions rp
JOIN roles granted ON granted.id = rp.role_id
WHERE granted.slug = 'owner' AND granted.tenant_id IS NULL
), '[]'::json)`,
tenant.ID, userID).Scan(&tenant.Role, &permissions)
if err != nil {
return Tenant{}, err
}
if err := json.Unmarshal(permissions, &tenant.Permissions); err != nil {
return Tenant{}, err
}
tenant.Role = string(role.Owner)
tenant.Permissions = role.Owner.Permissions()
if err := tx.Commit(); err != nil {
return Tenant{}, err
}
@ -237,14 +263,11 @@ func (s *Store) CreateApplication(ctx context.Context, userID string, app Applic
row := s.db.QueryRowContext(ctx, `
INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts)
SELECT $1, $2, $3, $4, $5
WHERE EXISTS (
SELECT 1 FROM memberships
WHERE tenant_id = $1 AND user_id = $6 AND role = ANY($7)
)
WHERE `+permissionPredicate("$1", "$6", "$7")+`
RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts),
client_id, to_json(redirect_uris), false`,
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID,
role.Grants(role.ManageApplications))
string(role.ManageApplications))
var allowedHosts, redirectURIs []byte
err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady)
if errors.Is(err, sql.ErrNoRows) {
@ -287,10 +310,9 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p
INSERT INTO api_keys(tenant_id, application_id, name, prefix, secret_hash, expires_at, created_by)
SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $6 AND m.role = ANY($7)
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$6", "$7")+`
RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`,
applicationID, name, prefix, hash, expiresAt, userID, role.Grants(role.ManageKeys)).
applicationID, name, prefix, hash, expiresAt, userID, string(role.ManageKeys)).
Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return APIKey{}, ErrNotFound
@ -301,9 +323,8 @@ func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, p
func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE api_keys k SET status = 'revoked', revoked_at = now()
FROM memberships m
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role = ANY($3)`,
keyID, userID, role.Grants(role.ManageKeys))
WHERE k.id = $1 AND `+permissionPredicate("k.tenant_id", "$2", "$3"),
keyID, userID, string(role.ManageKeys))
if err != nil {
return err
}
@ -363,11 +384,10 @@ func (s *Store) SaveContract(ctx context.Context, userID, applicationID string,
INSERT INTO openapi_contracts(application_id, version, source_url, document, created_by)
SELECT a.id, $2, $3, $4, $5
FROM applications a
JOIN memberships m ON m.tenant_id = a.tenant_id
WHERE a.id = $1 AND m.user_id = $5 AND m.role = ANY($6)
WHERE a.id = $1 AND `+permissionPredicate("a.tenant_id", "$5", "$6")+`
RETURNING id, application_id, version, source_url, created_at`,
applicationID, document.Version, sourceURL, document.JSON, userID,
role.Grants(role.ManageContracts)).
string(role.ManageContracts)).
Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Contract{}, ErrNotFound