fix: enforce explicit grant invariants
Explicit-grant management could produce states an operator could not reason about, and pushed request-shaped problems into the database. Two identical createGrant calls produced two grants; five concurrent identical calls produced five. Revoking one returned success while the equivalent kept authorizing the same user — an operator could believe they had removed access that was still live. Two issuances are now the same authorization source when user_id, permission_id, scope, source_kind, and source_ref are all equal, with NULL equal only to NULL; expiry, creation metadata, actor, and grant id do not distinguish them. Creating one while an equivalent is currently effective fails with AUTHORIZATION_GRANT_ALREADY_EXISTS (HTTP 409, no-store) naming nothing about the grant that already exists, and writing no grant and no audit row. A revoked or expired equivalent does not block a fresh issuance. Conflict rather than idempotent return: the caller asked to record a new authorization with their own actor and expiry, and answering "done" while recording neither would hide that nothing happened. Enforcement is database-backed, since nothing constrains the number of API processes: every create and revoke touching one equivalence tuple takes a transaction-scoped advisory lock keyed by a hash of that tuple, encoded as a JSON array so NULL stays distinct from the empty string and a crafted scope cannot forge a separator. A collision can only make unrelated tuples serialize, never hide a conflict, because the decision is always an exact SQL predicate. Its single-key lock space is verified disjoint from the two-key space the migration runner and bootstrap use. A time-dependent partial unique index was rejected: now() is not immutable and cannot be indexed. One revocation now clears every unrevoked equivalent — including expired-but- unrevoked legacy rows, so the history is left in one state — with one audit row per grant actually revoked, each carrying its own id, in one transaction. Differently scoped or sourced grants are untouched. Historical duplicates are not silently revoked by migration; the store prevents new effective equivalents and an operator's explicit revocation resolves the class. The request boundary no longer leaks into PostgreSQL. GrantId accepts exactly the canonical lowercase hyphenated UUID the database emits, with no trimming and no case folding, so a malformed path parameter is a 400 decided after authorization but before any store call instead of reaching the UUID comparison and returning an indistinguishable 503; a canonical unknown id still returns 404. expiresAt accepts exactly YYYY-MM-DDTHH:mm:ss.sssZ, byte-for-byte equal to the parsed date's own toISOString, and must be strictly future — bare new Date silently rewrote 2026-02-31 to March 3rd and accepted locale, date-only, offset, and millisecond-free forms, and a past expiry was stored as a grant dead on arrival. The store validates the same thing before any SQL so trusted internal callers get the guarantee too. The generic HTTP surface now rejects a scope field in every form, including null. No route middleware supplies a scope to the evaluator, so a scope accepted here produced a grant that persisted, listed, and authorized nothing. Core's scope semantics are unchanged for internal callers; no resolver was added. Listing was unbounded and returned a user's entire history. It is now keyset pagination over created_at DESC, id DESC with a strict boundary on both values, so rows sharing a timestamp are neither skipped nor repeated, at most limit + 1 rows read, explicit-source filtering server-side on every page, and an opaque unsigned position cursor that is strictly decoded and reveals nothing on rejection. Core's list method requires a validated finite bound with no unbounded fallback anywhere. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
532ce3803b
commit
5c589d8fec
+259
-31
@@ -28,7 +28,10 @@ import {
|
||||
} from "./schema.js";
|
||||
|
||||
export type AuthorizationStoreErrorCode =
|
||||
| "AUTHORIZATION_GRANT_ALREADY_EXISTS"
|
||||
| "AUTHORIZATION_GRANT_CREATE_FAILED"
|
||||
| "AUTHORIZATION_GRANT_EXPIRATION_INVALID"
|
||||
| "AUTHORIZATION_GRANT_LIMIT_INVALID"
|
||||
| "AUTHORIZATION_GRANT_LIST_FAILED"
|
||||
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
|
||||
| "AUTHORIZATION_GRANT_NOT_FOUND"
|
||||
@@ -38,7 +41,12 @@ export type AuthorizationStoreErrorCode =
|
||||
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
|
||||
|
||||
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
|
||||
AUTHORIZATION_GRANT_ALREADY_EXISTS:
|
||||
"An equivalent authorization grant is already effective for this user.",
|
||||
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
|
||||
AUTHORIZATION_GRANT_EXPIRATION_INVALID:
|
||||
"The authorization grant expiration must be a valid instant in the future.",
|
||||
AUTHORIZATION_GRANT_LIMIT_INVALID: "The authorization grant page size is invalid.",
|
||||
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
|
||||
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
|
||||
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
|
||||
@@ -49,6 +57,10 @@ const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>>
|
||||
"The platform-owner record was not found or is already revoked.",
|
||||
});
|
||||
|
||||
/** The largest page any caller may request from `listGrantsForUser`, enforced identically by the
|
||||
* store and by the HTTP surface so there is exactly one bound, not two that could drift. */
|
||||
export const AUTHORIZATION_GRANT_LIST_MAX_LIMIT = 100;
|
||||
|
||||
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
||||
export class AuthorizationStoreError extends Error {
|
||||
readonly code: AuthorizationStoreErrorCode;
|
||||
@@ -101,11 +113,38 @@ export interface RevokeGrantInput {
|
||||
readonly sourceKind?: GrantSourceKind;
|
||||
}
|
||||
|
||||
/** One row's position in the `created_at DESC, id DESC` ordering, used as a strict keyset
|
||||
* boundary. Both values are required: comparing on the timestamp alone would skip or repeat rows
|
||||
* whenever two grants share a `created_at`, which concurrent creation makes routine. */
|
||||
export interface GrantPageBoundary {
|
||||
readonly createdAt: Date;
|
||||
readonly id: GrantId;
|
||||
}
|
||||
|
||||
export interface ListGrantsForUserOptions {
|
||||
/** Required. The maximum number of rows to return, 1 through
|
||||
* `AUTHORIZATION_GRANT_LIST_MAX_LIMIT`. There is deliberately no default and no unbounded
|
||||
* fallback anywhere: an unbounded listing returned a user's entire authorization history in one
|
||||
* response, and a bound a caller may simply omit is not a bound. */
|
||||
readonly limit: number;
|
||||
/** When provided, only grants strictly *after* this position in the ordering are returned. */
|
||||
readonly after?: GrantPageBoundary;
|
||||
/** When provided, only grants with this exact `sourceKind` are returned. */
|
||||
readonly sourceKind?: GrantSourceKind;
|
||||
}
|
||||
|
||||
export interface GrantPage {
|
||||
readonly grants: readonly GrantRecord[];
|
||||
/** The last returned row's position when more rows exist, otherwise `undefined`. */
|
||||
readonly nextBoundary?: GrantPageBoundary;
|
||||
}
|
||||
|
||||
export interface CreateAuthorizationStoreOptions {
|
||||
/** Injected only so expiration validation is deterministic under test. Production composition
|
||||
* leaves this unset and the store reads real time. */
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
|
||||
* append-only audit log, all behind one explicit factory — no environment reads, no connection
|
||||
@@ -117,12 +156,9 @@ export interface ListGrantsForUserOptions {
|
||||
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
|
||||
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
|
||||
revokeGrant(input: RevokeGrantInput): Promise<void>;
|
||||
/** Every grant ever recorded for the user, active or revoked, newest first — the caller decides
|
||||
* what to display. Never includes another user's grants. */
|
||||
listGrantsForUser(
|
||||
userId: EkklesiaUserId,
|
||||
options?: ListGrantsForUserOptions,
|
||||
): Promise<readonly GrantRecord[]>;
|
||||
/** One bounded page of the user's grants, active or revoked, ordered `created_at DESC, id DESC`.
|
||||
* Never includes another user's grants. */
|
||||
listGrantsForUser(userId: EkklesiaUserId, options: ListGrantsForUserOptions): Promise<GrantPage>;
|
||||
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
}
|
||||
@@ -160,11 +196,141 @@ function toGrantRecord(row: GrantRow): GrantRecord {
|
||||
});
|
||||
}
|
||||
|
||||
export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): AuthorizationStore {
|
||||
/**
|
||||
* The five values that make two grant issuances the *same authorization source*: subject,
|
||||
* permission, scope, source kind, and source reference — with `NULL` equal only to `NULL`.
|
||||
* `expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
|
||||
* distinguish them: re-issuing the identical authority with a different expiry or by a different
|
||||
* administrator is the same grant, not a second one.
|
||||
*/
|
||||
interface GrantEquivalence {
|
||||
readonly userId: string;
|
||||
readonly permissionId: string;
|
||||
readonly scope: string | null;
|
||||
readonly sourceKind: string;
|
||||
readonly sourceRef: string | null;
|
||||
}
|
||||
|
||||
function equivalenceOf(input: CreateGrantInput): GrantEquivalence {
|
||||
return {
|
||||
permissionId: input.permission,
|
||||
scope: input.scope ?? null,
|
||||
sourceKind: input.sourceKind,
|
||||
sourceRef: input.sourceRef ?? null,
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes every create/revoke touching one equivalence tuple against a transaction-scoped
|
||||
* PostgreSQL advisory lock, so concurrent callers cannot both observe "no effective equivalent"
|
||||
* and both insert. A process-local mutex would not do this — nothing constrains the number of API
|
||||
* processes — and neither would an unlocked check-then-insert.
|
||||
*
|
||||
* The key is a 64-bit hash of the tuple encoded as a JSON array, which keeps `NULL` and the empty
|
||||
* string distinct and makes separator forgery through a crafted scope or source reference
|
||||
* impossible. A hash collision is harmless by construction: it can only make two unrelated tuples
|
||||
* serialize against each other, never make a real conflict go unnoticed, because the authoritative
|
||||
* decision is always the exact SQL predicate below and never the hash.
|
||||
*
|
||||
* `pg_advisory_xact_lock(bigint)` uses PostgreSQL's *single-key* advisory-lock space, which is
|
||||
* verified to be disjoint from the two-key space used by the migration runner
|
||||
* (`core/src/migrations/postgres-database.ts`) and by platform-owner bootstrap. Grant serialization
|
||||
* therefore can never block, or be blocked by, migration or bootstrap locking. Being
|
||||
* transaction-scoped, it is released on COMMIT or ROLLBACK with no manual unlock.
|
||||
*/
|
||||
async function lockEquivalence(
|
||||
tx: Parameters<Parameters<AuthorizationDrizzleDatabase["transaction"]>[0]>[0],
|
||||
equivalence: GrantEquivalence,
|
||||
): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
|
||||
${equivalence.userId}::text,
|
||||
${equivalence.permissionId}::text,
|
||||
${equivalence.scope}::text,
|
||||
${equivalence.sourceKind}::text,
|
||||
${equivalence.sourceRef}::text
|
||||
)::text, 0))`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches exactly the rows sharing an equivalence tuple. `is not distinct from` is what gives
|
||||
* `NULL = NULL` for the two nullable members; plain `=` would silently match nothing. */
|
||||
function equivalenceCondition(equivalence: GrantEquivalence) {
|
||||
return and(
|
||||
eq(ekklesiaCoreGrant.userId, equivalence.userId),
|
||||
eq(ekklesiaCoreGrant.permissionId, equivalence.permissionId),
|
||||
sql`${ekklesiaCoreGrant.scope} is not distinct from ${equivalence.scope}::text`,
|
||||
eq(ekklesiaCoreGrant.sourceKind, equivalence.sourceKind),
|
||||
sql`${ekklesiaCoreGrant.sourceRef} is not distinct from ${equivalence.sourceRef}::text`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A supplied expiration must be a real instant strictly later than the acceptance clock. A past
|
||||
* or equal-to-now expiration produces a grant that is dead the moment it is written, which is
|
||||
* never what a caller meant; rejecting it here means trusted internal callers get the same
|
||||
* guarantee the HTTP surface enforces, rather than each validating separately. */
|
||||
function assertValidExpiration(expiresAt: Date | undefined, now: Date): void {
|
||||
if (expiresAt === undefined) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(expiresAt instanceof Date) ||
|
||||
Number.isNaN(expiresAt.getTime()) ||
|
||||
expiresAt.getTime() <= now.getTime()
|
||||
) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_EXPIRATION_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidLimit(limit: number): number {
|
||||
if (
|
||||
typeof limit !== "number" ||
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT
|
||||
) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIMIT_INVALID");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
export function createAuthorizationStore(
|
||||
db: AuthorizationDrizzleDatabase,
|
||||
options: CreateAuthorizationStoreOptions = {},
|
||||
): AuthorizationStore {
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
|
||||
return Object.freeze({
|
||||
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
|
||||
// Validated before any transaction opens, so a rejected expiration performs no database work
|
||||
// at all and can never leave a grant or an audit row behind.
|
||||
assertValidExpiration(input.expiresAt, now());
|
||||
const equivalence = equivalenceOf(input);
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
await lockEquivalence(tx, equivalence);
|
||||
|
||||
// Under the lock, "currently effective" is authoritative: an equivalent that is revoked,
|
||||
// or whose expiry has already passed, does not block a fresh issuance.
|
||||
const effective = await tx
|
||||
.select({ id: ekklesiaCoreGrant.id })
|
||||
.from(ekklesiaCoreGrant)
|
||||
.where(
|
||||
and(
|
||||
equivalenceCondition(equivalence),
|
||||
isNull(ekklesiaCoreGrant.revokedAt),
|
||||
or(
|
||||
isNull(ekklesiaCoreGrant.expiresAt),
|
||||
gt(ekklesiaCoreGrant.expiresAt, sql`now()`),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (effective.length > 0) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS");
|
||||
}
|
||||
|
||||
const [row] = await tx
|
||||
.insert(ekklesiaCoreGrant)
|
||||
.values({
|
||||
@@ -202,16 +368,32 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Revoking one grant revokes *every* currently unrevoked issuance sharing its equivalence
|
||||
* tuple, in one transaction, with one `grant.revoke` audit row per grant actually changed.
|
||||
*
|
||||
* Addressing a single id and revoking only that row was actively misleading: an operator who
|
||||
* revoked a grant received `success` while an equivalent duplicate kept authorizing the same
|
||||
* user. Expired-but-unrevoked equivalents are included deliberately — they are already
|
||||
* ineffective, but leaving them unrevoked would keep the history in two states for what is one
|
||||
* authorization source. Grants with a different subject, permission, scope, source kind, or
|
||||
* source reference are untouched.
|
||||
*/
|
||||
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(ekklesiaCoreGrant)
|
||||
.set({
|
||||
revokedAt: sql`now()`,
|
||||
revokedByActorId: actorId(input.actor),
|
||||
revokedByActorType: input.actor.type,
|
||||
// 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.
|
||||
const [target] = await tx
|
||||
.select({
|
||||
permissionId: ekklesiaCoreGrant.permissionId,
|
||||
scope: ekklesiaCoreGrant.scope,
|
||||
sourceKind: ekklesiaCoreGrant.sourceKind,
|
||||
sourceRef: ekklesiaCoreGrant.sourceRef,
|
||||
userId: ekklesiaCoreGrant.userId,
|
||||
})
|
||||
.from(ekklesiaCoreGrant)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.id, input.grantId),
|
||||
@@ -221,20 +403,38 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!row) {
|
||||
.limit(1);
|
||||
if (!target) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "grant.revoke",
|
||||
actorId: actorId(input.actor),
|
||||
actorType: input.actor.type,
|
||||
grantId: row.id,
|
||||
permissionId: row.permissionId,
|
||||
scope: row.scope,
|
||||
subjectUserId: row.userId,
|
||||
});
|
||||
await lockEquivalence(tx, target);
|
||||
|
||||
const rows = await tx
|
||||
.update(ekklesiaCoreGrant)
|
||||
.set({
|
||||
revokedAt: sql`now()`,
|
||||
revokedByActorId: actorId(input.actor),
|
||||
revokedByActorType: input.actor.type,
|
||||
})
|
||||
.where(and(equivalenceCondition(target), isNull(ekklesiaCoreGrant.revokedAt)))
|
||||
.returning();
|
||||
if (rows.length === 0) {
|
||||
// A concurrent revocation committed between the lookup and the lock.
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values(
|
||||
rows.map((row) => ({
|
||||
action: "grant.revoke",
|
||||
actorId: actorId(input.actor),
|
||||
actorType: input.actor.type,
|
||||
grantId: row.id,
|
||||
permissionId: row.permissionId,
|
||||
scope: row.scope,
|
||||
subjectUserId: row.userId,
|
||||
})),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
@@ -269,10 +469,21 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deterministic keyset pagination over `created_at DESC, id DESC`. The boundary is a strict
|
||||
* row-value comparison on *both* columns, so grants sharing a `created_at` — which concurrent
|
||||
* creation makes routine — can neither be skipped nor repeated across pages. Offset pagination
|
||||
* would do exactly that under concurrent writes, which is why it is not used.
|
||||
*
|
||||
* At most `limit + 1` rows are read: the extra row only answers "is there another page" and is
|
||||
* never returned.
|
||||
*/
|
||||
listGrantsForUser: async (
|
||||
userId: EkklesiaUserId,
|
||||
options: ListGrantsForUserOptions = {},
|
||||
): Promise<readonly GrantRecord[]> => {
|
||||
listOptions: ListGrantsForUserOptions,
|
||||
): Promise<GrantPage> => {
|
||||
const limit = assertValidLimit(listOptions.limit);
|
||||
const after = listOptions.after;
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -280,14 +491,31 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.userId, userId),
|
||||
...(options.sourceKind === undefined
|
||||
...(listOptions.sourceKind === undefined
|
||||
? []
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, options.sourceKind)]),
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, listOptions.sourceKind)]),
|
||||
...(after === undefined
|
||||
? []
|
||||
: [
|
||||
sql`(${ekklesiaCoreGrant.createdAt}, ${ekklesiaCoreGrant.id}) < (${after.createdAt}::timestamptz, ${after.id}::uuid)`,
|
||||
]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(ekklesiaCoreGrant.createdAt));
|
||||
return Object.freeze(rows.map(toGrantRecord));
|
||||
} catch {
|
||||
.orderBy(desc(ekklesiaCoreGrant.createdAt), desc(ekklesiaCoreGrant.id))
|
||||
.limit(limit + 1);
|
||||
|
||||
const page = rows.slice(0, limit).map(toGrantRecord);
|
||||
const last = page.at(-1);
|
||||
return Object.freeze({
|
||||
grants: Object.freeze(page),
|
||||
...(rows.length > limit && last !== undefined
|
||||
? { nextBoundary: Object.freeze({ createdAt: last.createdAt, id: last.id }) }
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user