fix: reject empty owner in owner-scoped cascade revocation (closes #275) - #277
Conversation
RevokeAllActiveForOwner accepted an empty owner_user_id and passed it
straight to migration 041's seed predicate, which is a plain equality:
WHERE i.owner_user_id = p_owner_user_id
AND i.account_id = p_account_id
identities.owner_user_id is VARCHAR(255) NOT NULL, so an ownerless
identity stores '' rather than NULL -- and ownerless is a documented
posture for `discovered` identities, not an anomaly. A blank owner
therefore matched EVERY ownerless identity in the account and
cascade-revoked each one's whole delegation subtree.
The realistic trigger is the intended caller: an IdP offboarding webhook
whose user-id field arrives blank turns "revoke one departing human's
agents" into a tenant-wide outage of exactly the workloads nobody
watches. Revoked credentials cannot be un-revoked, so this fails closed.
Reproduced against a real postgres with migration 042 removed -- a
blank-owner call returned no error and revoked 3 credentials: both
ownerless identities and a delegated child.
Guards at both layers, because they protect different callers:
- internal/service/credential.go rejects empty owner_user_id or
account_id before reaching the repo.
- migration 042 mirrors it with RAISE EXCEPTION so the guarantee
survives a caller that bypasses the service (a psql session, another
service, a repo method added later). 042 CREATE OR REPLACEs the
function; 041 is untouched since it is already merged.
RAISE rather than returning zero rows: a blank owner is a caller bug,
and silently reporting "revoked 0" would let a broken offboarding
integration look healthy indefinitely.
Also gives migration 041 its first test coverage -- a recursive CTE with
CYCLE detection, a depth cap and a data-modifying CTE with RETURNING
that previously had none:
- empty owner rejected, ownerless credentials survive (the #275 case,
verified to fail without 042)
- empty account rejected
- happy path: owner's credential + delegated child + grandchild all
revoked exactly once; another owner and an ownerless identity in the
same account untouched
Service-level guard tests use a nil repo deliberately, so a panic proves
the guard failed to short-circuit. Both suites mutation-checked.
Closes #275
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-audit: does this change introduce the same class of bug, and does the pattern recur?This change
Does the pattern recur elsewhere?Swept every SQL function and every bulk destructive repo operation. The shape that matters is "a legitimately-empty string is the primary breadth selector".
That is fail-closed, but by accident of upstream validation rather than by a local guard — precisely the property #275 criticised about the identity-scoped sibling ("protected only by accident"). Not a bug today; worth a symmetric guard if anyone touches that path. I've deliberately not bundled it here, since it isn't currently exploitable and this PR is a targeted fix.
Conclusion
|
Closes #275.
The bug
RevokeAllActiveForOwneraccepted an emptyowner_user_idand passed it straight into migration 041's seed predicate, which is a plain equality:identities.owner_user_idisVARCHAR(255) NOT NULL(001_init_schema), so an ownerless identity stores''— not NULL. And ownerless is a documented posture fordiscoveredidentities, not an anomaly:identity.gocalls ownership "OPTIONAL — ownerless" for them, andpostgres/identity.gohas an explicit(owner_user_id IS NULL OR owner_user_id = '')facet built on exactly that value.So a blank owner matched every ownerless identity in the account and cascade-revoked each one's entire delegation subtree.
The realistic trigger is the intended caller: an IdP offboarding webhook whose user-id field arrives blank turns "revoke one departing human's agents" into a tenant-wide outage of precisely the workloads nobody is watching. Revoked credentials cannot be un-revoked.
Reproduced against a real database
With migration 042 removed, seeding two ownerless identities plus a delegated child and calling with a blank owner:
No error, three credentials gone — including the delegated child, so the cascade reached past the seed rows. With 042 the function raises and nothing is touched.
The fix — guards at both layers
They protect different callers, and the failure mode is unrecoverable, so both earn their place:
internal/service/credential.gorejects an emptyowner_user_idoraccount_idbefore reaching the repo.RAISE EXCEPTIONso the guarantee survives a caller that bypasses the service — a psql session, another service, or a repo method added later.RAISErather than returning zero rows on purpose: a blank owner is a caller bug, and silently reporting "revoked 0 credentials" would let a broken offboarding integration look healthy indefinitely.041 is untouched — it's already merged, so 042
CREATE OR REPLACEs the function, mirroring how 031's down restores the 029 bodies. The down migration restores 041's body and carries an explicit warning that rolling back reintroduces the hole.Everything else in the function — the recursive walk, CYCLE guard, depth cap 50, liveness filters on the final UPDATE only, and the RETURNING projection — is carried over verbatim. Signature unchanged, so no caller changes.
Tests — migration 041's first coverage
041 shipped with none, which is the second half of #275. It's the most intricate SQL in the repo (recursive CTE + CYCLE detection + depth cap + data-modifying CTE with RETURNING), and its first execution in anger would have been its first execution ever.
tests/integration/revoke_by_owner_test.go(real Postgres):internal/service/credential_revoke_owner_test.go: the service guard, built with a nil repo deliberately — a panic there proves the guard failed to short-circuit before the DB layer. Mutation-checked: removing the guard makes them fail.go vetclean,gofmtclean, unit suite green, full integration suite green (68s).Not in this PR
#275 also notes the primitive is unreachable — no caller, no
Servermethod, nohooks.gowrapper. I've deliberately left that out: the identity-scoped sibling isn't exported either (it's reached from internal deactivation flows), so exposing this one means inventing a new public API shape, and choosing the offboarding entry point — Server method? admin endpoint? webhook handler? — is a design decision rather than a bug fix.The issue's warning was that "wiring a caller without first adding the guard is the dangerous ordering". This PR establishes the guard, so whichever entry point is chosen can now be wired safely. Happy to follow up once we've picked the shape.
🤖 Generated with Claude Code