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:
co-authored by
Claude Opus 5
parent
5c589d8fec
commit
4a34a8203c
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user