mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 23:28:51 +00:00
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 72b54ce 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:
@@ -282,6 +282,56 @@ func TestAuthorizer_SpecificScopeMatchesExactID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Audit 2026-05-11 A-1 — pin that when the SQL narrowed effective set
|
||||
// reflects an actor-role-scope-narrowed permission, CheckPermission
|
||||
// authorizes only the narrowed scope. This is the unit-level
|
||||
// counterpart to TestEffectivePermissions_ActorRoleProfile_RolePermGlobal_A1Closure
|
||||
// in internal/repository/postgres/auth_scope_test.go which exercises
|
||||
// the actual SQL.
|
||||
//
|
||||
// Pre-fix, the SQL ignored ar.scope_*, so a profile-scoped grant
|
||||
// produced a row with rp.scope (global), and CheckPermission would
|
||||
// pass for ANY profile. Post-fix, the SQL narrows the row to
|
||||
// (profile, p-prod), and CheckPermission only passes when the
|
||||
// request scope matches.
|
||||
func TestAuthorizer_ActorRoleProfileScope_OnlyNarrowedScopeAuthorizes_A1(t *testing.T) {
|
||||
r := newFakeActorRoleRepo()
|
||||
scope := "p-prod"
|
||||
// Simulate the post-A-1 SQL emission: actor-role scoped to
|
||||
// profile=p-prod + role-permission scoped global → narrowed
|
||||
// effective row at profile=p-prod.
|
||||
r.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{
|
||||
{PermissionName: "cert.read", ScopeType: authdomain.ScopeTypeProfile, ScopeID: &scope},
|
||||
}
|
||||
az := NewAuthorizer(r)
|
||||
|
||||
// Request scope matches narrowed grant → authorize.
|
||||
matchID := "p-prod"
|
||||
ok, err := az.CheckPermission(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "cert.read", authdomain.ScopeTypeProfile, &matchID)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPermission (matching scope): %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("A-1: profile-scoped grant must authorize matching profile request")
|
||||
}
|
||||
|
||||
// Different profile → reject (the load-bearing post-fix
|
||||
// behavior). Pre-fix this would have returned true silently.
|
||||
wrongID := "p-acme"
|
||||
ok, _ = az.CheckPermission(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "cert.read", authdomain.ScopeTypeProfile, &wrongID)
|
||||
if ok {
|
||||
t.Error("A-1 regression: profile-scoped grant must NOT authorize a different profile (the canonical CRIT-5 shape)")
|
||||
}
|
||||
|
||||
// Global request → also reject. A profile-scoped actor-role
|
||||
// grant doesn't elevate to global; same shape as RFC 9700
|
||||
// least-privilege.
|
||||
ok, _ = az.CheckPermission(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "cert.read", authdomain.ScopeTypeGlobal, nil)
|
||||
if ok {
|
||||
t.Error("A-1: profile-scoped grant must NOT authorize a global request")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RoleService tests
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user