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
@@ -9,6 +9,7 @@ import {
|
||||
type GrantId,
|
||||
parseAuthorizationScope,
|
||||
parseEkklesiaUserId,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
SYSTEM_ACTOR,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -341,23 +342,56 @@ describe.sequential("live Core authorization store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("denies a grant once it expires, evaluated against a real elapsed instant", async () => {
|
||||
const expiringPermission = parsePermissionId("calendar.events.export");
|
||||
const past = new Date(Date.now() - 1_000);
|
||||
it("refuses to create a grant that is already expired on arrival", async () => {
|
||||
// The store no longer accepts a past expiration at all: such a grant is dead the moment it is
|
||||
// written, which is never what a caller meant. This is validated before any transaction opens.
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.createGrant({
|
||||
actor,
|
||||
expiresAt: new Date(Date.now() - 1_000),
|
||||
permission: parsePermissionId("calendar.events.export"),
|
||||
sourceKind: "explicit",
|
||||
userId: userOneId,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_EXPIRATION_INVALID" });
|
||||
expect(
|
||||
await safeQuery(admin, `select id from "ekklesia_core_grant" where permission_id = $1`, [
|
||||
"calendar.events.export",
|
||||
]),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("denies a grant once its expiry has passed, evaluated against a real elapsed instant", async () => {
|
||||
const expiringPermission = parsePermissionId("calendar.events.expiring");
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
await store.createGrant({
|
||||
actor,
|
||||
expiresAt: past,
|
||||
expiresAt,
|
||||
permission: expiringPermission,
|
||||
sourceKind: "explicit",
|
||||
userId: userOneId,
|
||||
});
|
||||
|
||||
const active = await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission: expiringPermission,
|
||||
userId: userOneId,
|
||||
});
|
||||
expect(active).toBe(false);
|
||||
// Deterministic rather than sleeping: the read port evaluates expiration against the instant
|
||||
// it is given, so advancing that instant past the stored expiry is a real elapsed-time check.
|
||||
expect(
|
||||
await store.hasActiveGrant({
|
||||
now: new Date(expiresAt.getTime() - 1_000),
|
||||
permission: expiringPermission,
|
||||
userId: userOneId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await store.hasActiveGrant({
|
||||
now: new Date(expiresAt.getTime() + 1_000),
|
||||
permission: expiringPermission,
|
||||
userId: userOneId,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a grant that has not yet expired", async () => {
|
||||
@@ -539,3 +573,515 @@ describe.sequential("live Core authorization store — no automatic migration",
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 3 grant-integrity scenarios, all against real PostgreSQL because every property under test
|
||||
* is a database-concurrency or database-constraint property that a fake cannot establish.
|
||||
*
|
||||
* The administrative client each scenario uses (`openAdministrativeClient`) is **test-only
|
||||
* provisioning and inspection infrastructure**: it seeds users, plants legacy duplicate rows that
|
||||
* the production store would now refuse to create, and reads back the resulting state. It is never
|
||||
* an API pool or a runtime connection, and it is closed in `afterAll` along with its disposable
|
||||
* database.
|
||||
*/
|
||||
describe.sequential("live Core authorization store — grant equivalence and revocation", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let store: ReturnType<typeof createAuthorizationStore>;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
const subject = parseEkklesiaUserId("equiv_subject");
|
||||
const other = parseEkklesiaUserId("equiv_other");
|
||||
const actor = createUserActor("equiv_other");
|
||||
const permission = parsePermissionId("calendar.events.read");
|
||||
|
||||
async function effectiveCount(userId: string, permissionId: string): Promise<number> {
|
||||
const rows = await safeQuery<{ n: string }>(
|
||||
admin,
|
||||
`select count(*)::int as n from "ekklesia_core_grant"
|
||||
where user_id = $1 and permission_id = $2 and revoked_at is null
|
||||
and (expires_at is null or expires_at > now())`,
|
||||
[userId, permissionId],
|
||||
);
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
it("prepares a disposable database with the three Core migrations applied", async () => {
|
||||
database = await createDisposableDatabase("equivalence");
|
||||
await applyCoreMigrations(database.url);
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
await seedUser(admin, subject, "[email protected]");
|
||||
await seedUser(admin, other, "[email protected]");
|
||||
const handle = createAuthorizationDrizzleDatabase(database.url);
|
||||
close = handle.close;
|
||||
store = createAuthorizationStore(handle.db);
|
||||
expect(await effectiveCount(subject, permission)).toBe(0);
|
||||
});
|
||||
|
||||
it("creates the first unscoped explicit grant and writes exactly one create audit row", async () => {
|
||||
const grant = await store.createGrant({
|
||||
actor,
|
||||
permission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
|
||||
expect(grant.scope).toBeUndefined();
|
||||
const audit = await safeQuery<{ action: string }>(
|
||||
admin,
|
||||
`select action from "ekklesia_core_authorization_audit" where grant_id = $1`,
|
||||
[grant.id],
|
||||
);
|
||||
expect(audit.map((row) => row.action)).toEqual(["grant.create"]);
|
||||
});
|
||||
|
||||
it("refuses a sequential equivalent creation, leaving no second grant and no second audit row", async () => {
|
||||
const auditBefore = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where subject_user_id = $1 and permission_id = $2`,
|
||||
[subject, permission],
|
||||
);
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.createGrant({ actor, permission, sourceKind: "explicit", userId: subject });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(AuthorizationStoreError);
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(await effectiveCount(subject, permission)).toBe(1);
|
||||
const auditAfter = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where subject_user_id = $1 and permission_id = $2`,
|
||||
[subject, permission],
|
||||
);
|
||||
expect(auditAfter).toHaveLength(auditBefore.length);
|
||||
});
|
||||
|
||||
it("a differing expiration does not make an otherwise identical issuance distinct", async () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.createGrant({
|
||||
actor,
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
permission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(await effectiveCount(subject, permission)).toBe(1);
|
||||
});
|
||||
|
||||
it("a different subject, permission, scope, source kind, or source reference is not equivalent", async () => {
|
||||
const distinct = [
|
||||
{ permission, sourceKind: "explicit" as const, userId: other },
|
||||
{
|
||||
permission: parsePermissionId("calendar.events.write"),
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subject,
|
||||
},
|
||||
{
|
||||
permission,
|
||||
scope: parseAuthorizationScope("calendar-event:1"),
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subject,
|
||||
},
|
||||
{ permission, sourceKind: "relationship" as const, userId: subject },
|
||||
{
|
||||
permission,
|
||||
sourceKind: "relationship" as const,
|
||||
sourceRef: "roster:7",
|
||||
userId: subject,
|
||||
},
|
||||
];
|
||||
for (const input of distinct) {
|
||||
const grant = await store.createGrant({ actor, ...input });
|
||||
expect(grant.id).toBeDefined();
|
||||
}
|
||||
// A second issuance of one of those distinct tuples is itself a duplicate.
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.createGrant({
|
||||
actor,
|
||||
permission,
|
||||
scope: parseAuthorizationScope("calendar-event:1"),
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
});
|
||||
|
||||
it("serializes concurrent equivalent creations to exactly one grant and one conflict", async () => {
|
||||
const concurrentPermission = parsePermissionId("calendar.events.concurrent");
|
||||
// Six genuinely concurrent transactions against the same tuple. The transaction-scoped
|
||||
// advisory lock is what makes this deterministic: no sleeps, no retries, no process-local
|
||||
// mutex — a second API process would be serialized identically.
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 6 }, () =>
|
||||
store.createGrant({
|
||||
actor,
|
||||
permission: concurrentPermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const fulfilled = results.filter((result) => result.status === "fulfilled");
|
||||
const rejected = results.filter((result) => result.status === "rejected");
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(5);
|
||||
for (const result of rejected) {
|
||||
expect((result as PromiseRejectedResult).reason).toMatchObject({
|
||||
code: "AUTHORIZATION_GRANT_ALREADY_EXISTS",
|
||||
});
|
||||
}
|
||||
expect(await effectiveCount(subject, concurrentPermission)).toBe(1);
|
||||
const audit = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit"
|
||||
where subject_user_id = $1 and permission_id = $2 and action = 'grant.create'`,
|
||||
[subject, concurrentPermission],
|
||||
);
|
||||
expect(audit).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows a fresh issuance once the previous equivalent is revoked, and again once one expires", async () => {
|
||||
const cyclePermission = parsePermissionId("calendar.events.cycle");
|
||||
const first = await store.createGrant({
|
||||
actor,
|
||||
permission: cyclePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
await store.revokeGrant({ actor, grantId: first.id, sourceKind: "explicit" });
|
||||
|
||||
// Revoked equivalents do not block a new issuance.
|
||||
const second = await store.createGrant({
|
||||
actor,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
permission: cyclePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
expect(second.id).not.toBe(first.id);
|
||||
|
||||
// Force the second grant past its expiry through the administrative client, which is the only
|
||||
// deterministic way to observe "expired equivalent" without sleeping.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`update "ekklesia_core_grant" set expires_at = now() - interval '1 second' where id = $1`,
|
||||
[second.id],
|
||||
);
|
||||
const third = await store.createGrant({
|
||||
actor,
|
||||
permission: cyclePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
expect(third.id).not.toBe(second.id);
|
||||
expect(await effectiveCount(subject, cyclePermission)).toBe(1);
|
||||
});
|
||||
|
||||
it("one revocation revokes every unrevoked equivalent, audits each by its own id, and leaves nothing authorizing", async () => {
|
||||
const legacyPermission = parsePermissionId("calendar.events.legacy");
|
||||
// Plant the exact state the old store could produce: several equivalent issuances, including
|
||||
// one already expired but never revoked, plus two deliberately non-equivalent neighbours.
|
||||
const legacyIds: string[] = [];
|
||||
for (const expires of ["null", "now() + interval '1 hour'", "now() - interval '1 hour'"]) {
|
||||
const rows = 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, expires_at)
|
||||
values ($1, $2, null, 'explicit', null, 'user', $3, ${expires}) returning id`,
|
||||
[subject, legacyPermission, other],
|
||||
);
|
||||
legacyIds.push(rows[0]?.id as string);
|
||||
}
|
||||
const scopedNeighbour = 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, 'calendar-event:9', 'explicit', null, 'user', $3) returning id`,
|
||||
[subject, legacyPermission, other],
|
||||
);
|
||||
// Deliberately on its own permission: an unscoped relationship grant for `legacyPermission`
|
||||
// would legitimately keep authorizing after this revocation (it is a different equivalence
|
||||
// tuple), which would make the "nothing authorizes" assertion below meaningless rather than
|
||||
// wrong. The scoped neighbour above stays on the same permission because a scoped grant never
|
||||
// satisfies an unscoped check.
|
||||
const relationshipNeighbour = 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, 'calendar.events.legacyrel', null, 'relationship', 'roster:9', 'user', $2) returning id`,
|
||||
[subject, other],
|
||||
);
|
||||
expect(await effectiveCount(subject, legacyPermission)).toBe(3);
|
||||
|
||||
await store.revokeGrant({
|
||||
actor,
|
||||
grantId: parseGrantId(legacyIds[0] as string),
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
|
||||
const revoked = await safeQuery<{ id: string; revoked_at: Date | null }>(
|
||||
admin,
|
||||
`select id, revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
|
||||
[legacyIds],
|
||||
);
|
||||
// Every unrevoked equivalent, including the expired-but-unrevoked one, is now revoked.
|
||||
expect(revoked.every((row) => row.revoked_at !== null)).toBe(true);
|
||||
|
||||
const auditRows = await safeQuery<{ grant_id: string }>(
|
||||
admin,
|
||||
`select grant_id from "ekklesia_core_authorization_audit"
|
||||
where action = 'grant.revoke' and grant_id = any($1::uuid[])`,
|
||||
[legacyIds],
|
||||
);
|
||||
// One audit row per grant actually revoked, each carrying that row's own id.
|
||||
expect(auditRows).toHaveLength(legacyIds.length);
|
||||
expect([...new Set(auditRows.map((row) => row.grant_id))].sort()).toEqual(
|
||||
[...legacyIds].sort(),
|
||||
);
|
||||
|
||||
// Differently scoped and differently sourced grants are untouched.
|
||||
const untouched = await safeQuery<{ revoked_at: Date | null }>(
|
||||
admin,
|
||||
`select revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
|
||||
[[scopedNeighbour[0]?.id, relationshipNeighbour[0]?.id]],
|
||||
);
|
||||
expect(untouched.every((row) => row.revoked_at === null)).toBe(true);
|
||||
|
||||
// No equivalent issuance continues authorizing the user.
|
||||
expect(
|
||||
await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission: legacyPermission,
|
||||
userId: subject,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes a concurrent create and revoke on the same tuple without leaving an unexplained duplicate", async () => {
|
||||
const racePermission = parsePermissionId("calendar.events.race");
|
||||
const seed = await store.createGrant({
|
||||
actor,
|
||||
permission: racePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
|
||||
const [revokeResult, createResult] = await Promise.allSettled([
|
||||
store.revokeGrant({ actor, grantId: seed.id, sourceKind: "explicit" }),
|
||||
store.createGrant({
|
||||
actor,
|
||||
permission: racePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Whichever order the two transactions serialize in, the end state is consistent with that
|
||||
// order: either the create lost to an effective equivalent (0 effective), or it ran after the
|
||||
// revoke (1 effective). Two effective equivalents is the state this pass makes impossible.
|
||||
expect(revokeResult.status).toBe("fulfilled");
|
||||
const effective = await effectiveCount(subject, racePermission);
|
||||
expect(effective).toBeLessThanOrEqual(1);
|
||||
if (createResult.status === "rejected") {
|
||||
expect(createResult.reason).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(effective).toBe(0);
|
||||
} else {
|
||||
expect(effective).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back every grant and audit change when the multi-row revocation transaction fails", async () => {
|
||||
const doomedPermission = parsePermissionId("calendar.events.doomed");
|
||||
const rows = 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), ($1, $2, null, 'explicit', null, 'user', $3)
|
||||
returning id`,
|
||||
[subject, doomedPermission, other],
|
||||
);
|
||||
// Force the audit insert inside the revoke transaction to violate a real constraint: the audit
|
||||
// action CHECK only permits the four known actions, so a temporary trigger that rewrites the
|
||||
// action makes the whole multi-row revocation fail after the grant updates were staged.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`create function ekklesia_test_break_audit() returns trigger as $$
|
||||
begin new.action := 'not.a.real.action'; return new; end;
|
||||
$$ language plpgsql`,
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`create trigger ekklesia_test_break_audit_trigger before insert on "ekklesia_core_authorization_audit"
|
||||
for each row execute function ekklesia_test_break_audit()`,
|
||||
);
|
||||
try {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.revokeGrant({
|
||||
actor,
|
||||
grantId: parseGrantId(rows[0]?.id as string),
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
||||
|
||||
const after = await safeQuery<{ revoked_at: Date | null }>(
|
||||
admin,
|
||||
`select revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
|
||||
[rows.map((row) => row.id)],
|
||||
);
|
||||
expect(after.every((row) => row.revoked_at === null)).toBe(true);
|
||||
const audit = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
||||
[doomedPermission],
|
||||
);
|
||||
expect(audit).toHaveLength(0);
|
||||
} finally {
|
||||
// Always restored, even when an assertion above fails.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`drop trigger if exists ekklesia_test_break_audit_trigger on "ekklesia_core_authorization_audit"`,
|
||||
);
|
||||
await safeQuery(admin, `drop function if exists ekklesia_test_break_audit()`);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports duplicate conflict, not-found, and infrastructure failure as three distinct sanitized outcomes", async () => {
|
||||
const codes: string[] = [];
|
||||
try {
|
||||
await store.createGrant({ actor, permission, sourceKind: "explicit", userId: subject });
|
||||
} catch (error) {
|
||||
codes.push((error as AuthorizationStoreError).code);
|
||||
}
|
||||
try {
|
||||
await store.revokeGrant({
|
||||
actor,
|
||||
grantId: parseGrantId("00000000-0000-4000-8000-000000000000"),
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
} catch (error) {
|
||||
codes.push((error as AuthorizationStoreError).code);
|
||||
}
|
||||
await close();
|
||||
try {
|
||||
await store.hasActiveGrant({ now: new Date(), permission, userId: subject });
|
||||
} catch (error) {
|
||||
codes.push((error as AuthorizationStoreError).code);
|
||||
}
|
||||
|
||||
expect(codes).toEqual([
|
||||
"AUTHORIZATION_GRANT_ALREADY_EXISTS",
|
||||
"AUTHORIZATION_GRANT_NOT_FOUND",
|
||||
"AUTHORIZATION_GRANT_LOOKUP_FAILED",
|
||||
]);
|
||||
await safeEnd(admin);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live Core authorization store — bounded keyset pagination", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let store: ReturnType<typeof createAuthorizationStore>;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
const subject = parseEkklesiaUserId("page_subject");
|
||||
|
||||
it("seeds explicit and relationship grants, including several sharing one created_at", async () => {
|
||||
database = await createDisposableDatabase("pagination");
|
||||
await applyCoreMigrations(database.url);
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
await seedUser(admin, subject, "[email protected]");
|
||||
const handle = createAuthorizationDrizzleDatabase(database.url);
|
||||
close = handle.close;
|
||||
store = createAuthorizationStore(handle.db);
|
||||
|
||||
// Seven explicit grants all sharing one identical `created_at`, which is exactly the case a
|
||||
// timestamp-only keyset boundary would skip or repeat, plus relationship grants that must
|
||||
// never appear on any page.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`insert into "ekklesia_core_grant"
|
||||
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, created_at)
|
||||
select $1, 'calendar.events.p' || generation, null, 'explicit', null, 'user', $1,
|
||||
timestamptz '2026-01-01 00:00:00Z'
|
||||
from generate_series(1, 7) as generation`,
|
||||
[subject],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`insert into "ekklesia_core_grant"
|
||||
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, created_at)
|
||||
select $1, 'calendar.events.r' || generation, null, 'relationship', 'roster:' || generation, 'user', $1,
|
||||
timestamptz '2026-01-01 00:00:00Z'
|
||||
from generate_series(1, 3) as generation`,
|
||||
[subject],
|
||||
);
|
||||
expect(
|
||||
await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [subject]),
|
||||
).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("traverses every explicit grant exactly once across pages, excluding relationship grants", async () => {
|
||||
const seen: string[] = [];
|
||||
let after: { createdAt: Date; id: GrantId } | undefined;
|
||||
let pages = 0;
|
||||
for (;;) {
|
||||
const page = await store.listGrantsForUser(subject, {
|
||||
...(after === undefined ? {} : { after }),
|
||||
limit: 3,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
pages += 1;
|
||||
seen.push(...page.grants.map((grant) => grant.id));
|
||||
expect(page.grants.every((grant) => grant.sourceKind === "explicit")).toBe(true);
|
||||
if (page.nextBoundary === undefined) {
|
||||
expect(page.grants.length).toBeLessThanOrEqual(3);
|
||||
break;
|
||||
}
|
||||
after = page.nextBoundary;
|
||||
expect(pages).toBeLessThan(10);
|
||||
}
|
||||
|
||||
// 7 explicit rows at limit 3 → pages of 3, 3, 1. No duplicates, no omissions, deterministic
|
||||
// despite every row sharing one created_at.
|
||||
expect(pages).toBe(3);
|
||||
expect(seen).toHaveLength(7);
|
||||
expect(new Set(seen).size).toBe(7);
|
||||
// Ordering is created_at DESC, id DESC — with equal timestamps that is a pure descending id
|
||||
// sort, which is exactly what makes traversal total and repeatable.
|
||||
expect([...seen].sort().reverse()).toEqual(seen);
|
||||
});
|
||||
|
||||
it("honours the 1 and 100 boundaries and rejects anything outside them", async () => {
|
||||
expect(
|
||||
(await store.listGrantsForUser(subject, { limit: 1, sourceKind: "explicit" })).grants,
|
||||
).toHaveLength(1);
|
||||
const all = await store.listGrantsForUser(subject, { limit: 100, sourceKind: "explicit" });
|
||||
expect(all.grants).toHaveLength(7);
|
||||
expect(all.nextBoundary).toBeUndefined();
|
||||
for (const limit of [0, 101, -1, 1.5]) {
|
||||
await expect(store.listGrantsForUser(subject, { limit })).rejects.toMatchObject({
|
||||
code: "AUTHORIZATION_GRANT_LIMIT_INVALID",
|
||||
});
|
||||
}
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,13 @@ const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
|
||||
import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js";
|
||||
import { AuthorizationStoreError, createAuthorizationStore } from "../src/authorization/store.js";
|
||||
import {
|
||||
AUTHORIZATION_GRANT_LIST_MAX_LIMIT,
|
||||
AuthorizationStoreError,
|
||||
createAuthorizationStore,
|
||||
} from "../src/authorization/store.js";
|
||||
|
||||
describe("Core authorization store — import and construction safety", () => {
|
||||
it("does not construct a pool or open a connection merely because the module is imported", () => {
|
||||
@@ -33,7 +38,11 @@ describe("Core authorization store — import and construction safety", () => {
|
||||
|
||||
describe("AuthorizationStoreError", () => {
|
||||
it.each([
|
||||
"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",
|
||||
"AUTHORIZATION_GRANT_REVOKE_FAILED",
|
||||
@@ -56,3 +65,71 @@ describe("AuthorizationStoreError", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Input validation the store performs *before* any transaction opens, so a rejected value performs
|
||||
* no database work at all. The fake database below records every call it receives; asserting it
|
||||
* stayed empty is what proves "no grant and no audit row" rather than merely "an error was
|
||||
* thrown". `createAuthorizationStore`'s injectable clock keeps this deterministic without
|
||||
* arbitrary sleeps or real-time flakiness.
|
||||
*/
|
||||
describe("Core authorization store — pre-database input validation", () => {
|
||||
const fixedNow = new Date("2026-07-26T12:00:00.000Z");
|
||||
|
||||
function recordingStore() {
|
||||
const calls: string[] = [];
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn(), query: vi.fn() };
|
||||
});
|
||||
const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
|
||||
const instrumented = new Proxy(db, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "transaction" || property === "select" || property === "execute") {
|
||||
calls.push(String(property));
|
||||
}
|
||||
return Reflect.get(target, property, receiver) as unknown;
|
||||
},
|
||||
});
|
||||
return { calls, store: createAuthorizationStore(instrumented, { now: () => fixedNow }) };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["a past instant", new Date("2020-01-01T00:00:00.000Z")],
|
||||
["exactly the acceptance clock", new Date("2026-07-26T12:00:00.000Z")],
|
||||
["an invalid Date", new Date("nonsense")],
|
||||
])("rejects %s expiration with no database work at all", async (_label, expiresAt) => {
|
||||
const { calls, store } = recordingStore();
|
||||
|
||||
await expect(
|
||||
store.createGrant({
|
||||
actor: SYSTEM_ACTOR,
|
||||
expiresAt,
|
||||
permission: parsePermissionId("calendar.events.read"),
|
||||
sourceKind: "explicit",
|
||||
userId: parseEkklesiaUserId("subject_1"),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "AUTHORIZATION_GRANT_EXPIRATION_INVALID" });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
101,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.MAX_SAFE_INTEGER + 2,
|
||||
])("rejects the unusable page size %s with no database work at all", async (limit) => {
|
||||
const { calls, store } = recordingStore();
|
||||
|
||||
await expect(
|
||||
store.listGrantsForUser(parseEkklesiaUserId("subject_1"), { limit }),
|
||||
).rejects.toMatchObject({ code: "AUTHORIZATION_GRANT_LIMIT_INVALID" });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes one shared maximum page size rather than two that could drift", () => {
|
||||
expect(AUTHORIZATION_GRANT_LIST_MAX_LIMIT).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user