roles
This commit is contained in:
parent
6319da650c
commit
3fdc8cb489
26 changed files with 2245 additions and 488 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
455
internal/store/roles.go
Normal 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")
|
||||
}
|
||||
242
internal/store/roles_integration_test.go
Normal file
242
internal/store/roles_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue