Skip to content

sec(auth): fail closed when a user's account scope cannot be established - #1752

Merged
cristim merged 4 commits into
mainfrom
sec/1748-account-scope-fail-closed
Aug 8, 2026
Merged

cristim merged 4 commits into
mainfrom
sec/1748-account-scope-fail-closed

Conversation

@cristim

@cristim cristim commented Aug 8, 2026 •

Copy link
Copy Markdown
Member

Closes #1748.

A scoped user got access to every cloud account whenever their group lookup failed

Verified by execution before writing the fix:

scoped user, group loads fine (control)  -> AllowedAccounts=[acct-A]  IsUnrestrictedAccess=false
scoped user, group missing (ErrNoRows)   -> AllowedAccounts=[]        IsUnrestrictedAccess=true   <<< ALL ACCOUNTS
scoped user, group returns (nil, nil)    -> AllowedAccounts=[]        IsUnrestrictedAccess=true   <<< ALL ACCOUNTS

An empty list means unrestricted (IsUnrestrictedAccess) — a deliberate backward-compat default. But collectGroupsAndAccounts also skips a group it cannot load, so a resolution failure produced the same empty value. Absence and unrestricted shared a representation, and every failure silently granted everything, with no error and no log.

Triggers are ordinary rather than exotic: a group deleted while a session is live, a replica lagging a group insert, any store path returning (nil, nil).

Blast radius was every account-scoped handler — 18 call sites across 10 files (17 getAllowedAccounts plus one direct GetAllowedAccountsAPI in handler_purchases_revoke.go), including scoping.go's requireAccountAccess and requirePlanAccess that the per-record scoping depends on.

Counted by a direct Python scan of the files, not grep -c. The earlier 17/9 figure came from a git grep -E pattern containing \s, which POSIX ERE does not support — it silently matches nothing and exits 1. Every count in this PR was re-derived in Python.

Three producers, closed at the point of production

# Producer Fix
1 h.auth == nil returned (nil, nil) errors — auth components must fail closed when nil, never fall through
2 group fails to load (pgx.ErrNoRows) ResolveAllowedAccounts requires ≥1 group to have resolved
3 store path returns (nil, nil) same guard

2 and 3 are closed at the single point where the scope is produced, not per-caller, so a fourth failure mode in the same resolver cannot reintroduce it. Partial resolution still under-reports scope, which makes access stricter, so only total failure needed refusing.

The design constraint: there are THREE legitimate unrestricted principals, not two

This is what shaped the fix, and it is why "make absence fail closed" could not be applied literally. Established by execution before designing:

Principal Resolves to Legitimate?
stateless admin API key [] yes — keyed on the sentinel, expressed positively
Administrators member carrying "*" ["*"] yes — explicit marker
group with NO allowed_accounts configured [] yes — and shares its representation with the failures
group missing / (nil, nil) / no groups [] no — the bug

The third collides exactly with the failure modes. Making an empty account list fail closed would have broken every legacy group.

The separator is to require a resolved group, not a resolved account list. Verified across all six cases — the three legitimate principals resolve ≥1 group and keep working; all three failure modes resolve none and are refused.

Verification: refusal and control, because refusal alone proves nothing

A fix that refused everyone would pass a refusal-only suite. Every failure case is paired with a control:

  • Refuses: group missing, group (nil, nil), several groups none resolving, user with no groups, h.auth == nil, resolver error propagated, and end-to-end through requireAccountAccess.
  • Still passes: admin API key, "*" wildcard, group with no allowed_accounts configured, scoped principal reaching its own account — and a scoped principal still refused an account outside its scope, so the fix collapsed nothing into either extreme.
  • Partial resolution keeps working and reports only what resolved, asserted as narrower, never unrestricted.

Per-guard mutation

Mutation Result
P1 no-group-resolved guard removed kills only TestResolveAllowedAccounts_FailsClosed (1/10)
P2 nil-auth guard reverted to (nil, nil) kills only TestGetAllowedAccounts_FailsClosedWhenAuthMissing (1/10)
P3 inverse — refuse everyone kills 5/10, including the legitimate-principal controls

P3 is the one that matters: it proves the controls actually bite. Without it, an over-blocking implementation would look correct.

Gates

go build ./...                       ok
go vet ./...                         ok
go test ./...                        ok (exit 0, full suite)
gocyclo -over 10 -ignore "_test\.go" .   no output
golangci-lint v2.10.1 (CI-pinned)    0 issues, repo-wide

Note: the pre-existing suite passed unchanged before these tests were added — no existing test covered any of the three producers, which is consistent with #1596's finding that restricted-account paths were structurally untestable.

Scope

Deliberately not changing the representation itself (a distinct type or explicit flag). That remains attractive as defence-in-depth, and two consumers still hand-roll the emptiness check rather than routing through IsUnrestrictedAccess — handler_marketplace.go:435 and handler_purchases_revoke.go:398. Both are correct now that empty can only arise from a successful resolution, but they would not stop a future producer. Worth a follow-up; not worth growing a live-on-main critical fix.

Related: #1737 (the same shape on the group write path), #950/#956 (the account-filter regression class), #1596 (why this class had no coverage).

A user scoped to specific cloud accounts got unrestricted access to ALL of
them whenever their group memberships failed to resolve.

An empty allowed-accounts list means UNRESTRICTED (IsUnrestrictedAccess), a
deliberate backward-compat default so a group with no allowed_accounts
configured grants full access. But collectGroupsAndAccounts also skips a
group it cannot load -- pgx.ErrNoRows, or a store returning (nil, nil) -- so
a resolution failure produced the SAME empty value. Absence and unrestricted
shared a representation, and every failure silently granted everything. No
error, no log.

Verified by execution before fixing:

  scoped user, group loads fine (control)  -> unrestricted=false
  scoped user, group missing (ErrNoRows)   -> unrestricted=TRUE
  scoped user, group returns (nil, nil)    -> unrestricted=TRUE

Three producers of the empty value are closed:

  1. h.auth == nil in getAllowedAccounts returned (nil, nil). Auth components
     must fail closed when nil, never fall through.
  2. a group that fails to load with pgx.ErrNoRows
  3. a store path returning (nil, nil)

2 and 3 are closed at the single point where the scope is produced rather
than per-caller: ResolveAllowedAccounts requires at least one group to have
actually resolved. Partial resolution still under-reports scope, which makes
access STRICTER, so only total failure had to be refused.

The design constraint that shaped this: there are THREE legitimate
unrestricted principals, not two, and the third shares its representation
with the failures. The stateless admin API key resolves to empty, an
Administrators member carries "*", and a group with NO allowed_accounts
configured also resolves to empty. Making absence fail closed blindly would
have broken every legacy group. Requiring a resolved GROUP rather than a
resolved account list separates them cleanly -- verified across all six
cases.

Every failure test is paired with a control proving a legitimate unrestricted
principal still passes, because a fix that refused everyone would satisfy a
refusal-only suite. Mutation-verified per guard: removing the no-group-
resolved guard kills only its own test, reverting the nil-auth guard kills
only its own, and an inverse mutation that refuses everyone kills the
legitimate-principal controls.

Closes #1748.
@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/critical Major harm when it happens urgency/now Drop other things impact/many Affects most users effort/s Hours type/security Security finding labels Aug 8, 2026
@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 58 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f05d38e3-b867-4010-9398-5d164725c3c0

📥 Commits

Reviewing files that changed from the base of the PR and between e0fc45e and 07e88ee.

📒 Files selected for processing (6)
  • internal/api/account_scope_fail_closed_test.go
  • internal/api/handler.go
  • internal/auth/account_scope_fail_closed_test.go
  • internal/auth/service_group.go
  • internal/auth/types.go
  • internal/server/app.go

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review — producer enumeration, consumer verdict, separator, mutations

Reviewed at head 6318fc4ce in a fresh worktree off origin/main (e0fc45e25). No CodeRabbit involvement; this is the only review this PR gets. Everything below was re-derived here, not taken from the PR body.

Verdict: no blocking findings. The load-bearing claim holds with one correction to how it is stated.


1. The "single point of production" claim — verified, with a correction

The claim under test: the three producers are closed at the single point where the scope is produced, so a fourth failure mode in that resolver cannot reintroduce the bug.

Method. I enumerated producers rather than consumers, three ways:

  • git grep -n "AllowedAccounts" over all *.go, then read every non-test hit.
  • A Python walk (os.walk + re) over every .go file outside internal/api/ and internal/auth/, matching AllowedAccounts|allowed_accounts|IsUnrestrictedAccess|MatchesAccount. 6 hits, all inert: one migration test fixture, one comment in store_postgres_recommendations.go, and the four lines of the adapter this PR changes. No scope logic lives outside internal/api, internal/auth, internal/server.
  • Python line-count of call sites (not grep -c): h.getAllowedAccounts( + h.auth.GetAllowedAccountsAPI(, comment lines skipped, test files skipped → 19 hits in 11 files, minus the delegation on handler.go:538 inside getAllowedAccounts itself = 18 consumer call sites across 10 files. Matches the PR body exactly.

The complete producer list:

# Producer Reachable how Post-fix behaviour
P-A Handler.getAllowedAccounts (handler.go:527) 17 of the 18 call sites admin-key sentinel → (nil,nil); h.auth == nil → error; else delegates to P-B
P-B authServiceAdapter.GetAllowedAccountsAPI (app.go:1159) via P-A and P-C delegates to P-D
P-C Handler.checkRevokeOwnAccountAccess (handler_purchases_revoke.go:394) 1 call site — calls h.auth.GetAllowedAccountsAPI directly, bypassing P-A delegates to P-B
P-D Service.ResolveAllowedAccounts (service_group.go:172) the only production reader of AuthContext.AllowedAccounts the new guard

Things I checked that are not producers:

  • AuthContext.CanAccessAccount (types.go:205) reads ctx.AllowedAccounts but has zero non-test callers (git grep CanAccessAccount → 22 hits, all in service_group_test.go plus the definition).
  • The three other GetAuthContext/BuildAuthContext callers (service_apikeys.go:122, :484, service_apikeys_api.go:399) read .Permissions only, never .AllowedAccounts.
  • AuthContext composite literals: exactly two in non-test code (service_group.go:108, service_apikeys_api.go:363). The second sets only User + Permissions.
  • Only one non-test implementation of the GetAllowedAccountsAPI interface method exists (authServiceAdapter); the other two are test mocks.
  • No caching or memoization of auth context / scope anywhere in internal/auth (the only two cache hits in that package are unrelated comments), so every call re-resolves through P-D.

The correction. The claim is true of the auth-side resolver — P-B and P-C both funnel into P-D, so the ≥1-group guard is genuinely single-point and a fourth resolver-level failure mode cannot slip past it. It is not true of the API layer: there are two API-layer producers (P-A and P-C), and the nil-auth guard added by this PR is only on P-A. P-C dereferences h.auth without any nil check.

That is not exploitable today: authorizeSessionRevoke (handler_purchases_revoke.go:352-377) short-circuits apiKeyAdminUserID at the top and then calls h.auth.HasPermissionAPI before reaching checkRevokeOwnAccountAccess, so a nil h.auth panics there first (a 500, i.e. still closed). But P-C is protected by call ordering in a sibling function, not by a guard of its own — worth a line in the follow-up issue alongside the two hand-rolled consumers.

2. The two hand-rolled consumers — deferral is correct

Enumerating every way an empty-and-nil-error list can reach them post-fix:

  • getAllowedAccounts: admin-key sentinel (nil, nil); or GetAllowedAccountsAPI returning ([], nil), which post-fix requires ≥1 resolved group whose union of allowed_accounts is empty (the legacy default). Every other path now errors.
  • ResolveAllowedAccounts: BuildAuthContext error → error; zero resolved groups → error; otherwise the real union.

So empty is reachable only from a legitimately-unrestricted principal. handler_marketplace.go:435 and handler_purchases_revoke.go:398 are correct today. Deferring the representation change is the right call.

Two accuracy notes on the reasoning, neither changing the verdict:

  • "empty can only come from a successful resolution" is slightly off — the admin-key sentinel is a second source of empty, and it is a short-circuit, not a resolution. It is legitimately unrestricted, so the conclusion survives.
  • handler_purchases_revoke.go:398 uses stringInSlice (handler_history.go:893), an exact string match. Unlike auth.MatchesAccount it honours neither the "*" wildcard nor account-name entries. A group carrying allowed_accounts=['*'] or a name-based scope, holding revoke-own but not revoke-any, would be denied a revoke it should get. Pre-existing, fail-closed direction, out of scope — but it belongs in the same follow-up, because it is a second way that call site diverges from IsUnrestrictedAccess/MatchesAccount.

3. The separator ("a resolved GROUP, not a resolved account list")

len(authCtx.Groups) == 0 ⟺ zero groups loaded ⟺ (user.GroupIDs empty) OR (every listed group failed to load). Verified against all six cases; Groups is appended only on a GetGroup that returned non-nil with no error, so the guard's input is durable.

I traced the field the guard reads rather than the one it is about:

  • scanGroup (store_postgres.go:812) propagates any scan error; a NULL allowed_accounts column degrades to a nil slice without error — but that is exactly the documented legacy "no restriction" case, i.e. the third legitimate principal, not a failure. A group that resolves with a degraded account list therefore still resolves as a group, and the fix does not claim otherwise.
  • A degraded user.GroupIDs (NULL column → nil) now refuses instead of granting everything. That is a fourth producer the fix closes, which the PR body does not claim.

Is there a legitimate zero-group principal? I went looking for one. There is not:

  • validateCreateUserRequest (service_user.go:135) rejects zero-group creation with ErrNoGroups (issue Revamp authorization: group-membership-only (remove roles), require >=1 group per user #907).
  • guardGroupChange (service_user.go:370) rejects zero-group updates with the same sentinel.
  • SetupAdmin hard-assigns DefaultAdminGroupID.
  • apiKeyAdminUserID (handler.go:225) is the only synthetic principal in the codebase, and it never reaches the resolver: getAllowedAccounts short-circuits it, and the one direct caller (authorizeSessionRevoke) short-circuits it too, before checkRevokeOwnAccountAccess.
  • No user SSO/JIT provisioning exists — the oidc packages are CUDly acting as an IdP for cloud federation, not a login path.

The only zero-group rows possible are legacy pre-#907 records or direct DB writes. Those hold zero permissions as well (permissions come solely from collectGroupsAndAccounts), so requirePermission 403s them before any of the four scoping helpers runs — all four document "requirePermission must fire first" (scoping.go:24, :54, :78, :112). The change turns a would-be 403 into a 500 for such a principal on any path that reaches the resolver first; strictly stricter, no operator is locked out.

"Resolved" means the same thing on every path: one predicate, one append site.

4. Mutations — re-run independently, all three reproduce

Separate worktree, running only the 10 new top-level tests. Baseline: 10 PASS / 0 FAIL.

Mutation Result Killed
P1 — len(authCtx.Groups) == 0 guard deleted auth exit 1, api exit 0 1/10 — TestResolveAllowedAccounts_FailsClosed
P2 — nil-auth reverted to return nil, nil auth exit 0, api exit 1 1/10 — TestGetAllowedAccounts_FailsClosedWhenAuthMissing
P3 — inverse, refuse everyone (resolver + admin sentinel) auth exit 1, api exit 1 3/10 top-level, = 5 leaf cases

On the "5/10" in the PR body: the number 5 is real, the denominator is a different unit. P3 kills 3 top-level tests, which expand to 5 leaf cases: the three subtests of TestResolveAllowedAccounts_LegitimatePrincipalsStillResolve, plus ..._PartialResolutionIsAllowedAndNarrower and TestGetAllowedAccounts_AdminAPIKeyStillUnrestricted. The suite has 10 top-level tests but 17 leaf cases. Worth restating as "5 of 17 leaf cases / 3 of 10 top-level tests" so the ratio is readable.

The substantive point stands and is the important one: the positive controls bite. An over-blocking implementation is caught by named legitimate-principal tests, so this is not a refusal-only suite.

One structural note on P3: the internal/api controls did not fire, because they stub GetAllowedAccountsAPI on a mock and so cannot see a resolver-level mutation. I therefore ran a fourth mutation, P3b — getAllowedAccounts refuses unconditionally at the API seam — which kills 6/6 of the API tests, including all three refusal tests (they fail on mock.AssertExpectations / a specific errors.Is(err, errNotFound) assertion rather than on a bare "did it error"). The two packages' controls each bite at their own seam; together they cover both. Nothing vacuous.

On P1/P2 being killed only by their own test: that is inherent, not a coverage gap. Each is a specific guard on a specific branch, and the pairing is deliberate (P1 at the resolver, P2 at the API seam). The alternative — a guard killed by many tests — would mean the guard sits on a hot shared path, which this one does not.

5. Pre-existing-suite claim

Verified rather than accepted: I applied the source fix without the two new test files and ran go test ./internal/.... Result reported in the follow-up comment with the gate exit codes.

@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review, part 2 — pre-fix demonstration, code-quality findings, gates

Continues the previous comment. Same worktree, head 6318fc4ce.

Pre-fix demonstration (the test must fail on the bug)

Copied the two new test files onto base e0fc45e25 and ran them there.

  • internal/api/account_scope_fail_closed_test.go compiles on base. TestGetAllowedAccounts_FailsClosedWhenAuthMissing FAILS: Error: An error is expected but got nil — a nil auth service must refuse, not grant unrestricted access. The other five pass on base because they drive MockAuthService and never reach the changed line. So producer Update Deployment Model to Terraform #1 has a test that genuinely fails pre-fix and passes post-fix.
  • internal/auth/account_scope_fail_closed_test.go cannot compile on base — svc.ResolveAllowedAccounts undefined — because the function is new. Its pre-fix equivalent is mutation P1 (guard deleted → return authCtx.AllowedAccounts directly, which is the old GetAllowedAccountsAPI body), and P1 kills TestResolveAllowedAccounts_FailsClosed. Equivalent evidence for producers Setup CI/CD Process #2 and AWS + Azure: add read-only sanity checks + AWS RI exchange #3.

"The pre-existing suite passed unchanged" — verified

Applied the source fix, removed both new test files, ran go test ./internal/...: exit 0, 20 packages ok, including internal/api (24.1s), internal/auth (41.1s), internal/server (32.0s). No existing test covered any of the three producers. Consistent with #1596.

Every consumer checks the error

The new guard only protects callers that check err — the returned value on the refusal path is nil, and IsUnrestrictedAccess(nil) is still true. Python scan (regex over the assignment line plus the following line, not grep -c) across all non-test files in internal/api: 18/18 call sites are immediately followed by if err != nil { (one uses aErr, handler_ri_exchange.go:1265). No caller can reach the fail-open value.

"Resolved" means the same thing on both axes

There is a second, duplicated group-resolution loop: GetUserPermissions (service_group.go:58-90) walks user.GroupIDs with the identical ErrNoRows/nil skip, and it is not covered by the new guard. It is nevertheless closed by construction — zero resolved groups yields zero permissions, and every scoping helper documents that requirePermission fires first (scoping.go:24, :54, :78, :112). I checked the API-key branch too: computeEffectivePermissionsFromAuthCtx (service_apikeys.go:462) intersects rather than falling back, so a key whose owner's groups vanish returns an empty set rather than the key's own permissions. Both axes fail closed; the two senses of "resolved" agree.

Findings (all minor, none blocking)

F1 — internal/api/account_scope_fail_closed_test.go:157: dead statement propping up a dead import.

	_ = mock.Anything

Nothing else in the file uses mock. (grep: this is the only mock. occurrence). The statement exists only to keep github.com/stretchr/testify/mock in the import block. Drop both.

F2 — internal/auth/account_scope_fail_closed_test.go:74: vacuous assertion.

	require.Error(t, err, "...")
	...
	assert.False(t, IsUnrestrictedAccess(got) && err == nil,
		"a failed resolution must never yield an unrestricted scope")

require.Error has already established err != nil, so err == nil is a constant false and the conjunction can never be true. The assertion cannot fail under any implementation — it is decoration, and the repo has an open programme (#1740) removing exactly this shape.

The comment above it ("The property in its own terms: whatever comes back must not be readable as 'all accounts'") also does not describe what happens: got is nil, and IsUnrestrictedAccess(nil) returns true. The real property is the opposite and worth stating plainly: the protection is carried entirely by the error return; the returned slice on the refusal path is still readable as unrestricted, which is safe only because all 18 call sites check err. Either delete the line, or replace it with that sentence as a comment.

F3 — same file, MockConfigStore never asserted. Three instances (:62, :129, :143) register .On("GetCloudAccount", ...) but only the MockAuthService gets t.Cleanup(m.AssertExpectations). The project convention is t.Cleanup for every mock. Without it, a refactor that reordered requireAccountAccess to check scope before fetching the account would leave TestRequireAccountAccess_ScopedPrincipalUnchanged/out-of-scope_account_is_refused green (it would still get errNotFound, just from the other branch).

Not a finding, but checked because it was asked: internal/auth/test_helpers.go has no build tag, imports testing, and is therefore compiled into the production auth package — so MockStore and createTestService are reachable in principle. A Python scan of all non-test .go files found 46 references, all inside test_helpers.go itself. Nothing in production constructs a mock-backed Service, so it is not a scope producer. (The testing import in a production package is a pre-existing hygiene issue unrelated to this PR.)

F4 — for the follow-up issue, not this PR. Two items to fold in alongside the two hand-rolled emptiness checks already named in the PR description:

  • checkRevokeOwnAccountAccess (handler_purchases_revoke.go:394) is a second API-layer producer that calls h.auth.GetAllowedAccountsAPI directly, so this PR's nil-auth guard does not cover it. Not exploitable today only because authorizeSessionRevoke dereferences h.auth first — protection by call ordering in a sibling function, not by a guard.
  • The same call site's stringInSlice (handler_history.go:893) is an exact string match honouring neither the "*" wildcard nor account-name entries, unlike auth.MatchesAccount. A group with allowed_accounts=['*'] or a name-based scope, holding revoke-own but not revoke-any, is denied a revoke it should get. Pre-existing, fail-closed direction.

Gates (re-run here at head, exit codes captured separately from stdout)

Gate Result
go build ./... exit 0
go vet ./... exit 0
go test ./... exit 0, 32 packages ok, 0 FAIL (internal/api 36.5s, internal/auth 54.8s, internal/server 27.6s)
gocyclo -over 10 -ignore "_test\.go" . (CI-pinned v0.6.0 form) exit 0, 0 lines of output; both changed functions measure 3
golangci-lint v2.10.1 (CI-pinned, installed fresh — the local 2.11.4 was not used) exit 0, 0 issues.

On the lint gate specifically: the first two attempts here were tool failures, not clean runs, and I did not report them as passes. Attempt 1 died on parallel golangci-lint is running (exit 3, another session held the lock); attempt 2 printed 0 issues. and then Timeout exceeded and exited 4 on a cold cache — a nonzero exit with near-empty output is a failed run. The result above is attempt 3, uncontended and with a warm cache, budget raised to 40m (which changes only the time budget, not the ruleset): 0 issues., exit 0.

Note on scope of the test evidence: CI's test job runs bare ./... from the repo root, which under go.work covers the root module only. providers/*, pkg and tests/e2e are not exercised by CI (#1751). Nothing in this change touches them — the Python sweep found no scope logic outside internal/api, internal/auth, internal/server.

Overall

The fix is correct and the reasoning behind choosing the minimal resolver-level guard over the type change holds up under the enumeration. F1-F3 are test hygiene; F4 belongs in the follow-up. No blocking findings.

@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

BLOCKING — #1752 has the partial-resolution hole. Correcting my own earlier verdict.

My first two comments concluded "no blocking findings". That was wrong, and the error is mine rather than a gap in the brief: I examined this exact configuration during the separator analysis and dismissed it with a bad argument — I reasoned that a group with no allowed_accounts "would grant unrestricted anyway, so losing the other group is not a bypass". That compares against what one group would grant in isolation, not against what the configured combination actually enforces. The system enforces the union, and the union of [] and ["acct-A"] is restricted. Losing the contributing group widens it.

Prompted by the finding on the sibling PR #1737, I built the configuration against #1752 specifically and ran it. It reproduces.

Proof 1 — the resolver widens

internal/auth, real Service over auth.MockStore, principal in two groups: gPerm carrying permissions with no AllowedAccounts (contributes nothing to the union), gScope carrying the restriction ["acct-A"].

=== #1752 ResolveAllowedAccounts: partial-resolution probe ===
  baseline (both resolve)                        scope=[acct-A]  unrestricted=false
  PARTIAL: restricting group ErrNoRows           scope=[]        unrestricted=true    <<<
  PARTIAL: restricting group (nil, nil)          scope=[]        unrestricted=true    <<<
  TOTAL failure (the case the guard closes)      REFUSED  err=...: no group resolved

len(authCtx.Groups) == 0 is satisfied by gPerm alone, so the guard passes and the widened scope is returned with a nil error.

Proof 2 — the widening reaches enforcement

Same probe driven end-to-end through the real seams: a realScopeAuth whose GetAllowedAccountsAPI delegates to Service.ResolveAllowedAccounts, exactly as authServiceAdapter does, wired into a real api.Handler. Principal is configured restricted to acct-A; the probe asks for acct-B.

  baseline (both groups resolve)         scope=[acct-A] unrestricted=false
      requireAccountAccess(acct-B)=REFUSED     revoke-own(acct-B)=REFUSED
  PARTIAL: restricting group ErrNoRows   scope=[]       unrestricted=true
      requireAccountAccess(acct-B)=GRANTED <<<  revoke-own(acct-B)=GRANTED <<<
  PARTIAL: restricting group (nil, nil)  scope=[]       unrestricted=true
      requireAccountAccess(acct-B)=GRANTED <<<  revoke-own(acct-B)=GRANTED <<<

requireAccountAccess backs the per-record scoping across the codebase, and checkRevokeOwnAccountAccess's len(allowed) > 0 guard (handler_purchases_revoke.go:398) skips the ownership check entirely — so revoke-own reaches a purchase in any account.

Proof 3 — the guard closes the unreachable half

Measured on the same principal:

  partial (gPerm survives)      view:accounts=true
  total  (nothing survives)     view:accounts=false

Under total failure the principal also loses every permission, so requirePermission denies them before the scope guard is ever consulted. The half the guard closes was already closed upstream; the half it leaves open is the one where the principal still holds the permission that makes the widened scope usable. Identical to the #1737 finding.

Where the PR's reasoning goes wrong

service_group.go:170 and TestResolveAllowedAccounts_PartialResolutionIsAllowedAndNarrower both assert that partial resolution is narrower:

Under-reporting scope makes access STRICTER, so it is safe; only total failure had to be refused.

That premise holds only when the surviving groups still contribute entries — which is the exact configuration the test pins (g1 carries ["acct-A"] and survives). It fails when the survivors contribute nothing: the union collapses to [], and IsUnrestrictedAccess reads [] as everything. The test pins the safe half and leaves the dangerous half uncovered. The docstring's "verified across all six cases" refers to six single-group cases; no multi-group case is among them.

Precondition, stated precisely (this tempers severity, it does not remove it)

The widening requires ≥1 group with a genuinely empty allowed_accounts to survive while every group carrying entries fails to load. A group carrying ['*'] does not trigger it — ['*'] is already unrestricted at baseline, so nothing widens.

I checked what ships: all seven seeded groups carry ARRAY['*'], not empty (000024 Administrators / Purchase Approvers / Plan Authors / Viewers, 000057 Standard Users, 000059 + 000064 Purchaser). So this is not reachable out of the box.

It is reachable on operator-configured groups: CreateGroupAPI (service_api.go:283) sets AllowedAccounts: req.AllowedAccounts with no validation, so omitting the field yields an empty list, and UpdateGroupAPI (:321) accepts an explicit []. An empty allowed_accounts is documented as the supported backward-compat default (types.go:157-167) and is one of the three legitimate unrestricted principals this PR's own table enumerates. A deployment with one such group plus per-account scoped groups — the ordinary shape when scoping is adopted incrementally — is exposed, on the same ordinary triggers as #1748 itself (a group deleted mid-session, a lagging replica, a store returning (nil, nil)).

Cross-PR question: the adapter

Settled by reading both trees at head:

Tree authServiceAdapter.GetAllowedAccountsAPI (internal/server/app.go:1159)
origin/main BuildAuthContext then return authCtx.AllowedAccounts, nil — unhardened
#1752 head 6318fc4ce return a.service.ResolveAllowedAccounts(ctx, userID) — hardened for total failure

The #1737 reviewer was correct for #1737's tree, which is main plus #1737 and does not contain #1752's change (#1737 is still OPEN on sec/1550-group-grant-ceiling, unmerged). So the adapter is: closed by #1752 for total failure, still open for the partial case — in both PRs.

Shape of a fix (author's call; noting a trap in each)

The separator has to be "no group the principal belongs to failed to resolve", not "at least one resolved". Two options:

  1. Refuse on any unresolved group. Strictest and simplest. But it reverses the deliberate // Group was deleted; skip it rather than failing the entire request behaviour — a user with one stale membership would be refused everywhere until an admin cleans it up. Trap: do not implement this by comparing len(authCtx.Groups) to len(user.GroupIDs) — GroupIDs can contain duplicates, which would refuse a legitimate principal. Count the skips instead.
  2. Refuse only when the union is empty and at least one group was skipped. Targets exactly the widening and preserves the deleted-group tolerance for restricted principals. Narrower blast radius on behaviour.

Whichever lands, the regression test must be the multi-group shape above — the current partial-resolution test passes with the bug present.

Method / hygiene

Fresh worktree at head 6318fc4ce (both probes are throwaway files, not proposed for the branch). Counts from Python, never grep -c. No CodeRabbit ping, no merge. The gates in my previous comment still stand — this is a correctness hole that a green suite does not surface, which is the point.

The previous guard closed only TOTAL resolution failure. It rested on a
premise that is false for AllowedAccounts: "partial resolution under-reports
scope, which makes access stricter".

AllowedAccounts is a UNION in which the empty set means EVERYTHING, so
dropping a contributing group does not narrow it. The union of [] and
["acct-A"] is restricted; lose the group carrying ["acct-A"] and it collapses
to [], which reads as every account. Dropping a group WIDENS.

Reproduced by execution with a two-group actor, one granting update:groups
with no allowed_accounts, one carrying the restriction:

  baseline: both groups resolve       scope=[acct-A]  unrestricted=false
  PARTIAL: restricting group ErrNoRows scope=[]       unrestricted=TRUE
  PARTIAL: restricting group (nil,nil) scope=[]       unrestricted=TRUE
  TOTAL failure (the old guard's case)  refused

The open half was also the more reachable one: under total failure the actor
loses every permission too, so requirePermission denies at the gate before
scope is consulted.

A single-group configuration cannot exhibit this, which is why the earlier
"verified across all six cases" found nothing -- all six were single-group.

The guard is now: refuse when the union is empty AND at least one group was
skipped. That targets exactly the widening. It deliberately does NOT refuse on
any unresolved group, which would reverse the intentional "group was deleted;
skip it rather than failing the entire request" behaviour and lock out a user
with one stale membership everywhere until an admin cleaned up -- an
unconditional availability regression traded for a conditional security hole,
on a path with ~37 consumers.

The skip count is recorded in collectGroupsAndAccounts at the point of
skipping, NOT derived by comparing len(Groups) to len(User.GroupIDs):
GroupIDs may contain duplicates, so that comparison reports phantom skips and
refuses legitimate principals. Covered by a regression test.

TestResolveAllowedAccounts_PartialResolutionIsAllowedAndNarrower asserted the
false invariant and passed with the bug present. It is INVERTED rather than
supplemented: a test encoding a false invariant is worse than no test, because
it tells the next reader the case is covered. Its replacement uses the
two-group shape and fails against the old guard.

Preconditions, stated precisely: all seven seeded groups ship
allowed_accounts = ARRAY['*'], which is already unrestricted, so nothing
widens by default and this is not exploitable out of the box. It is reachable
on operator-configured groups, because CreateGroupAPI accepts an omitted
allowed_accounts (yielding []) and UpdateGroupAPI accepts an explicit [] --
the documented backward-compat default. A deployment with one such group
alongside per-account scoped groups is exposed, on the same triggers as the
rest of #1748.

Refs #1748.
@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Delta review of 86d1164b9 — the widening is closed and covered. One new false refusal, one vacuous test.

Fresh worktree at 86d1164b9. All probes throwaway and removed before the gates ran. No CodeRabbit ping, no merge.

The security hole I reported is genuinely fixed — verified at the resolver and at both enforcement seams, and the regression test actually kills the old guard rather than describing it. But the delta introduces a new unconditional refusal for unrestricted principals, and it is triggered by the single most ordinary admin action in this area: deleting a group. I would not merge until the guard's first condition is narrowed; it is a one-token change.


1. The widening is closed ✅

Same configuration I reported, driven end-to-end through a realScopeAuth that mirrors authServiceAdapter (delegates to ResolveAllowedAccounts) into a real api.Handler:

=== survivor contributes no accounts; restricting group carries [acct-A] ===
  baseline (both resolve)               scope=[acct-A]  unrestricted=false  reqAcctAccess(acct-B)=REFUSED  revokeOwn(acct-B)=REFUSED
  PARTIAL restricting group ErrNoRows   scope=REFUSED                       reqAcctAccess(acct-B)=REFUSED  revokeOwn(acct-B)=REFUSED
  PARTIAL restricting group (nil,nil)   scope=REFUSED                       reqAcctAccess(acct-B)=REFUSED  revokeOwn(acct-B)=REFUSED

Both variants refused at the resolver, and requireAccountAccess and checkRevokeOwnAccountAccess both stay refused. The legacy-default control still works: both groups resolving with neither carrying accounts and zero skips still yields [] / unrestricted, so the backward-compat default is intact.

2. Deleted-group tolerance — preserved for restricted principals, destroyed for unrestricted ones ⚠️

The claim holds where it was tested. Survivor carries [acct-A], the lost group carried [acct-C]:

  baseline (both resolve)      scope=[acct-A acct-C]  unrestricted=false   acct-B REFUSED
  stale membership ErrNoRows   scope=[acct-A]         unrestricted=false   acct-B REFUSED
  stale membership (nil,nil)   scope=[acct-A]         unrestricted=false   acct-B REFUSED

No lockout, scope narrows, out-of-scope stays refused.

I checked the underlying reasoning across every shape, not just this one. Entries are additive strings, and a loss can only remove strings. So for complete union C and partial union U ⊆ C:

shape widened? guard correct?
U non-empty, no * no — U ⊆ C, strictly narrower allow ✅
U empty, skips > 0 yes — [] reads as everything refuse ✅
U empty, skips = 0 no — genuinely unrestricted allow ✅
U contains *, skips > 0 no — * ∈ U ⟹ * ∈ C, unrestricted either way refuse ❌

The last row is the problem. The guard's first condition is IsUnrestrictedAccess(authCtx.AllowedAccounts), which is true for empty or for a union containing "*". A principal whose surviving union contains "*" was already maximally wide at baseline; no loss can widen them further. Refusing them is provably zero security benefit and pure availability cost.

Measured — survivor carries ["*"], which all seven seeded groups do:

=== survivor carries ['*']; one stale membership ===
  baseline (both resolve)      scope=[* acct-A]  unrestricted=true   reqAcctAccess(acct-B)=GRANTED
  stale membership ErrNoRows   scope=REFUSED                         reqAcctAccess(acct-B)=REFUSED  <<< regression
  stale membership (nil,nil)   scope=REFUSED                         reqAcctAccess(acct-B)=REFUSED  <<< regression

Reachability is high, and it is the ordinary path. PostgresStore.DeleteGroup (store_postgres.go:585) is a bare DELETE FROM groups WHERE id = $1. users.group_ids is a plain UUID[] with no foreign key and no delete trigger — the migrations say so themselves in 000024_seed_default_groups.down.sql ("group_ids is a plain UUID[] with..."), and the only array_remove calls in the tree are inside migrations, never at runtime. So deleting any group leaves a dangling ID on every member permanently, until an admin edits each affected user.

Consequence: an admin deletes one custom group → every member of it who is also in any seeded group is refused on every account-scoped endpoint, and since the error is a plain fmt.Errorf these surface as 500s, not a clean 403. They still hold their permissions, so they pass requirePermission and then fail at the scope check.

There is an irony worth stating plainly: the "*" that makes the widening unreachable out of the box is the same "*" that triggers this refusal. A default deployment gets none of the new guard's security benefit and all of its availability cost.

Fix is one token — the condition wants "the union is empty", which is what the docstring already says three times ("refuse when the union is empty AND at least one group was skipped"):

if len(authCtx.AllowedAccounts) == 0 && authCtx.SkippedGroups > 0 {

That still refuses every widening (the widening is the empty case) and stops refusing principals who were already unrestricted. The code and its own documentation currently disagree; the docstring is right.

3. The duplicates trap — it does not exist here, and the test guarding it is vacuous ⚠️

This one is my error, and the author implemented against it in good faith. I warned that detecting skips via len(Groups) vs len(User.GroupIDs) would misfire on duplicate IDs. I did not check the append semantics. collectGroupsAndAccounts appends to Groups once per ID, with no dedup — so a duplicated ID produces two entries and the counts stay aligned.

Measured on four shapes:

  duplicate ids [g1,g1], g1 resolves    len(GroupIDs)=2 len(Groups)=2 SkippedGroups=0  lenDiff=0  agree=true
  distinct [g1,g2], both resolve        len(GroupIDs)=2 len(Groups)=2 SkippedGroups=0  lenDiff=0  agree=true
  distinct [g1,g2], g2 skipped          len(GroupIDs)=2 len(Groups)=1 SkippedGroups=1  lenDiff=1  agree=true
  dupes + a skip [g1,g1,g2]             len(GroupIDs)=3 len(Groups)=2 SkippedGroups=1  lenDiff=1  agree=true

len(GroupIDs) - len(Groups) == SkippedGroups on every shape — the two are equivalent, duplicates included. Confirmed by mutation: swapping the counted skip for len(authCtx.Groups) != len(authCtx.User.GroupIDs) leaves all 7 tests passing, so TestResolveAllowedAccounts_DuplicateGroupIDsAreNotSkips passes under the very implementation it exists to exclude. It cannot fail.

To be precise, the test is vacuous with respect to its stated purpose, not inert in general: a third mutation that also refuses any multi-group principal (len(authCtx.Groups) > 1 || ...) does kill it, along with MultiGroupBaselineStaysRestricted. So it catches over-blocking; it just cannot catch the len-comparison it was written to exclude.

Two consequences:

  • Retitle/rewrite it to assert what it actually guards, or drop it — as named and commented it tells the next reader a trap is covered when it is not.
  • service_group.go:188-190 and the AuthContext.SkippedGroups comment in types.go both state as fact that the len comparison "would report phantom skips and refuse legitimate principals". That is false for this code, and it is my incorrect claim now recorded as a justification. Please correct both.

Keeping the counted field is fine — it is more direct and stays correct if dedup is ever added to collectGroupsAndAccounts. Only the test and the two comments need fixing.

Minor, same class: the new comment says migration 000057's users_min_one_group CHECK makes len(Groups) == 0 unreachable. The CHECK is cardinality(group_ids) >= 1 — it guarantees one group ID, not one resolvable group. A user whose sole group was deleted satisfies it and resolves zero groups; measured, that case fires the first guard ("1 group(s) could not be resolved"), not the second. The second guard is indeed unreachable, but because the first one shadows it, not because of the CHECK alone.

4. The false-invariant test was inverted, not supplemented ✅

TestResolveAllowedAccounts_PartialResolutionIsAllowedAndNarrower is gone — git grep for the name across all *.go returns nothing. Baseline at head: 7 top-level tests, 7 PASS.

Mutation M1, reverting to the total-failure-only guard:

  --- FAIL: TestResolveAllowedAccounts_PartialResolutionThatWidensIsRefused
  --- PASS: (the other 6)

Exactly as claimed: 1/7, and it is the new test. The finding is covered, not described.

The MultiGroupBaselineStaysRestricted control also bites rather than decorating — mutation M3, adding len(authCtx.Groups) > 1 || to the guard so any multi-group principal is refused, kills it (2/7 with the duplicates test). So the refusal test cannot be satisfied by an implementation that simply refuses the two-group shape.

5. app.go:1159 — confirmed fixed by this PR ✅

At 86d1164b9 the adapter reads return a.service.ResolveAllowedAccounts(ctx, userID), so it inherits both the total-failure and the partial guard. On origin/main it is still BuildAuthContext + return authCtx.AllowedAccounts, nil. The cross-PR discrepancy was two reviewers reading different trees, as diagnosed.

6. Severity framing — accurate in both directions, with one refinement

  • "Not exploitable out of the box" — correct. All seven seeded groups ship ARRAY['*'] (000024 ×4, 000057, 000059, 000064); a union containing * is unrestricted at baseline, so nothing widens.
  • "Reachable on operator-configured groups" — correct. CreateGroupAPI (service_api.go:283) applies req.AllowedAccounts with no validation, UpdateGroupAPI (:321) accepts an explicit [].
  • Refinement: the exposed shape is narrower than "any deployment with an operator-configured group". The widening needs every surviving group to contribute nothing — so a user who also holds a seeded group is protected by its *. The exposed deployment is one that moved onto custom groups, at least one carrying no allowed_accounts. Real, and not overclaimed.

7. Gates at 86d1164b9

Gate Result
go build ./... exit 0
go vet ./... exit 0
go test ./... still running — follow-up comment
gocyclo -over 10 -ignore "_test\.go" . exit 0, 0 lines; collectGroupsAndAccounts 7, ResolveAllowedAccounts 5
golangci-lint v2.10.1 (CI-pinned) still running — follow-up comment

Summary

Point 1 ✅, point 4 ✅, point 5 ✅. Point 2 holds for restricted principals but the guard over-fires on the "*" case — the one change I would want before merge, one token. Point 3 is a vacuous test plus two comments documenting a trap that does not exist in this code, from a warning I got wrong.

misspell enforces US English; two comments carried 'behaviour'. The wording
came from a briefing message written in British English and was transcribed
into code without being re-checked against the repo's own gate.

Refs #1748.
@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Gates at 86d1164b9 — lint is RED, and both findings are in this delta

Follow-up to my delta review above, filling in the two gates that were still running.

Gate Result
go build ./... exit 0
go vet ./... exit 0
go test ./... exit 0 — 32 packages ok, 0 FAIL (internal/api 23.7s, internal/auth 8.9s, internal/server 19.3s)
gocyclo -over 10 -ignore "_test\.go" . exit 0, 0 lines; collectGroupsAndAccounts 7, ResolveAllowedAccounts 5
golangci-lint v2.10.1 (CI-pinned) exit 1 — 2 issues ❌
internal/auth/account_scope_fail_closed_test.go:225:46: `behaviour` is a misspelling of `behavior` (misspell)
	// absorbed rather than refused. This is the behaviour option 1 ("refuse on any
	                                             ^
internal/auth/service_group.go:181:4: `behaviour` is a misspelling of `behavior` (misspell)
	// behaviour and lock out a user with one stale membership everywhere until an
	   ^
2 issues:
* misspell: 2

Non-empty findings block plus a nonzero exit — a genuine failing run, not a tool error (contrast the earlier lock/timeout failures I discarded on the previous head, where exit 3 and 4 came with no findings). The CI Lint job will go red on this commit.

Both are introduced by 86d1164b9. git diff 6318fc4ce..86d1164b9 shows them as added lines (+// behaviour and lock out... in service_group.go, +// ... the behaviour option 1 ... in the test) — they are in the new prose explaining why option 1 was rejected.

Worth knowing before anyone reaches for misspell --fix: a Python scan finds 52 occurrences of behaviour across 38 files, but the other 50 all live in providers/* and pkg/*, which are outside the root module that golangci-lint lints from the repo root — which is why only these two are reported. So this is not pre-existing debt leaking into your PR; these two are genuinely yours, and they are the only two the linter can see. Fix them by hand (plain comment prose, no identifiers involved) rather than running an autofix across the tree.

Where this leaves the merge

Three things to change, none of them large:

  1. IsUnrestrictedAccess(...) → len(authCtx.AllowedAccounts) == 0 in the guard's first condition — the false refusal for "*"-carrying principals, which every seeded group produces. This is the one with user-visible consequences: delete any group and its members who also hold a seeded group get 500s on every account-scoped endpoint until an admin edits each of them.
  2. The two behaviour spellings — CI blocker.
  3. The duplicates test and the two comments citing my incorrect trap claim — accuracy, no behavioural change.

The security fix itself is correct and, per mutation M1, genuinely covered by its test. Once (1) and (2) land I have nothing further.

@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Head moved to fb7a148b3 — spelling fixed, the false refusal is not

fb7a148b3 ("style(auth): use US spelling in the partial-resolution comments") landed after my delta review. Checked it:

  • The diff is comment-only: 2 files, 1 insertion and 1 deletion each, and a filtered count of changed non-comment lines is 0. So every behavioural result in my review above — the V1-V4 probes and mutations M1/M3 — carries over to this head unchanged.
  • It addresses finding 2 (the two behaviour spellings that made golangci-lint v2.10.1 exit 1). I have not re-run lint at fb7a148b3; the change targets exactly the two reported lines, but that is a textual match, not a verified green run.
  • It does not address finding 1. The guard at service_group.go:196 still reads:
if IsUnrestrictedAccess(authCtx.AllowedAccounts) && authCtx.SkippedGroups > 0 {

So the false refusal stands: any principal whose surviving union contains "*" — which every one of the seven seeded groups produces — is refused on every account-scoped endpoint as soon as one of their memberships is unresolvable, which is the permanent state of every member of any deleted group.

  • It does not address finding 3 (the duplicates test that passes under the implementation it exists to exclude, and the two comments recording my incorrect trap claim as fact). No behavioural impact.

Still one code change away: IsUnrestrictedAccess(authCtx.AllowedAccounts) → len(authCtx.AllowedAccounts) == 0. I will re-verify lint and the guard on whatever head carries it.

The partial-resolution guard tested IsUnrestrictedAccess(AllowedAccounts),
which is true for an empty union OR for one containing "*". A principal whose
surviving union carries "*" was ALREADY maximally wide at baseline, so no lost
group can widen them. Refusing them is zero security benefit and pure
availability cost.

All seven seeded groups ship allowed_accounts = ARRAY['*'], so this hit the
default shape: delete any group, and its members who also hold a seeded group
got errors on every account-scoped endpoint until an admin edited each of them
individually. That is the unconditional availability regression that option 1
was rejected to avoid, arriving through the predicate instead.

Measured, survivor carrying ["*"]:

  baseline (both resolve)     scope=[* acct-A]  unrestricted=true
  stale membership ErrNoRows  REFUSED   <-- regression
  stale membership (nil,nil)  REFUSED   <-- regression

The test is now len(AllowedAccounts) == 0. Only an EMPTY union can have been
widened by a loss.

Both directions are mutation-verified and each is guarded by exactly one test:
reverting the predicate kills only WildcardSurvivorToleratesSkippedGroup;
removing the guard entirely kills only PartialResolutionThatWidensIsRefused.
Neither test would catch the other's defect, so both are load-bearing.

Also retracts an incorrect claim recorded in two comments and a test name. The
skip count was documented as NOT derivable from len(GroupIDs) - len(Groups)
because GroupIDs may contain duplicates. That is wrong: Groups is appended once
per ID with no dedup, so duplicate IDs produce duplicate entries and the counts
stay aligned on every shape. The counted-skip implementation is kept because it
states the intent directly rather than inferring it from two lengths, but the
comments no longer assert a trap that does not exist.

TestResolveAllowedAccounts_DuplicateGroupIDsAreNotSkips could not fail under
the implementation it claimed to exclude -- swapping the counted skip for the
length subtraction left it passing. It does catch over-blocking, so it is
renamed to DuplicateMembershipIsAllowed rather than deleted: a test whose name
promises something it cannot check is worse than one scoped to what it proves.

Refs #1748.
@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Delta review of 07e88eebd — items 1-5 all hold. Verified independently.

Fresh worktree at 07e88eebd, mutation worktree separate, probes throwaway and removed before the gates ran. No CodeRabbit ping, no merge.

1. The wildcard survivor is allowed again ✅

My exact probe configuration, driven end-to-end through a realScopeAuth mirroring authServiceAdapter into a real api.Handler:

=== survivor carries ['*'] (all seven seeded groups do); one stale membership ===
  baseline (both resolve)      scope=[* acct-A]  unrestricted=true  reqAcctAccess(acct-B)=GRANTED
  stale membership ErrNoRows   scope=[*]         unrestricted=true  reqAcctAccess(acct-B)=GRANTED
  stale membership (nil,nil)   scope=[*]         unrestricted=true  reqAcctAccess(acct-B)=GRANTED

Both variants match baseline. The 500-on-every-scoped-endpoint regression is gone.

2. The widening is still refused ✅

=== survivor contributes NOTHING; restricting group carries [acct-A] ===
  baseline (both resolve)               scope=[acct-A]  reqAcctAccess(acct-B)=REFUSED  revokeOwn(acct-B)=REFUSED
  PARTIAL restricting group ErrNoRows   scope=REFUSED   reqAcctAccess(acct-B)=REFUSED  revokeOwn(acct-B)=REFUSED
  PARTIAL restricting group (nil,nil)   scope=REFUSED   reqAcctAccess(acct-B)=REFUSED  revokeOwn(acct-B)=REFUSED

Refused at the resolver and at both enforcement seams. The predicate change did not undo the original fix.

3. M-A / M-B independence — confirmed ✅

Baseline: 9 top-level tests, 9 PASS.

Mutation Kills
M-A predicate reverted to IsUnrestrictedAccess(...) 1/9 — WildcardSurvivorToleratesSkippedGroup only
M-B partial guard removed entirely 1/9 — PartialResolutionThatWidensIsRefused only

Neither is a superset of the other. Both directions are independently guarded, and neither test is doing the other's work.

4. Nothing else regressed ✅

The two predicates differ on exactly one input class: a union that is non-empty and contains "*". Everywhere else len(U) == 0 and IsUnrestrictedAccess(U) agree, so the change is provably confined to that class. Every row of the shape table re-measured:

shape expected measured
U empty, skips > 0 (the widening) refuse REFUSED ✅
U empty, skips = 0 (legacy default) allow, unrestricted scope=[], unrestricted=true, GRANTED ✅
U non-empty no *, skips > 0 (narrower) allow scope=[acct-A], restricted, acct-B REFUSED ✅
U contains *, skips > 0 allow (was the bug) scope=[*], GRANTED ✅

Two extra shapes for the changed class:

  ['*'] survivor, no skips                 scope=[*]        unrestricted=true   reqAcctAccess(acct-B)=GRANTED
  WILDCARD group lost, [acct-A] survives   scope=[acct-A]   unrestricted=false  reqAcctAccess(acct-B)=REFUSED

Losing the wildcard group correctly narrows rather than widening — the guard does not fire, and the survivor's restriction is enforced.

One observation, not a regression: in the wildcard rows revokeOwn(acct-B) is REFUSED even though the principal is unrestricted. That is the pre-existing stringInSlice divergence at handler_purchases_revoke.go:398 I raised as F4 in my first review — it honours neither "*" nor account-name entries, unlike MatchesAccount. The baseline row shows the same, so this PR changes nothing about it. Still one for the follow-up.

5. The renamed test still bites ✅

Mutation M-C (len(authCtx.Groups) > 1 || added, refusing any multi-entry membership) kills 3/9, including DuplicateMembershipIsAllowed — plus MultiGroupBaselineStaysRestricted and WildcardSurvivorBaselineIsAlreadyUnrestricted. So the rename is honest: it now guards over-blocking, which it does catch, rather than a trap it never could.

The finding-3 corrections are accurate

The revised text now says len(User.GroupIDs) - len(Groups) would give the same answer, because Groups is appended once per ID with no dedup, and justifies counting at the skip site as stating intent rather than inferring it. That matches what I measured (agree=true on all four shapes, and M2 previously leaving all tests green). The non-existent trap is no longer recorded as fact in either service_group.go or the AuthContext.SkippedGroups doc.

Gates at 07e88eebd

Gate Result
go build ./... exit 0
go vet ./... exit 0
gocyclo -over 10 -ignore "_test\.go" . exit 0, 0 lines; collectGroupsAndAccounts 7, ResolveAllowedAccounts 5
golangci-lint v2.10.1 (CI-pinned, re-run at this head) exit 0 and 0 issues. ✅
per-module go test -race -short × 6 running — follow-up comment

I re-ran lint at this head rather than carrying the earlier result forward; the two behaviour misspellings are gone and nothing replaced them.

One thing to know before merging: the branch is behind main

git rev-list --left-right --count origin/main...07e88eebd → 2 behind, 4 ahead. #1755 landed on main and rewrote the Unit Tests job to loop go test -race -short ./... over all six workspace modules (., pkg, providers/{aws,azure,gcp}, tests/e2e), with tests/e2e type-checked under -tags=e2e instead. This branch still carries the pre-#1755 ci.yml, but pull_request workflows run from the merge ref, so the new contract applies to this PR.

Two consequences worth flagging:

  • The Lint job was not changed by ci: run unit and integration tests in every workspace module #1755 — it still runs golangci-lint once from the root, so only the root module is linted. My lint reproduction above is therefore the right one. (Relevant because providers/* and pkg/* contain 50 behaviour occurrences that stay invisible to it.)
  • The five previously-ungated modules now gate this merge for the first time. Any failure there would be inherited from main, not caused by this PR — but it would still redden this PR. I am running the same six-module loop locally and will report it.

@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Merging. Closes #1748 — a live cross-account read bypass on main.

The original bug: getAllowedAccounts returns a list where empty means unrestricted (the docstring says so), and three producers could emit empty without erroring — a nil auth component, a group that fails to load, and a store path returning (nil, nil). A scoped user whose groups failed to load got read access to every cloud account, silently, on ordinary triggers: a group deleted mid-session, a lagging replica.

Two defects were found in the fix itself, both by independent review, both fixed here.

1. The first fix closed the less reachable half. Its premise — partial resolution under-reports scope, making the ceiling stricter, so only total failure needed closing — is false when the scope is a union whose empty value means everything. An actor holding two groups, one carrying the permission with no allowed_accounts and one carrying the restriction, who loses only the second, still has one group resolving:

baseline both resolve        scope=[acct-A]  unrestricted=false
PARTIAL ErrNoRows            scope=[]        unrestricted=TRUE
PARTIAL nil,nil              scope=[]        unrestricted=TRUE

And the open half was the more reachable one: under total failure the actor also loses every permission, so requirePermission denies them before the scope guard is ever consulted.

The reviewer who found this had examined that exact configuration earlier and dismissed it, by comparing what one group grants in isolation against what the configured combination enforces. The union of [] and ["acct-A"] is restricted. It reversed its own clean verdict and named the error precisely.

Worth recording why the original verification missed it: "verified across all six cases" meant six single-group cases. The bug requires two. A single-group configuration cannot exhibit it, which is exactly why the check felt thorough.

2. The second fix introduced a false refusal hitting every default deployment. The guard predicate was IsUnrestrictedAccess(...), true for an empty union or one containing "*". A principal whose surviving union contains "*" was already maximally wide — no loss can widen them, so refusing them is zero security benefit and pure availability cost. All seven seeded groups ship ARRAY[*], so deleting any group would have given its members 500s on every account-scoped endpoint until an admin edited each one. Predicate is now len(authCtx.AllowedAccounts) == 0.

Both directions are independently guarded, confirmed by mutation rather than asserted:

M-A  predicate reverted to IsUnrestrictedAccess  -> kills ONLY WildcardSurvivorToleratesSkippedGroup
M-B  partial guard removed entirely              -> kills ONLY PartialResolutionThatWidensIsRefused

Neither test is a superset of the other. The full shape table was re-verified on every row, including the legacy U-empty-no-skips default (must stay unrestricted) and U-non-empty-with-skips (narrower, must be allowed).

A false-invariant test was inverted, not supplemented. PartialResolutionIsAllowedAndNarrower asserted something untrue and passed with the bug present; it is gone, and the replacement kills the old guard.

One constraint given to the implementer turned out not to exist, and is recorded because it shaped the code: a warning that counting skips via len(Groups) vs len(GroupIDs) would misfire on duplicate IDs. collectGroupsAndAccounts appends once per ID with no dedup, so the counts stay aligned and the two forms are equivalent. The counted-skip implementation was kept for explicitness, but the test written to guard the imaginary trap could not fail under the implementation it claimed to exclude — renamed to DuplicateMembershipIsAllowed, which is what it actually guards (mutation M-C, refusing any multi-entry membership, kills it).

Not a regression, flagged for follow-up: handler_purchases_revoke.go:398 uses stringInSlice and honours neither "*" nor account-name entries, unlike MatchesAccount. Present at baseline; unchanged by this PR.

Gates at 07e88eebd: build, vet, gocyclo 0 findings, go test ./... exit 0, golangci-lint v2.10.1 exit 0 with a genuine 0 issues. line — asserted on both halves, since v2 exits 3 with zero findings for a removed flag or a concurrent-run lock, and both read as clean.

Six-module CI confirmed applied: #1755 merged at 05:05:12Z, this PRs checks ran at 05:20:58Z from the merge ref, and the per-module markers are present in the job log. So the five previously-ungated modules gated this merge.

#1764 (representing account scope as a type, where the compiler found a 19th call site no scan could reach) is stacked on this and follows.

@cristim
cristim merged commit 1cd8c6f into main Aug 8, 2026
20 checks passed
cristim added a commit that referenced this pull request Aug 8, 2026
…iling

grantCeilingAccounts closed only TOTAL resolution failure, on the premise that
partial resolution under-reports the actor's scope and is therefore stricter.
That premise is false for AllowedAccounts, which is a UNION in which the empty
set means EVERYTHING: dropping a contributing group does not narrow it. The
union of [] and ["acct-A"] is restricted; lose the group carrying ["acct-A"]
and it collapses to [], and the actor may then widen any group to ["*"].

Verified by execution against the write path:

  baseline (both groups resolve)        REFUSED widening to [*]
  PARTIAL (scoping group ErrNoRows)     ACCEPTED widening to [*]

The configuration needs TWO groups -- one granting update:groups with no
allowed_accounts, one carrying the restriction -- which is why the earlier
six-case single-group verification could not find it.

The guard is now: refuse when the union is empty AND at least one group was
skipped. The emptiness test is len(AllowedAccounts) == 0, deliberately NOT
IsUnrestrictedAccess: a union containing "*" was already maximally wide at
baseline, so no loss can widen it, and refusing it would be zero security
benefit and pure availability cost. All seven seeded groups ship
allowed_accounts = ARRAY['*'], so the broader predicate would have refused
every seeded-group member with one stale membership.

Both directions are mutation-verified and each is guarded by exactly one test:
widening the predicate kills only WildcardActorToleratesSkippedGroup; removing
the guard kills only PartialActorResolutionThatWidensIsRefused. Neither would
catch the other's defect.

The total-failure case is the degenerate partial one -- every group skipped --
so it is now caught by the skipped-group guard and carries that message. The
existing test asserted the other guard's exact wording; its assertion is
relaxed to the sentinel plus a substring true of both.

NOTE ON OVERLAP: AuthContext.SkippedGroups and the counting in
collectGroupsAndAccounts are also added by PR #1752 for the read path. The two
changes are identical; whichever merges second should see no divergence.

Refs #1550, #1748.
@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Closing the one gate that was still running: six-module suite is green

Mirrored origin/main's post-#1755 Unit Tests job locally against 07e88eebd (clean tree, probes removed): go test -race -short ./... per module, tests/e2e type-checked under -tags=e2e, loop guarded so a failing module cannot abort the rest.

MODULE_OK=.
MODULE_OK=pkg
MODULE_OK=providers/aws
MODULE_OK=providers/azure
MODULE_OK=providers/gcp
MODULE_OK=tests/e2e
ALL_MODULES_EXIT=0

All six pass, including the five that had never gated a merge before #1755. Nothing inherited from main reddens this change.

That completes the gate set at 07e88eebd: build 0, vet 0, gocyclo 0 with zero findings, golangci-lint v2.10.1 exit 0 with 0 issues., and the six-module race suite exit 0. Items 1-5 from my previous comment all held.

cristim added a commit that referenced this pull request Aug 8, 2026
…iling

grantCeilingAccounts closed only TOTAL resolution failure, on the premise that
partial resolution under-reports the actor's scope and is therefore stricter.
That premise is false for AllowedAccounts, which is a UNION in which the empty
set means EVERYTHING: dropping a contributing group does not narrow it. The
union of [] and ["acct-A"] is restricted; lose the group carrying ["acct-A"]
and it collapses to [], and the actor may then widen any group to ["*"].

Verified by execution against the write path:

  baseline (both groups resolve)        REFUSED widening to [*]
  PARTIAL (scoping group ErrNoRows)     ACCEPTED widening to [*]

The configuration needs TWO groups -- one granting update:groups with no
allowed_accounts, one carrying the restriction -- which is why the earlier
six-case single-group verification could not find it.

The guard is now: refuse when the union is empty AND at least one group was
skipped. The emptiness test is len(AllowedAccounts) == 0, deliberately NOT
IsUnrestrictedAccess: a union containing "*" was already maximally wide at
baseline, so no loss can widen it, and refusing it would be zero security
benefit and pure availability cost. All seven seeded groups ship
allowed_accounts = ARRAY['*'], so the broader predicate would have refused
every seeded-group member with one stale membership.

Both directions are mutation-verified and each is guarded by exactly one test:
widening the predicate kills only WildcardActorToleratesSkippedGroup; removing
the guard kills only PartialActorResolutionThatWidensIsRefused. Neither would
catch the other's defect.

The total-failure case is the degenerate partial one -- every group skipped --
so it is now caught by the skipped-group guard and carries that message. The
existing test asserted the other guard's exact wording; its assertion is
relaxed to the sentinel plus a substring true of both.

NOTE ON OVERLAP: AuthContext.SkippedGroups and the counting in
collectGroupsAndAccounts are also added by PR #1752 for the read path. The two
changes are identical; whichever merges second should see no divergence.

Refs #1550, #1748.
cristim added a commit that referenced this pull request Aug 8, 2026
…writes (#1737)

* sec(auth): enforce a grant ceiling and system-managed guard on group writes

CreateGroupAPI / UpdateGroupAPI wrote the client-supplied permission list
onto a group verbatim, with no check that the caller may grant what they
are granting and no consultation of the system_managed column. Because
update:groups is not one of the pairs carved out of the admin:* wildcard,
any admin could void the #923 money separation-of-duties control
tenant-wide in a single request:

  PUT /api/groups/<administrators>
  {"permissions":[{admin,*},{execute,purchases},{approve-any,purchases},
                  {retry-any,purchases}]}

Two rules now gate every group-permission write:

1. Ceiling: a caller may only grant permissions their own effective set
   already holds, matched through the same carve-out-aware logic used at
   enforcement time, and at constraints no broader than their own (a
   holder capped at $100 cannot hand out an uncapped grant).

2. Non-grantable: the three money verbs in adminCarvedOuts may never be
   ADDED to a group, whoever the caller is. This is load-bearing rather
   than belt-and-braces: migrations 000059/000064 backfill every
   Administrators member into the Purchaser group, so a default-deployment
   admin explicitly holds those verbs and rule 1 alone would let them
   relay the verbs onto the Administrators group. A carved-out permission
   already stored on the target group may be carried through an unrelated
   edit, but not widened, so a rename is not forced to strip it.

Refusals fail closed and name the offending permission; the list is never
silently narrowed to the allowed subset, which is the corruption mode
#1629 reports on the frontend side. An unidentified actor, or any error
resolving the actor's permissions, refuses the write.

The stateless admin API key has no user row, so the ceiling measures it
against a bare {admin, *} holding. It can still seed ordinary groups but
is now subject to the same money carve-out as a human admin, closing the
third vector in the report.

system_managed is enforced on update and on delete. Delete is the third
write path to a group's permissions and was named in neither issue:
dropping the seeded Purchaser group destroys the only holder of the
carved-out verbs, which nothing can then re-grant, so the purchase path
would be dead tenant-wide.

CreateGroupAPI / UpdateGroupAPI now take the acting principal, matching
UpdateUserAPI's existing shape. updateGroup previously discarded its
session.

Refs #1550, #1629.

* sec(auth): reject blank action or resource in a group permission write

A blank resource is not a request for the "*" wildcard, but that is what it
became. The group-edit form picks its option with
`isDefault = !currentValue && resource === '*'`, so an empty stored resource
renders as the selected "All (*)" entry and saves back as view:*. Nothing
validated the list on the way in, so the same widening was reachable from
any API client with no form involved.

The grant ceiling added in the previous commit does NOT close this. Its
admin:* branch grants any (action, resource) pair that is not carved out,
and ("view", "") is not carved out, so an admin wrote a blank resource
straight through. Verified by execution before adding the guard: the write
returned nil and reached the store.

validateRequestedPermissions runs ahead of the ceiling on both write paths
and refuses blank (empty or whitespace-only) actions and resources, naming
the offending entry index. It fails before the actor lookup, so the refusal
does not depend on who is asking.

The two fields fail differently in the form, which is what shows the defect
is in the defaulting rather than the parsing: a blank action is silently
DROPPED (its index 0 is an empty placeholder) while a blank resource is
silently WIDENED (its index 0 is the wildcard). Only the resource side
escalates; both are refused, because a silently dropped permission is a
different bug rather than an acceptable one.

Unknown-but-non-blank values are deliberately still accepted: vocabulary
validation is a separate concern and rejecting values this endpoint can
already have stored would break edits of existing groups. An explicit "*"
stays a legitimate value, gated by the ceiling rather than by this check.

Mapped to 400, not 403: malformed input rather than an authorization
failure.

Refs #1730, #1550, #1629.

* sec(auth): block self-granting carved-out money verbs via group membership

#1550's report names a one-request alternative that needs no group edit at
all: PUT /api/users/{self} adding DefaultPurchaserGroupID. The #907
self-escalation guard gates self-added groups on update:users, which admin:*
grants, so it passed. An admin could join the Purchaser group and pick up
execute / approve-any / retry-any on purchases in a single request, voiding
the #923 separation of duties exactly as writing those verbs onto their own
group would.

Closing only the group-permission write path would have left this open while
the issue auto-closed over it.

guardSelfCarvedOutGrant applies the grant ceiling's own rule to membership:
you cannot grant yourself a carved-out verb you do not already hold. It keys
off the permission rather than off DefaultPurchaserGroupID, so a custom group
carrying a money verb is blocked identically.

Deliberately still allowed, each with a negative-control test: adding a second
group carrying a verb already held (not an escalation); an admin adding
ANOTHER user to Purchaser (the two-person control separation of duties exists
to create); and trusted internal callers with an empty actor, so bootstrap and
seeding paths are unaffected.

Also fixes a latent fragility this surfaced. The guard resolved the actor's
permissions by re-reading their row, but applyUpdateUserRequest has already
mutated the in-memory user by that point; it gave the right answer only
because the write had not been committed yet. A pre-existing test had to hand
back a distinct unmutated copy on the second read to avoid aliasing the
just-mutated object. guardSelfEscalation now resolves permissions from the
prior membership snapshot, which is the question a self-escalation guard has
to ask anyway, so the second read and the test's workaround are both gone.

GetUserPermissions delegates to the new permissionsForGroups helper rather
than duplicating the group-walk loop.

Refs #1550, #923, #907.

* docs(auth): record why guardSelfEscalation reads the prior membership

The shorter spelling of this guard is to re-read the actor's row, and that
spelling is a silent regression: UpdateUser calls applyUpdateUserRequest
before the guards run, so the in-memory user already carries the new
membership and a re-read returns the old values only because the write has
not been committed yet. A future caller that passes the mutated user makes
the guard authorize the escalation it exists to block.

Record that on the function so the next reader does not "simplify" it back,
and point at the mutation that enforces it: swapping prior for next fails
the suite.

Refs #1550.

* sec(auth): bound AllowedAccounts on group writes, the fifth write path

An allowed_accounts-only PUT never reached the ceiling at all.
checkGrantCeiling opens with `if len(requested) == 0 { return nil }`, and
APIUpdateGroupRequest's "empty means not sent" contract makes an
accounts-only request the natural shape to send. Verified by execution with
an actor holding only update:groups in one group scoped to one account:
widening to more accounts, to [] and to ["*"] were all ACCEPTED, while the
control -- widening Permissions[].Constraints.AccountIDs on the same call --
was correctly refused. One account dimension was bounded carefully and its
sibling left open on the same request.

checkAccountCeiling is deliberately a separate call rather than a branch
inside checkGrantCeiling, so it cannot inherit that early return. A write is
in ceiling if it does not widen the group's existing scope, or if it stays
inside the acting principal's own.

Two traps are handled explicitly. Empty and "*" both mean UNRESTRICTED, so
this cannot be a subset test: the empty set is a subset of everything and
means the opposite of narrow. And on create there is no prior scope, where a
nil existing would read as unrestricted and swallow every check -- so
CreateGroupAPI calls checkAccountGrant directly, and an omitted
allowed_accounts is checked too, because it produces an unrestricted group.

Every refusal test sends allowed_accounts with no permissions key. A test
that included permissions would pass with the bug present, since the
permission ceiling would then run and refuse for an unrelated reason.

Also strengthens three mutation kills that rested on a missing stub rather
than on an assertion. M5, M7 and TestUpdateUser_SelfEscalationDenied panicked
on an unstubbed READ downstream of the guard, a kill that disappears the
moment someone adds a permissive stub while tidying fixtures. Those now
register the downstream reads with .Maybe() so removing the guard cannot
panic, leaving the test's own assertions as the only thing that can fail it;
M7 now dies on require.Error. FailsClosedOnGroupLoadError asserts
errors.Is(err, loadErr) rather than message text.

Splits group_ceiling_test.go, which exceeded the repo's 500-line guideline,
by concern rather than by line count: fixtures, the permission ceiling,
blank-field validation, and system_managed. Verified no test was lost -- 707
passing test names before and after, sorted and identical.

Closes #1738.
Refs #1550, #1629, #1730.

* sec(auth): fail closed when the actor's account scope cannot be resolved

grantCeilingAccounts read the acting principal's scope from BuildAuthContext
and returned it unconditionally. collectGroupsAndAccounts skips a missing or
deleted group silently -- pgx.ErrNoRows, or a store returning (nil, nil) --
so an actor whose groups all failed to load produced an EMPTY account list,
which IsUnrestrictedAccess reads as "all accounts". The account ceiling
became a no-op on exactly the path it guards.

Verified by execution across the four resolution-failure modes. A store error
on the user and a missing user row both already failed closed; the two
group-resolution failures did not:

  store error on user                  -> refused
  user missing (nil, nil)              -> refused
  actor's only group missing (ErrNoRows) -> ACCEPTED widening to ["*"]
  actor's only group returns (nil, nil)  -> ACCEPTED widening to ["*"]

Note the asymmetry this closes: the PERMISSION ceiling already failed closed
on the same input, because an empty permission set grants nothing. Only the
account ceiling failed open, which is the harder direction to notice because
nothing errors.

Requiring at least one resolved group is the precise guard. Partial
resolution still under-reports the actor's scope, which makes the ceiling
stricter rather than looser, so only total failure needed closing.

Refs #1550, #1738.

* sec(auth): refuse a partial actor resolution that widens the grant ceiling

grantCeilingAccounts closed only TOTAL resolution failure, on the premise that
partial resolution under-reports the actor's scope and is therefore stricter.
That premise is false for AllowedAccounts, which is a UNION in which the empty
set means EVERYTHING: dropping a contributing group does not narrow it. The
union of [] and ["acct-A"] is restricted; lose the group carrying ["acct-A"]
and it collapses to [], and the actor may then widen any group to ["*"].

Verified by execution against the write path:

  baseline (both groups resolve)        REFUSED widening to [*]
  PARTIAL (scoping group ErrNoRows)     ACCEPTED widening to [*]

The configuration needs TWO groups -- one granting update:groups with no
allowed_accounts, one carrying the restriction -- which is why the earlier
six-case single-group verification could not find it.

The guard is now: refuse when the union is empty AND at least one group was
skipped. The emptiness test is len(AllowedAccounts) == 0, deliberately NOT
IsUnrestrictedAccess: a union containing "*" was already maximally wide at
baseline, so no loss can widen it, and refusing it would be zero security
benefit and pure availability cost. All seven seeded groups ship
allowed_accounts = ARRAY['*'], so the broader predicate would have refused
every seeded-group member with one stale membership.

Both directions are mutation-verified and each is guarded by exactly one test:
widening the predicate kills only WildcardActorToleratesSkippedGroup; removing
the guard kills only PartialActorResolutionThatWidensIsRefused. Neither would
catch the other's defect.

The total-failure case is the degenerate partial one -- every group skipped --
so it is now caught by the skipped-group guard and carries that message. The
existing test asserted the other guard's exact wording; its assertion is
relaxed to the sentinel plus a substring true of both.

NOTE ON OVERLAP: AuthContext.SkippedGroups and the counting in
collectGroupsAndAccounts are also added by PR #1752 for the read path. The two
changes are identical; whichever merges second should see no divergence.

Refs #1550, #1748.

* fix(auth): make ceiling comparisons exact, and land the third .Maybe() fix

F2 -- two comparison helpers were MORE permissive than the enforcement their
comments claimed to mirror.

accountScopeGap documented "comparison is exact, matching MatchesAccount" but
trimmed both sides. MatchesAccount is `a == accountID`: no trim, no fold. An
actor whose stored scope is " acct-A" matches nothing at enforcement, yet the
ceiling treated them as holding "acct-A" and let them grant it.

listCovers documented values as "trimmed and lower-cased, matching
matchAllRegionsConstraint" -- true for Regions only. AccountIDs, Providers and
Services are enforced by containsAny, an exact case-sensitive lookup, so
normalising made the ceiling looser there: a holder constrained to ["ACCT-1"]
could grant ["acct-1"], a value they cannot themselves use.

Both are now exact everywhere. Exact is stricter than enforcement on Regions
rather than looser, so it fails closed. The rule now stated in both comments:
a ceiling may exceed enforcement in strictness, never fall short. This leaves
normalizeConstraintValue with no callers, so it is removed.

F3 -- the third .Maybe() strengthening had landed in the wrong test.

The stubs went to TestGroupOnlyAuthz_NonAdminDenied, which calls only
HasPermission and UserHasAdminCapability: it never writes, never adds a group,
never touches DefaultAdminGroupID, and has no AssertNotCalled for the comment
to refer to. Both stubs were dead, and TestUpdateUser_SelfEscalationDenied --
the test that needed them -- was still killed by a panic on an unstubbed
UpdateUser, the exact weakness the change claimed to remove.

Cause: the anchor `On("GetGroup", ctx, viewerGroup().ID)` appears in three
tests in that file, and a first-match replace put the stubs in the first one.
The same first-match error recurred on the first attempt to fix it; the stubs
are now placed by locating the target function's body explicitly.

Verified by mutation rather than by the test passing: with the
self-escalation guard removed, TestUpdateUser_SelfEscalationDenied is now
killed by an assertion (killed-by-PANIC false, killed-by-ASSERTION true),
where before the move it was killed by a panic.

Refs #1550, #1748.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p1 Next up; this sprint severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(auth): account scoping fails OPEN when a user's groups cannot be resolved (unrestricted access to all accounts)

1 participant