fix: revalidate grant revocation under lock

`revokeGrant` discovers the addressed grant's equivalence tuple before taking
that tuple's advisory lock, which is safe because those columns are immutable.
The all-equivalent update that follows then matches on the tuple rather than on
the addressed id — and nothing re-checked the addressed grant once the lock was
finally held.

That allowed a revocation to succeed by revoking a grant it never addressed. A
revoker reads its target and stalls before the lock; a second revoker takes the
lock, revokes that same target, audits it, commits; a creator takes the lock,
sees no effective equivalent, creates a replacement, commits; the first revoker
finally acquires the lock, finds its stale tuple still valid, and revokes the
replacement — returning success for an already-revoked target that should have
been not-found. No serial order produces those three results together.

Reproduced deterministically rather than by racing: a test-only connection holds
the exact tuple key, computed with the identical SQL expression the store uses,
while the stale revocation is proven — via `pg_locks`/`pg_stat_activity`, not
assumed — to be blocked on that lock; ordinary administrative statements then
commit the revoked target, its audit row, and the replacement, because the
advisory lock is cooperative. Before the fix the stale revocation fulfilled, the
replacement was revoked, and a `grant.revoke` audit row was written for it.

The correction re-reads the addressed grant after the lock is acquired,
re-asserting every condition the pre-lock read relied on: the exact id, still
unrevoked, still matching the caller's explicit/relationship source guard, and
still carrying the tuple the lock was taken for. A miss is
AUTHORIZATION_GRANT_NOT_FOUND. It is a plain read, not `for update`, so the
tuple advisory lock remains the single create/revoke serialization order and no
second lock ordering or deadlock path is introduced. The broad update stays
untouched, because narrowing it to the addressed id would have stopped clearing
genuine legacy duplicates — the behavior this pass exists to provide.

The comment claiming the target's unrevoked state was re-decided by the update
predicate described a sequence the code did not implement, and is corrected.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 22:32:26 -05:00
co-authored by Claude Opus 5
parent 5c589d8fec
commit 4a34a8203c
2 changed files with 238 additions and 4 deletions
+41 -4
View File
@@ -382,9 +382,10 @@ export function createAuthorizationStore(
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
try {
await db.transaction(async (tx) => {
// The tuple columns are immutable once written, so reading them before taking the lock
// is safe; whether the target is still unrevoked is re-decided under the lock by the
// update predicate below.
// This first read exists only to discover the equivalence tuple, which is safe to read
// before the lock because those columns are immutable once written. It deliberately does
// *not* settle whether the revocation may proceed: everything it observed can change
// while this transaction waits for the lock below.
const [target] = await tx
.select({
permissionId: ekklesiaCoreGrant.permissionId,
@@ -410,6 +411,40 @@ export function createAuthorizationStore(
await lockEquivalence(tx, target);
// Revalidate the *addressed* grant now that the lock is held. Without this, a revocation
// that paused between the read above and the lock could wake after its target was
// revoked and a fresh equivalent created, and the tuple-matching update below would
// silently revoke that replacement — reporting success for a grant the caller never
// addressed, in an order no serial execution could produce, and losing the not-found
// result an already-revoked target must return.
//
// The check is deliberately narrow and re-asserts every condition the pre-lock read
// relied on: the exact addressed id, still unrevoked, still satisfying the caller's
// explicit/relationship source guard, and still carrying the same equivalence tuple the
// lock was taken for. It is a plain read, not `for update`: the tuple advisory lock
// remains the one create/revoke serialization order, so no second lock ordering — and no
// deadlock path — is introduced.
const [addressed] = await tx
.select({ id: ekklesiaCoreGrant.id })
.from(ekklesiaCoreGrant)
.where(
and(
eq(ekklesiaCoreGrant.id, input.grantId),
isNull(ekklesiaCoreGrant.revokedAt),
...(input.sourceKind === undefined
? []
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
equivalenceCondition(target),
),
)
.limit(1);
if (!addressed) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
}
// Only now is the whole equivalence class cleared, which is what still removes genuine
// legacy duplicates — including expired-but-unrevoked ones. Narrowing this update to the
// addressed id instead would not have been a fix; it would have dropped that behavior.
const rows = await tx
.update(ekklesiaCoreGrant)
.set({
@@ -420,7 +455,9 @@ export function createAuthorizationStore(
.where(and(equivalenceCondition(target), isNull(ekklesiaCoreGrant.revokedAt)))
.returning();
if (rows.length === 0) {
// A concurrent revocation committed between the lookup and the lock.
// Unreachable while the lock is held, since the row confirmed above satisfies this
// same predicate — kept as a fail-closed guard against a writer that bypasses the
// lock, rather than silently auditing nothing.
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
}