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> => {
|
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
// The tuple columns are immutable once written, so reading them before taking the lock
|
// This first read exists only to discover the equivalence tuple, which is safe to read
|
||||||
// is safe; whether the target is still unrevoked is re-decided under the lock by the
|
// before the lock because those columns are immutable once written. It deliberately does
|
||||||
// update predicate below.
|
// *not* settle whether the revocation may proceed: everything it observed can change
|
||||||
|
// while this transaction waits for the lock below.
|
||||||
const [target] = await tx
|
const [target] = await tx
|
||||||
.select({
|
.select({
|
||||||
permissionId: ekklesiaCoreGrant.permissionId,
|
permissionId: ekklesiaCoreGrant.permissionId,
|
||||||
@@ -410,6 +411,40 @@ export function createAuthorizationStore(
|
|||||||
|
|
||||||
await lockEquivalence(tx, target);
|
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
|
const rows = await tx
|
||||||
.update(ekklesiaCoreGrant)
|
.update(ekklesiaCoreGrant)
|
||||||
.set({
|
.set({
|
||||||
@@ -420,7 +455,9 @@ export function createAuthorizationStore(
|
|||||||
.where(and(equivalenceCondition(target), isNull(ekklesiaCoreGrant.revokedAt)))
|
.where(and(equivalenceCondition(target), isNull(ekklesiaCoreGrant.revokedAt)))
|
||||||
.returning();
|
.returning();
|
||||||
if (rows.length === 0) {
|
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");
|
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1085,3 +1085,200 @@ describe.sequential("live Core authorization store — bounded keyset pagination
|
|||||||
await safeEnd(admin);
|
await safeEnd(admin);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A revocation must never succeed by revoking a *replacement* grant it never addressed.
|
||||||
|
*
|
||||||
|
* `revokeGrant` discovers the addressed grant's equivalence tuple before taking that tuple's
|
||||||
|
* advisory lock, because the tuple columns are immutable once written. The all-equivalent update
|
||||||
|
* that follows matches on the tuple, not on the addressed id — so without an under-lock
|
||||||
|
* revalidation of the target itself, a revoker that paused between its target read and the lock
|
||||||
|
* could wake up after the target was revoked and a fresh equivalent created, and then revoke that
|
||||||
|
* replacement while reporting success. No serial order explains that outcome, and it contradicts
|
||||||
|
* the rule that an already-revoked target keeps not-found behavior.
|
||||||
|
*
|
||||||
|
* Reproducing it needs the interleaving forced, not raced: an ordinary `Promise.all` would depend
|
||||||
|
* on timing luck. The tuple advisory lock is cooperative, so a test-only connection can hold the
|
||||||
|
* exact key the store computes — using the identical SQL expression, not a re-derived constant —
|
||||||
|
* while ordinary `UPDATE`/`INSERT` statements proceed normally. That is the same test-only
|
||||||
|
* administrative provisioning this suite already uses to seed legacy duplicates and force
|
||||||
|
* expirations; no production test hook exists to pause the store.
|
||||||
|
*/
|
||||||
|
describe.sequential("live Core authorization store — stale revocation under the equivalence lock", () => {
|
||||||
|
/** Bounded, condition-based wait — never an arbitrary sleep. Returns once a backend is genuinely
|
||||||
|
* blocked on an advisory lock in this disposable database, which is the only way to know the
|
||||||
|
* stale revocation has passed its target read and reached the lock. */
|
||||||
|
async function awaitAdvisoryLockWait(client: Client): Promise<void> {
|
||||||
|
const deadline = Date.now() + 20_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const rows = await safeQuery<{ waiting: number }>(
|
||||||
|
client,
|
||||||
|
`select count(*)::int as waiting
|
||||||
|
from pg_locks l
|
||||||
|
join pg_stat_activity a on a.pid = l.pid
|
||||||
|
where l.locktype = 'advisory'
|
||||||
|
and not l.granted
|
||||||
|
and a.wait_event_type = 'Lock'
|
||||||
|
and a.wait_event = 'advisory'
|
||||||
|
and l.database = (select oid from pg_database where datname = current_database())`,
|
||||||
|
);
|
||||||
|
if (Number(rows[0]?.waiting ?? 0) > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
}
|
||||||
|
throw harnessError("The stale revocation never reached the advisory-lock wait.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An independent outer deadline, so a regression can never hang the suite. The timer is always
|
||||||
|
* cleared, including when the awaited promise rejects. */
|
||||||
|
async function withDeadline<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => reject(harnessError("The stale revocation did not settle before its deadline.")),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects a revocation whose addressed target was revoked and replaced while it waited for the tuple lock", async () => {
|
||||||
|
const database = await createDisposableDatabase("stale_revoke");
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
const admin = await openAdministrativeClient(database.url);
|
||||||
|
const lockHolder = await openAdministrativeClient(database.url);
|
||||||
|
const handle = createAuthorizationDrizzleDatabase(database.url);
|
||||||
|
let staleSettled: Promise<unknown> | undefined;
|
||||||
|
let lockHeld = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subject = parseEkklesiaUserId("stale_subject");
|
||||||
|
const manager = parseEkklesiaUserId("stale_manager");
|
||||||
|
await seedUser(admin, subject, "[email protected]");
|
||||||
|
await seedUser(admin, manager, "[email protected]");
|
||||||
|
const store = createAuthorizationStore(handle.db);
|
||||||
|
const actor = createUserActor(manager);
|
||||||
|
const permission = parsePermissionId("calendar.events.stale");
|
||||||
|
|
||||||
|
const firstGrant = await store.createGrant({
|
||||||
|
actor,
|
||||||
|
permission,
|
||||||
|
sourceKind: "explicit",
|
||||||
|
userId: subject,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hold the exact tuple key the store derives, computed with the identical SQL expression.
|
||||||
|
await safeQuery(lockHolder, "begin");
|
||||||
|
await safeQuery(
|
||||||
|
lockHolder,
|
||||||
|
`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
|
||||||
|
$1::text, $2::text, null::text, 'explicit'::text, null::text
|
||||||
|
)::text, 0))`,
|
||||||
|
[subject, permission],
|
||||||
|
);
|
||||||
|
lockHeld = true;
|
||||||
|
|
||||||
|
// The stale revoker reads its target successfully, then blocks on the held lock.
|
||||||
|
const stale = store.revokeGrant({ actor, grantId: firstGrant.id, sourceKind: "explicit" });
|
||||||
|
staleSettled = stale.then(
|
||||||
|
() => ({ status: "fulfilled" as const }),
|
||||||
|
(error: unknown) => ({ error, status: "rejected" as const }),
|
||||||
|
);
|
||||||
|
await awaitAdvisoryLockWait(admin);
|
||||||
|
|
||||||
|
// Committed while the stale revoker waits: the target is revoked and audited, then a fresh
|
||||||
|
// equivalent replacement is created — exactly what a concurrent revoker and creator produce.
|
||||||
|
await safeQuery(
|
||||||
|
admin,
|
||||||
|
`update "ekklesia_core_grant"
|
||||||
|
set revoked_at = now(), revoked_by_actor_type = 'user', revoked_by_actor_id = $2
|
||||||
|
where id = $1`,
|
||||||
|
[firstGrant.id, manager],
|
||||||
|
);
|
||||||
|
await safeQuery(
|
||||||
|
admin,
|
||||||
|
`insert into "ekklesia_core_authorization_audit"
|
||||||
|
(action, actor_type, actor_id, subject_user_id, permission_id, grant_id)
|
||||||
|
values ('grant.revoke', 'user', $1, $2, $3, $4)`,
|
||||||
|
[manager, subject, permission, firstGrant.id],
|
||||||
|
);
|
||||||
|
const replacementRows = await safeQuery<{ id: string }>(
|
||||||
|
admin,
|
||||||
|
`insert into "ekklesia_core_grant"
|
||||||
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
||||||
|
values ($1, $2, null, 'explicit', null, 'user', $3)
|
||||||
|
returning id`,
|
||||||
|
[subject, permission, manager],
|
||||||
|
);
|
||||||
|
const replacementId = replacementRows[0]?.id as string;
|
||||||
|
expect(replacementId).toBeDefined();
|
||||||
|
|
||||||
|
// Release the cooperative lock and let the stale revoker resume.
|
||||||
|
await safeQuery(lockHolder, "commit");
|
||||||
|
lockHeld = false;
|
||||||
|
|
||||||
|
const outcome = (await withDeadline(staleSettled, 30_000)) as {
|
||||||
|
error?: unknown;
|
||||||
|
status: "fulfilled" | "rejected";
|
||||||
|
};
|
||||||
|
|
||||||
|
// The addressed target no longer exists in the state it was addressed in.
|
||||||
|
expect(outcome.status).toBe("rejected");
|
||||||
|
expect(outcome.error).toBeInstanceOf(AuthorizationStoreError);
|
||||||
|
expect(outcome.error).toMatchObject({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||||
|
|
||||||
|
// The replacement it never addressed is untouched, and no audit row claims otherwise.
|
||||||
|
const replacement = await safeQuery<{ revoked_at: Date | null }>(
|
||||||
|
admin,
|
||||||
|
`select revoked_at from "ekklesia_core_grant" where id = $1`,
|
||||||
|
[replacementId],
|
||||||
|
);
|
||||||
|
expect(replacement[0]?.revoked_at).toBeNull();
|
||||||
|
expect(
|
||||||
|
await safeQuery(
|
||||||
|
admin,
|
||||||
|
`select id from "ekklesia_core_authorization_audit"
|
||||||
|
where action = 'grant.revoke' and grant_id = $1`,
|
||||||
|
[replacementId],
|
||||||
|
),
|
||||||
|
).toHaveLength(0);
|
||||||
|
|
||||||
|
// The end state is consistent with a serial order: revoke(G1), create(G2), reject(stale).
|
||||||
|
expect(await store.hasActiveGrant({ now: new Date(), permission, userId: subject })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const effective = await safeQuery<{ n: number }>(
|
||||||
|
admin,
|
||||||
|
`select count(*)::int as n from "ekklesia_core_grant"
|
||||||
|
where user_id = $1 and permission_id = $2 and revoked_at is null`,
|
||||||
|
[subject, permission],
|
||||||
|
);
|
||||||
|
expect(Number(effective[0]?.n)).toBe(1);
|
||||||
|
const revokeAudit = await safeQuery<{ grant_id: string }>(
|
||||||
|
admin,
|
||||||
|
`select grant_id from "ekklesia_core_authorization_audit" where action = 'grant.revoke'`,
|
||||||
|
);
|
||||||
|
expect(revokeAudit.map((row) => row.grant_id)).toEqual([firstGrant.id]);
|
||||||
|
} finally {
|
||||||
|
// The lock is released first, so a failed assertion above can never leave the stale
|
||||||
|
// revocation blocked forever; every client, pool, promise, and database is then cleaned up.
|
||||||
|
if (lockHeld) {
|
||||||
|
await safeQuery(lockHolder, "rollback").catch(() => undefined);
|
||||||
|
}
|
||||||
|
if (staleSettled) {
|
||||||
|
await withDeadline(staleSettled, 30_000).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await safeEnd(lockHolder).catch(() => undefined);
|
||||||
|
await safeEnd(admin).catch(() => undefined);
|
||||||
|
await handle.close().catch(() => undefined);
|
||||||
|
await dropDatabase(database.name).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user