mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
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
This commit is contained in:
@@ -377,11 +377,24 @@ func (r *ActorRoleRepository) Grant(ctx context.Context, ar *authdomain.ActorRol
|
||||
if ar.ExpiresAt != nil {
|
||||
expires = *ar.ExpiresAt
|
||||
}
|
||||
// Audit 2026-05-10 HIGH-10 — per-actor scope columns. Default to
|
||||
// "global"+NULL when the caller didn't supply them (back-compat
|
||||
// with pre-migration code paths). Migration 000043's schema-level
|
||||
// DEFAULT 'global' covers the same case; passing explicitly here
|
||||
// makes the Go-level write deterministic.
|
||||
scopeType := string(ar.ScopeType)
|
||||
if scopeType == "" {
|
||||
scopeType = string(authdomain.ScopeTypeGlobal)
|
||||
}
|
||||
var scopeID interface{}
|
||||
if ar.ScopeID != nil && *ar.ScopeID != "" {
|
||||
scopeID = *ar.ScopeID
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO actor_roles (id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (actor_id, actor_type, role_id, tenant_id) DO NOTHING
|
||||
`, ar.ID, ar.ActorID, string(ar.ActorType), ar.RoleID, ar.GrantedAt, expires, ar.GrantedBy, ar.TenantID)
|
||||
INSERT INTO actor_roles (id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id, scope_type, scope_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (actor_id, actor_type, role_id, scope_type, scope_id, tenant_id) DO NOTHING
|
||||
`, ar.ID, ar.ActorID, string(ar.ActorType), ar.RoleID, ar.GrantedAt, expires, ar.GrantedBy, ar.TenantID, scopeType, scopeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("actorRole.grant: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user