Files
certctl/migrations/000043_actor_role_scope.up.sql
T
shankar0123 551812b2ca feat(auth/rbac): scope_type+scope_id+expires_at on role grants (HIGH-10)
Audit 2026-05-10 — close HIGH-10 from the HANDOFF.md backend batch
(item 1). Per-actor scoped + time-bound role grants are now
expressible via the API.

Migration 000043: adds scope_type TEXT NOT NULL DEFAULT 'global' +
scope_id TEXT to actor_roles. Constraints:
  - actor_roles_scope_type_enum: scope_type ∈ {global, profile, issuer}
  - actor_roles_scope_id_required_when_not_global: scope_id is NULL
    iff scope_type='global'
  - Uniqueness extended: (actor_id, actor_type, role_id, scope_type,
    scope_id, tenant_id) — so an operator can grant the same role to
    the same actor scoped to multiple profiles/issuers (e.g.
    r-operator on p-finance AND on p-engineering).
Index idx_actor_roles_scope for non-global lookup hot paths.

Domain: ActorRole.ScopeType (ScopeType enum) + ScopeID (*string).
Authorizer.CheckPermission already understands the tuple via the
parallel role_permissions columns; this addition gives operators a
per-actor knob without forking roles.

Postgres repo: Grant writes scope_type+scope_id with ON CONFLICT keyed
on the new uniqueness tuple. Defaults to (global, NULL) when caller
omits.

Handler: assignRoleRequest extended with scope_type / scope_id /
expires_at. Validation:
  - role_id required (unchanged)
  - scope_type defaults to 'global'; allowed values global/profile/
    issuer; anything else → 400
  - scope_id required when scope_type ∈ {profile, issuer}; rejected
    (must be empty) when scope_type='global'
  - expires_at must be in the future when present; nil = standing

Regression matrix in internal/api/handler/auth_test.go (6 cases):
  - TestAssignRoleToKey_HIGH10_ProfileScopeBoundGrantPersists
  - TestAssignRoleToKey_HIGH10_TimeBoundGrantPersists
  - TestAssignRoleToKey_HIGH10_RejectsScopeIDWithGlobalScope
  - TestAssignRoleToKey_HIGH10_RejectsMissingScopeIDOnProfile
  - TestAssignRoleToKey_HIGH10_RejectsPastExpiry
  - TestAssignRoleToKey_HIGH10_RejectsInvalidScopeType

HIGH-10 marked CLOSED in audit-doc — the v3 deferral from the prior
session is reversed; everything lands in v2.

Refs: cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 1
      cowork/auth-bundles-audit-2026-05-10.md HIGH-10
2026-05-10 22:47:45 +00:00

47 lines
2.1 KiB
SQL

-- =============================================================================
-- 2026-05-10 Audit / HIGH-10 closure
-- =============================================================================
--
-- Per-actor scope override on role grants. Pre-fix, actor_roles had
-- expires_at (already shipped) but no scope_type/scope_id columns, so
-- "give Alice operator over profile X only" wasn't expressible at the
-- grant layer — the only path was creating a scoped role and granting
-- that. This migration adds the per-grant scope tuple so an operator
-- can attach Alice to the standing r-operator role but scope the
-- grant to profile X.
--
-- scope_type defaults to 'global' to preserve existing rows; scope_id
-- stays NULL when scope_type='global'. Authorizer.CheckPermission
-- already understands the tuple shape (role_permissions carries the
-- same columns); the actor-role addition gives operators a second
-- knob without forcing them to fork roles.
-- =============================================================================
ALTER TABLE actor_roles
ADD COLUMN IF NOT EXISTS scope_type TEXT NOT NULL DEFAULT 'global',
ADD COLUMN IF NOT EXISTS scope_id TEXT;
ALTER TABLE actor_roles
ADD CONSTRAINT actor_roles_scope_type_enum
CHECK (scope_type IN ('global', 'profile', 'issuer'));
ALTER TABLE actor_roles
ADD CONSTRAINT actor_roles_scope_id_required_when_not_global
CHECK (
(scope_type = 'global' AND scope_id IS NULL) OR
(scope_type IN ('profile', 'issuer') AND scope_id IS NOT NULL)
);
-- The (actor_id, actor_type, role_id, tenant_id) uniqueness must
-- relax: an operator can grant the same role to the same actor at
-- different scopes (e.g. r-operator on profile-A AND on profile-B).
ALTER TABLE actor_roles
DROP CONSTRAINT IF EXISTS actor_roles_actor_id_actor_type_role_id_tenant_id_key;
ALTER TABLE actor_roles
ADD CONSTRAINT actor_roles_actor_role_scope_unique
UNIQUE (actor_id, actor_type, role_id, scope_type, scope_id, tenant_id);
CREATE INDEX IF NOT EXISTS idx_actor_roles_scope
ON actor_roles(scope_type, scope_id) WHERE scope_id IS NOT NULL;