fix(auth/rbac): close HIGH-10 lying field — EffectivePermissions reads actor-role scope (A-1)

Audit 2026-05-11 A-1 closure. Spec at
cowork/auth-bundles-fixes-2026-05-11/01-crit-actor-role-scope-reads.md.

WHAT.

The HIGH-10 closure (commit 551812b on dev/auth-bundle-2) added
`scope_type` + `scope_id` columns to `actor_roles` via migration
000043. The handler accepted them on POST /api/v1/auth/keys/{id}/roles.
The repo Grant INSERTed them. The uniqueness tuple was extended to
include them. The GUI exposed them as form inputs.

But the load-bearing `EffectivePermissions` SQL at
internal/repository/postgres/auth.go:470 never read them. The query
only JOINed against rp.scope_type/rp.scope_id (role-permission
scope) and ignored ar.scope_type/ar.scope_id (actor-role scope).

Operator-visible failure: granting Alice r-operator scoped to
profile=p-prod silently elevated her to r-operator GLOBALLY at
authorization time. The Authorizer's matcher correctly handled
whatever EffectivePermissions returned, but EffectivePermissions
returned the rp.scope (typically global), not the ar.scope
narrowing.

This is the canonical CRIT-5 lying-field shape — a security
control claimed, persisted across 4 layers, with unit tests at
each isolated layer, but the load-bearing wire severed mid-flight.
CLAUDE.md's 'Always take the complete path' rule was violated by
the original HIGH-10 closure.

Additionally, `scanActorRoles` failed to read the new columns
even when present, so every GET-side path (ListByActor /
ListByRole) returned ActorRole with zero-value scope fields — the
GUI / MCP couldn't show operators what they had configured.

HOW.

internal/repository/postgres/auth.go:
  - EffectivePermissions SQL extended to intersect ar.scope with
    rp.scope via a CASE-in-subquery. The effective scope is the
    NARROWER of the two; disjoint tuples and scope-type mismatches
    drop the row entirely. WHERE filter on effective_scope_type
    IS NOT NULL excludes dropped rows.

    Match matrix (encoded by the CASE):
      ar.scope    rp.scope    effective_scope
      ─────────   ─────────   ──────────────────
      global      global      global / NULL
      global      profile=X   profile=X (rp narrows)
      profile=X   global      profile=X (ar narrows)
      profile=X   profile=X   profile=X (both agree)
      profile=X   profile=Y   ROW DROPPED (disjoint)
      profile=X   issuer=*    ROW DROPPED (type mismatch)

  - ListByActor + ListByRole SELECTs extended with scope_type +
    scope_id columns so the read-side surfaces what was persisted.
  - scanActorRoles reads the new columns into ActorRole.ScopeType
    + ScopeID via the existing sql.NullString + ScopeType cast
    pattern (mirrors RolePermission scan).

internal/repository/postgres/auth_scope_test.go (NEW):
  Testcontainer-backed regression matrix. 8 cases:
  1. ActorRoleGlobal_RolePermGlobal — trivial happy path.
  2. ActorRoleGlobal_RolePermProfile — rp narrows.
  3. ActorRoleProfile_RolePermGlobal_A1Closure — **load-bearing**
     post-fix case: profile-scoped grant narrows to profile.
  4. BothScopedSameTuple_Matches — exact-match collapse.
  5. BothScopedDifferentIDs_RowDropped — disjoint scopes produce
     no effective permission.
  6. ScopeTypeMismatch_RowDropped — profile vs issuer mismatch.
  7. ExpiredGrant_Excluded — pre-fix behavior preserved.
  8. ListByActor_ReturnsScopeColumns — read-side surface check.

  Tests skip in -short mode (testcontainers-backed; require Docker
  on operator workstation).

internal/service/auth/service_test.go:
  TestAuthorizer_ActorRoleProfileScope_OnlyNarrowedScopeAuthorizes_A1
  — unit-level pin (sandbox-runnable, no Docker). Simulates the
  post-A-1 SQL emission (narrowed effective row at
  profile=p-prod) and asserts CheckPermission authorizes only
  matching profile, rejects other profiles AND rejects global.
  Existing matcher code is unchanged; this proves the integration
  point.

CHANGELOG.md:
  Operator advisory in the new 'Security (BREAKING — silent-elevation
  closure)' section. Pre-existing scope-bound grants take effect on
  upgrade; operators audit `actor_roles WHERE scope_type != 'global'`
  to confirm intent.

cowork/auth-bundles-audit-2026-05-10.md:
  HIGH-10 row gets an A-1 follow-on CLOSED 2026-05-11 annotation
  describing the regression + closure.

VERIFY.

- gofmt -l <changed files>                                       (no diff)
- go vet ./internal/repository/postgres/... ./internal/service/auth/...
  ./internal/api/handler/... ./internal/auth/... ./cmd/server/...  PASS
- go test -short -count=1 ./internal/service/auth/...
  ./internal/repository/postgres/... ./internal/api/handler/...    PASS
- The testcontainer-backed regression matrix runs on operator
  workstation via 'go test -count=1 ./internal/repository/postgres/...'
  (skip in -short).

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-10 (A-1 follow-on)
      cowork/auth-bundles-fixes-2026-05-11/01-crit-actor-role-scope-reads.md
      CLAUDE.md 'Always take the complete path' rule
This commit is contained in:
shankar0123
2026-05-11 02:02:39 +00:00
parent 661b6dbefb
commit 393f83ac34
4 changed files with 444 additions and 12 deletions
+69 -12
View File
@@ -335,8 +335,13 @@ func NewActorRoleRepository(db *sql.DB) *ActorRoleRepository {
}
func (r *ActorRoleRepository) ListByActor(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]*authdomain.ActorRole, error) {
// Audit 2026-05-11 A-1 — include scope_type + scope_id in the
// SELECT so the GUI / MCP surface can render which scope an
// actor's grant is bound to. Pre-fix, these columns were
// persisted by Grant (HIGH-10 closure) but never surfaced on
// read — operators couldn't see what they configured.
rows, err := r.db.QueryContext(ctx, `
SELECT id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id
SELECT id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id, scope_type, scope_id
FROM actor_roles
WHERE actor_id = $1 AND actor_type = $2 AND tenant_id = $3
ORDER BY granted_at
@@ -349,7 +354,7 @@ func (r *ActorRoleRepository) ListByActor(ctx context.Context, actorID string, a
func (r *ActorRoleRepository) ListByRole(ctx context.Context, roleID string) ([]*authdomain.ActorRole, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id
SELECT id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id, scope_type, scope_id
FROM actor_roles
WHERE role_id = $1
ORDER BY granted_at
@@ -468,15 +473,55 @@ func (r *ActorRoleRepository) AdminExists(ctx context.Context, tenantID string)
}
func (r *ActorRoleRepository) EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]repository.EffectivePermission, error) {
// Audit 2026-05-11 A-1 — effective scope is the intersection of
// the actor-role's scope (ar.scope_*) AND the role-permission's
// scope (rp.scope_*). Pre-fix, only rp.scope_* was read; an
// actor granted r-operator scoped to profile=p-prod silently
// got every r-operator permission at every scope rp emitted
// (typically global), defeating HIGH-10's per-actor scope knob.
//
// Matching rules (the inner CASE encodes them):
//
// ar.scope rp.scope effective_scope
// ───────── ───────── ──────────────────────
// global global global / NULL
// global profile=X profile=X (rp narrows)
// profile=X global profile=X (ar narrows)
// profile=X profile=X profile=X (both agree)
// profile=X profile=Y ROW DROPPED (disjoint scopes — no permission flows)
// profile=X issuer=* ROW DROPPED (scope-type mismatch)
//
// The HAVING-style filter is implemented via a subquery — Postgres
// doesn't allow referencing a CASE alias from HAVING in a SELECT
// DISTINCT context without a wrapping CTE.
rows, err := r.db.QueryContext(ctx, `
SELECT DISTINCT p.name, rp.scope_type, rp.scope_id
FROM actor_roles ar
JOIN role_permissions rp ON rp.role_id = ar.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ar.actor_id = $1
AND ar.actor_type = $2
AND ar.tenant_id = $3
AND (ar.expires_at IS NULL OR ar.expires_at > NOW())
SELECT DISTINCT permission_name, effective_scope_type, effective_scope_id
FROM (
SELECT
p.name AS permission_name,
CASE
WHEN ar.scope_type = 'global' AND rp.scope_type = 'global' THEN 'global'
WHEN ar.scope_type = 'global' THEN rp.scope_type
WHEN rp.scope_type = 'global' THEN ar.scope_type
WHEN ar.scope_type = rp.scope_type AND ar.scope_id IS NOT DISTINCT FROM rp.scope_id THEN ar.scope_type
ELSE NULL
END AS effective_scope_type,
CASE
WHEN ar.scope_type = 'global' AND rp.scope_type = 'global' THEN NULL
WHEN ar.scope_type = 'global' THEN rp.scope_id
WHEN rp.scope_type = 'global' THEN ar.scope_id
WHEN ar.scope_type = rp.scope_type AND ar.scope_id IS NOT DISTINCT FROM rp.scope_id THEN ar.scope_id
ELSE NULL
END AS effective_scope_id
FROM actor_roles ar
JOIN role_permissions rp ON rp.role_id = ar.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ar.actor_id = $1
AND ar.actor_type = $2
AND ar.tenant_id = $3
AND (ar.expires_at IS NULL OR ar.expires_at > NOW())
) AS intersected
WHERE effective_scope_type IS NOT NULL
`, actorID, string(actorType), tenantID)
if err != nil {
return nil, fmt.Errorf("actorRole.effective: %w", err)
@@ -505,9 +550,16 @@ func scanActorRoles(rows *sql.Rows) ([]*authdomain.ActorRole, error) {
var out []*authdomain.ActorRole
for rows.Next() {
var ar authdomain.ActorRole
var actorType string
var actorType, scopeType string
var expires sql.NullTime
if err := rows.Scan(&ar.ID, &ar.ActorID, &actorType, &ar.RoleID, &ar.GrantedAt, &expires, &ar.GrantedBy, &ar.TenantID); err != nil {
var scopeID sql.NullString
// Audit 2026-05-11 A-1 — scope_type + scope_id are persisted
// by Grant (HIGH-10 closure, migration 000043). Pre-fix they
// were never scanned, so callers received ActorRole with
// zero-value scope fields regardless of what the row held.
// EffectivePermissions narrowing depends on these being
// populated correctly.
if err := rows.Scan(&ar.ID, &ar.ActorID, &actorType, &ar.RoleID, &ar.GrantedAt, &expires, &ar.GrantedBy, &ar.TenantID, &scopeType, &scopeID); err != nil {
return nil, fmt.Errorf("actorRole scan: %w", err)
}
ar.ActorType = authdomain.ActorTypeValue(actorType)
@@ -515,6 +567,11 @@ func scanActorRoles(rows *sql.Rows) ([]*authdomain.ActorRole, error) {
t := expires.Time
ar.ExpiresAt = &t
}
ar.ScopeType = authdomain.ScopeType(scopeType)
if scopeID.Valid {
s := scopeID.String
ar.ScopeID = &s
}
out = append(out, &ar)
}
return out, rows.Err()