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
+220
-35
@@ -1,16 +1,17 @@
|
||||
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
||||
import type { EkklesiaUserId, GrantId, PermissionId } from "@ekklesia/contracts";
|
||||
import {
|
||||
createUserActor,
|
||||
isAuthenticatedPrincipal,
|
||||
safeParseAuthorizationScope,
|
||||
safeParseEkklesiaUserId,
|
||||
safeParseGrantId,
|
||||
safeParsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
AUTHORIZATION_GRANT_LIST_MAX_LIMIT,
|
||||
type AuthorizationStore,
|
||||
AuthorizationStoreError,
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
type GrantPageBoundary,
|
||||
type GrantRecord,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { Hono } from "hono";
|
||||
@@ -26,13 +27,36 @@ import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.j
|
||||
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly store: AuthorizationStore;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
/** Injected only so expiration validation is deterministic under test. Production composition
|
||||
* leaves this unset and the routes read real time. */
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
const DEFAULT_LIST_LIMIT = 50;
|
||||
|
||||
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
if (error instanceof AuthorizationStoreError) {
|
||||
const status = error.code === "AUTHORIZATION_GRANT_NOT_FOUND" ? 404 : 503;
|
||||
return c.json({ code: error.code }, status);
|
||||
if (error.code === "AUTHORIZATION_GRANT_NOT_FOUND") {
|
||||
return c.json({ code: error.code }, 404);
|
||||
}
|
||||
// The one conflict the caller can act on. It deliberately reveals nothing about the grant that
|
||||
// already exists — not its id, actor, scope, source reference, or expiry — only that an
|
||||
// equivalent authorization is already effective for this subject.
|
||||
if (error.code === "AUTHORIZATION_GRANT_ALREADY_EXISTS") {
|
||||
return c.json({ code: error.code }, 409);
|
||||
}
|
||||
// Defense in depth: the routes below reject both of these before ever reaching the store, so
|
||||
// arriving here means a request-shaped problem slipped through. It is still a client input
|
||||
// failure, never an infrastructure one, and it reports exactly like any other malformed
|
||||
// request rather than leaking the store's internal distinction.
|
||||
if (
|
||||
error.code === "AUTHORIZATION_GRANT_EXPIRATION_INVALID" ||
|
||||
error.code === "AUTHORIZATION_GRANT_LIMIT_INVALID"
|
||||
) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
return c.json({ code: error.code }, 503);
|
||||
}
|
||||
return c.json({ code: "INTERNAL_ERROR" }, 503);
|
||||
}
|
||||
@@ -40,20 +64,55 @@ function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
|
||||
interface ParsedCreateGrantBody {
|
||||
readonly expiresAt?: Date;
|
||||
readonly permission: PermissionId;
|
||||
readonly scope?: AuthorizationScope;
|
||||
readonly subjectUserId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "scope", "expiresAt"]);
|
||||
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "expiresAt"]);
|
||||
|
||||
/**
|
||||
* Accepts only `subjectUserId`, `permission`, an optional `scope`, and an optional `expiresAt`.
|
||||
* Any other key present — `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`,
|
||||
* `revokedAt`, a grant id, or anything claiming owner/effective-permission state — makes the
|
||||
* whole body invalid rather than being silently dropped, so a caller cannot smuggle
|
||||
* server-derived fields through a request that otherwise looks well-formed.
|
||||
* The one accepted expiration representation: `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to
|
||||
* the parsed `Date`'s own `toISOString()`.
|
||||
*
|
||||
* `new Date(value)` alone was far too permissive. It silently *normalized* impossible dates
|
||||
* (`2026-02-31T00:00:00.000Z` became March 3rd) and accepted locale-shaped input
|
||||
* (`"March 5, 2027"`), date-only values, offsets, and missing milliseconds — so what an operator
|
||||
* typed and what the platform stored could differ without any error. The regular expression pins
|
||||
* the lexical form and the round-trip comparison rejects every value the parser would otherwise
|
||||
* quietly rewrite.
|
||||
*/
|
||||
function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
|
||||
const CANONICAL_INSTANT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
||||
|
||||
function parseCanonicalFutureInstant(value: unknown, now: Date): Date | undefined {
|
||||
if (typeof value !== "string" || !CANONICAL_INSTANT_PATTERN.test(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) {
|
||||
return undefined;
|
||||
}
|
||||
// Strictly future: an expiration equal to or before the acceptance clock produces a grant that
|
||||
// is already dead, which is never what the caller meant.
|
||||
if (parsed.getTime() <= now.getTime()) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts only `subjectUserId`, `permission`, and an optional `expiresAt`. Any other key present —
|
||||
* `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`, `revokedAt`, a grant id, or
|
||||
* anything claiming owner/effective-permission state — makes the whole body invalid rather than
|
||||
* being silently dropped, so a caller cannot smuggle server-derived fields through a request that
|
||||
* otherwise looks well-formed.
|
||||
*
|
||||
* **`scope` is deliberately among the rejected keys**, including when supplied as `null`, empty, or
|
||||
* an otherwise valid scope string. Core's scoped-grant semantics remain intact for trusted internal
|
||||
* callers, but no HTTP route middleware supplies a scope to the evaluator today, so a scope
|
||||
* accepted here would produce a grant that looks meaningful, persists, appears in listings, and can
|
||||
* authorize nothing at all. Accepting it would be offering a switch that is not connected to
|
||||
* anything. When a server-owned resolver exists, this is the one place that changes.
|
||||
*/
|
||||
function parseCreateGrantBody(raw: unknown, now: Date): ParsedCreateGrantBody | undefined {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -71,31 +130,138 @@ function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let scope: AuthorizationScope | undefined;
|
||||
if (body.scope !== undefined) {
|
||||
const parsedScope = safeParseAuthorizationScope(body.scope);
|
||||
if (!parsedScope.success) {
|
||||
return undefined;
|
||||
}
|
||||
scope = parsedScope.data;
|
||||
}
|
||||
|
||||
let expiresAt: Date | undefined;
|
||||
if (body.expiresAt !== undefined) {
|
||||
if (typeof body.expiresAt !== "string") {
|
||||
expiresAt = parseCanonicalFutureInstant(body.expiresAt, now);
|
||||
if (expiresAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedDate = new Date(body.expiresAt);
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
expiresAt = parsedDate;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
permission: permission.data,
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
subjectUserId: subjectUserId.data,
|
||||
});
|
||||
}
|
||||
|
||||
const CURSOR_VERSION = 1;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
||||
|
||||
/**
|
||||
* An opaque pagination *position*, not an authorization token. It carries only the subject user id,
|
||||
* the boundary row's creation instant, and the boundary grant id — never a permission, scope,
|
||||
* source reference, actor, credential, or secret — so there is nothing in it worth protecting.
|
||||
*
|
||||
* It is deliberately unsigned. Signing would imply the cursor carries authority it does not have:
|
||||
* the permission check and the server-side subject filter remain authoritative on every page, and
|
||||
* a forged cursor can at most name a different position within grants the caller is already
|
||||
* entitled to list. The strict decode below still refuses anything that is not exactly what this
|
||||
* function would emit.
|
||||
*/
|
||||
function encodeCursor(userId: EkklesiaUserId, boundary: GrantPageBoundary): string {
|
||||
return Buffer.from(
|
||||
JSON.stringify({
|
||||
g: boundary.id,
|
||||
t: boundary.createdAt.toISOString(),
|
||||
u: userId,
|
||||
v: CURSOR_VERSION,
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict in every dimension: canonical unpadded base64url, valid UTF-8 JSON, an object with exactly
|
||||
* the four expected keys, the exact version, a subject matching the *requested* one, a canonical
|
||||
* `toISOString()` timestamp, a canonical `GrantId`, and — the catch-all — a re-encoding that
|
||||
* reproduces the supplied string byte-for-byte, which rejects padding, trailing data, and any
|
||||
* alternative encoding of the same payload. A rejected cursor reveals nothing it decoded.
|
||||
*/
|
||||
function decodeCursor(raw: string, userId: EkklesiaUserId): GrantPageBoundary | undefined {
|
||||
if (!BASE64URL_PATTERN.test(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const payload = parsed as Record<string, unknown>;
|
||||
if (Object.keys(payload).sort().join(",") !== "g,t,u,v") {
|
||||
return undefined;
|
||||
}
|
||||
if (payload.v !== CURSOR_VERSION || payload.u !== userId || typeof payload.t !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const createdAt = new Date(payload.t);
|
||||
if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== payload.t) {
|
||||
return undefined;
|
||||
}
|
||||
const id = safeParseGrantId(payload.g);
|
||||
if (!id.success) {
|
||||
return undefined;
|
||||
}
|
||||
const boundary: GrantPageBoundary = Object.freeze({ createdAt, id: id.data });
|
||||
if (encodeCursor(userId, boundary) !== raw) {
|
||||
return undefined;
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
const LIST_ALLOWED_QUERY_KEYS = new Set(["userId", "limit", "cursor"]);
|
||||
/** Canonical base-10, no sign, no leading zero, no decimal point, no whitespace, no exponent. */
|
||||
const CANONICAL_POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
|
||||
|
||||
interface ParsedListQuery {
|
||||
readonly after?: GrantPageBoundary;
|
||||
readonly limit: number;
|
||||
readonly subjectUserId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
/** Fully validated before any store or database work happens, so an invalid page request never
|
||||
* becomes a query, and never becomes a way to probe which subjects or grants exist. */
|
||||
function parseListQuery(url: string): ParsedListQuery | undefined {
|
||||
const searchParams = new URL(url).searchParams;
|
||||
for (const key of searchParams.keys()) {
|
||||
if (!LIST_ALLOWED_QUERY_KEYS.has(key) || searchParams.getAll(key).length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const subjectUserId = safeParseEkklesiaUserId(searchParams.get("userId"));
|
||||
if (!subjectUserId.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let limit = DEFAULT_LIST_LIMIT;
|
||||
const rawLimit = searchParams.get("limit");
|
||||
if (rawLimit !== null) {
|
||||
if (!CANONICAL_POSITIVE_INTEGER_PATTERN.test(rawLimit)) {
|
||||
return undefined;
|
||||
}
|
||||
limit = Number(rawLimit);
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let after: GrantPageBoundary | undefined;
|
||||
const rawCursor = searchParams.get("cursor");
|
||||
if (rawCursor !== null) {
|
||||
after = decodeCursor(rawCursor, subjectUserId.data);
|
||||
if (after === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...(after === undefined ? {} : { after }),
|
||||
limit,
|
||||
subjectUserId: subjectUserId.data,
|
||||
});
|
||||
}
|
||||
@@ -125,9 +291,12 @@ function serializeGrant(grant: GrantRecord) {
|
||||
* `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting
|
||||
* to revoke a relationship-derived grant by id behaves exactly like the grant does not exist —
|
||||
* those grants remain owned by the module operation that established the relationship.
|
||||
*
|
||||
* Creation is unscoped-only; see `parseCreateGrantBody`.
|
||||
*/
|
||||
export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
// Origin is checked first (a rejected cross-origin mutation must never have its body read), then
|
||||
@@ -137,15 +306,29 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
|
||||
routes.get("/api/v1/grants", requireManagePermission, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const subjectUserId = safeParseEkklesiaUserId(c.req.query("userId"));
|
||||
if (!subjectUserId.success) {
|
||||
// Reached only after `requireManagePermission` allowed the caller, so query validation can
|
||||
// never disclose a validation distinction — or the existence of any subject or grant — to an
|
||||
// unauthorized caller.
|
||||
const query = parseListQuery(c.req.url);
|
||||
if (!query) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
try {
|
||||
const grants = await options.store.listGrantsForUser(subjectUserId.data, {
|
||||
const page = await options.store.listGrantsForUser(query.subjectUserId, {
|
||||
...(query.after === undefined ? {} : { after: query.after }),
|
||||
limit: query.limit,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
return c.json({ grants: grants.map(serializeGrant) }, 200);
|
||||
return c.json(
|
||||
{
|
||||
grants: page.grants.map(serializeGrant),
|
||||
nextCursor:
|
||||
page.nextBoundary === undefined
|
||||
? null
|
||||
: encodeCursor(query.subjectUserId, page.nextBoundary),
|
||||
},
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
return sanitizedStoreErrorResponse(c, error);
|
||||
}
|
||||
@@ -169,7 +352,7 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
} catch {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
const parsed = parseCreateGrantBody(rawBody);
|
||||
const parsed = parseCreateGrantBody(rawBody, now());
|
||||
if (!parsed) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
@@ -189,7 +372,6 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
actor: createUserActor(principal.userId),
|
||||
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
|
||||
permission: parsed.permission,
|
||||
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
|
||||
sourceKind: "explicit",
|
||||
userId: parsed.subjectUserId,
|
||||
});
|
||||
@@ -212,6 +394,9 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
|
||||
// Canonical-UUID validation happens after authorization but strictly before any store or
|
||||
// database call: a malformed id previously reached PostgreSQL's own UUID comparison and came
|
||||
// back as an indistinguishable infrastructure failure (503) rather than a request problem.
|
||||
const grantId = safeParseGrantId(c.req.param("grantId"));
|
||||
if (!grantId.success) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
@@ -220,7 +405,7 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
try {
|
||||
await options.store.revokeGrant({
|
||||
actor: createUserActor(principal.userId),
|
||||
grantId: grantId.data,
|
||||
grantId: grantId.data satisfies GrantId,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
return c.json({ success: true }, 200);
|
||||
|
||||
@@ -1396,7 +1396,7 @@ describe.sequential("live API — platform owner and delegated grant management"
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ grants: [] });
|
||||
expect(await response.json()).toEqual({ grants: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it("lets the owner delegate grants.manage to another account through the explicit-grant HTTP API", async () => {
|
||||
@@ -1480,10 +1480,13 @@ describe.sequential("live API — platform owner and delegated grant management"
|
||||
|
||||
it("creates an explicit grant via HTTP, deriving sourceKind and actor server-side, and records an atomic audit entry", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/grants`, {
|
||||
// A subject with no equivalent grant yet — the manager already holds an equivalent
|
||||
// `grants.manage` grant from the delegation scenario above, which the new duplicate
|
||||
// invariant would (correctly) refuse. Unscoped: the generic HTTP surface creates only
|
||||
// unscoped explicit grants, since a scope it accepted could never authorize anything.
|
||||
body: JSON.stringify({
|
||||
permission: testPermission,
|
||||
scope: "calendar-event:1",
|
||||
subjectUserId: managerUserId,
|
||||
subjectUserId: ownerUserId,
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
@@ -1498,7 +1501,7 @@ describe.sequential("live API — platform owner and delegated grant management"
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body.grant.sourceKind).toBe("explicit");
|
||||
expect(body.grant.userId).toBe(managerUserId);
|
||||
expect(body.grant.userId).toBe(ownerUserId);
|
||||
createdGrantId = body.grant.id;
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
@@ -1514,14 +1517,21 @@ describe.sequential("live API — platform owner and delegated grant management"
|
||||
}
|
||||
});
|
||||
|
||||
it("lists the explicit grant via HTTP", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, {
|
||||
it("lists the explicit grant via HTTP, bounded and with an explicit next-page cursor field", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
const body = (await response.json()) as { grants: Array<{ id: string }> };
|
||||
const body = (await response.json()) as {
|
||||
grants: Array<{ id: string; scope: string | null }>;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(body.grants.some((grant) => grant.id === createdGrantId)).toBe(true);
|
||||
// Created through the generic HTTP surface, so it is unscoped.
|
||||
expect(body.grants.find((grant) => grant.id === createdGrantId)?.scope).toBeNull();
|
||||
expect(body.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a create-grant request carrying disallowed fields such as actor or sourceKind, even alongside otherwise-valid fields", async () => {
|
||||
@@ -1659,6 +1669,302 @@ describe.sequential("live API — authorization infrastructure failure is never
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 3's explicit-grant invariants, exercised end-to-end through a real authorized
|
||||
* grants-management session against real PostgreSQL — the layer where a validation decision that
|
||||
* "should" happen before the database either does or does not.
|
||||
*/
|
||||
describe.sequential("live API — explicit-grant invariants over real HTTP", () => {
|
||||
let database: { name: string; url: string };
|
||||
let running: RunningApi;
|
||||
let origin: string;
|
||||
let ownerUserId: string;
|
||||
let subjectUserId: string;
|
||||
let ownerCookie: string;
|
||||
const password = "correct horse battery staple";
|
||||
const managePermission = "core.authorization.grants.manage";
|
||||
|
||||
async function post(path: string, body?: unknown): Promise<Response> {
|
||||
return await fetch(`${origin}${path}`, {
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
headers: {
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async function list(query: string): Promise<{ body: unknown; response: Response }> {
|
||||
const response = await fetch(`${origin}/api/v1/grants?${query}`, {
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
return { body: await response.json(), response };
|
||||
}
|
||||
|
||||
it("starts a freshly migrated API with a bootstrapped owner and a separate grant subject", async () => {
|
||||
database = await createDisposableDatabase("grant_invariants");
|
||||
await applyCoreMigrations(database.url);
|
||||
running = await startMigratedApi(database.url);
|
||||
origin = apiOrigin(running);
|
||||
|
||||
ownerUserId = (await signUp(origin, "[email protected]", password)).body.user.id;
|
||||
subjectUserId = (await signUp(origin, "[email protected]", password)).body.user
|
||||
.id;
|
||||
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) });
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
const ownerSignIn = await signIn(origin, "[email protected]", password);
|
||||
expect(ownerSignIn.cookiePair).toBeDefined();
|
||||
ownerCookie = ownerSignIn.cookiePair as string;
|
||||
});
|
||||
|
||||
it("rejects a malformed non-UUID grant id with a sanitized 400, never a database failure", async () => {
|
||||
for (const malformed of [
|
||||
"not-a-uuid",
|
||||
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
|
||||
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
|
||||
]) {
|
||||
const response = await post(`/api/v1/grants/${malformed}/revoke`);
|
||||
const raw = await response.text();
|
||||
|
||||
// Before Pass 3 this reached PostgreSQL's own UUID comparison and returned 503.
|
||||
expect(response.status, malformed).toBe(400);
|
||||
expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(raw).not.toContain(malformed);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the sanitized 404 for a canonical but unknown grant id", async () => {
|
||||
const response = await post("/api/v1/grants/00000000-0000-4000-8000-000000000000/revoke");
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("rejects a scoped create body with a sanitized 400 and writes nothing", async () => {
|
||||
const response = await post("/api/v1/grants", {
|
||||
permission: managePermission,
|
||||
scope: "calendar-event:1",
|
||||
subjectUserId,
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
expect(
|
||||
await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [
|
||||
subjectUserId,
|
||||
]),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects normalized-impossible, non-canonical, and past expirations with a sanitized 400", async () => {
|
||||
for (const expiresAt of [
|
||||
"2026-02-31T00:00:00.000Z",
|
||||
"March 5, 2027",
|
||||
"2027-01-01",
|
||||
"2027-01-01T00:00:00Z",
|
||||
"2027-01-01T00:00:00.000+02:00",
|
||||
"2020-01-01T00:00:00.000Z",
|
||||
]) {
|
||||
const response = await post("/api/v1/grants", {
|
||||
expiresAt,
|
||||
permission: managePermission,
|
||||
subjectUserId,
|
||||
});
|
||||
expect(response.status, expiresAt).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates one grant, then answers an equivalent creation with a sanitized 409 and no second row", async () => {
|
||||
const created = await post("/api/v1/grants", {
|
||||
permission: managePermission,
|
||||
subjectUserId,
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const duplicate = await post("/api/v1/grants", {
|
||||
permission: managePermission,
|
||||
subjectUserId,
|
||||
});
|
||||
const raw = await duplicate.text();
|
||||
expect(duplicate.status).toBe(409);
|
||||
expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(duplicate.headers.get("cache-control")).toBe("no-store");
|
||||
// No existing grant id, actor, scope, source reference, SQL, or driver text leaks.
|
||||
expect(raw).not.toContain(ownerUserId);
|
||||
expect(raw).not.toContain(subjectUserId);
|
||||
expect(raw).not.toContain("ekklesia_core_grant");
|
||||
|
||||
// A differing expiration is not a different grant either.
|
||||
const withExpiry = await post("/api/v1/grants", {
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
permission: managePermission,
|
||||
subjectUserId,
|
||||
});
|
||||
expect(withExpiry.status).toBe(409);
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
expect(
|
||||
await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_grant" where user_id = $1 and revoked_at is null`,
|
||||
[subjectUserId],
|
||||
),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
});
|
||||
|
||||
it("paginates deterministically across at least three pages and ends with a null cursor", async () => {
|
||||
// Seeded through the test-only administrative client so the page count is deterministic and
|
||||
// independent of how many grants the scenarios above happened to create.
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
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.page' || generation, null, 'explicit', null, 'user', $2,
|
||||
timestamptz '2026-01-01 00:00:00Z'
|
||||
from generate_series(1, 6) as generation`,
|
||||
[subjectUserId, ownerUserId],
|
||||
);
|
||||
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)
|
||||
values ($1, 'roster.volunteer.read', null, 'relationship', 'roster:1', 'user', $2)`,
|
||||
[subjectUserId, ownerUserId],
|
||||
);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
for (;;) {
|
||||
const query = `userId=${subjectUserId}&limit=3${
|
||||
cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`
|
||||
}`;
|
||||
const { body, response } = await list(query);
|
||||
const page = body as {
|
||||
grants: Array<{ id: string; sourceKind: string }>;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(page.grants.length).toBeLessThanOrEqual(3);
|
||||
// Relationship grants never appear on any page.
|
||||
expect(page.grants.every((grant) => grant.sourceKind === "explicit")).toBe(true);
|
||||
seen.push(...page.grants.map((grant) => grant.id));
|
||||
pages += 1;
|
||||
if (page.nextCursor === null) {
|
||||
break;
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
expect(pages).toBeLessThan(10);
|
||||
}
|
||||
|
||||
expect(pages).toBeGreaterThanOrEqual(3);
|
||||
expect(seen).toHaveLength(7);
|
||||
expect(new Set(seen).size).toBe(seen.length);
|
||||
});
|
||||
|
||||
it("rejects an out-of-range limit and a foreign or malformed cursor with a sanitized 400", async () => {
|
||||
for (const query of [
|
||||
`userId=${subjectUserId}&limit=0`,
|
||||
`userId=${subjectUserId}&limit=101`,
|
||||
`userId=${subjectUserId}&limit=01`,
|
||||
`userId=${subjectUserId}&limit=1&limit=2`,
|
||||
`userId=${subjectUserId}&unknown=1`,
|
||||
`userId=${subjectUserId}&cursor=!!!`,
|
||||
// A cursor minted for a different subject must not be replayable against this one.
|
||||
`userId=${subjectUserId}&cursor=${encodeURIComponent(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
g: "00000000-0000-4000-8000-000000000000",
|
||||
t: "2026-01-01T00:00:00.000Z",
|
||||
u: ownerUserId,
|
||||
v: 1,
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64url"),
|
||||
)}`,
|
||||
]) {
|
||||
const { body, response } = await list(query);
|
||||
expect(response.status, query).toBe(400);
|
||||
expect(body).toEqual({ code: "INVALID_REQUEST" });
|
||||
}
|
||||
});
|
||||
|
||||
it("revokes every unrevoked equivalent through one HTTP revocation, leaving nothing authorizing", async () => {
|
||||
const permission = "calendar.events.legacydupe";
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
// Legacy duplicates the pre-Pass-3 store could produce, planted directly because the
|
||||
// production store now refuses to create them.
|
||||
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),
|
||||
($1, $2, null, 'explicit', null, 'user', $3)
|
||||
returning id`,
|
||||
[subjectUserId, permission, ownerUserId],
|
||||
);
|
||||
ids = rows.map((row) => row.id);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
|
||||
const response = await post(`/api/v1/grants/${ids[0]}/revoke`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ success: true });
|
||||
|
||||
const inspector = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
const remaining = await safeQuery(
|
||||
inspector,
|
||||
`select id from "ekklesia_core_grant"
|
||||
where user_id = $1 and permission_id = $2 and revoked_at is null`,
|
||||
[subjectUserId, permission],
|
||||
);
|
||||
expect(remaining).toHaveLength(0);
|
||||
const audit = await safeQuery<{ grant_id: string }>(
|
||||
inspector,
|
||||
`select grant_id from "ekklesia_core_authorization_audit"
|
||||
where action = 'grant.revoke' and grant_id = any($1::uuid[])`,
|
||||
[ids],
|
||||
);
|
||||
expect([...new Set(audit.map((row) => row.grant_id))].sort()).toEqual([...ids].sort());
|
||||
} finally {
|
||||
await safeEnd(inspector);
|
||||
}
|
||||
});
|
||||
|
||||
it("shuts down cleanly", async () => {
|
||||
await running.shutdown();
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — installed module permission end-to-end", () => {
|
||||
let database: { name: string; url: string };
|
||||
let modulesDirectory: string;
|
||||
|
||||
@@ -62,7 +62,7 @@ function fakeAuthorization(overrides: {
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
...overrides.store,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createAuthenticatedPrincipal,
|
||||
parseAuthorizationScope,
|
||||
parseEkklesiaUserId,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
@@ -50,7 +49,7 @@ function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationSt
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
...overrides,
|
||||
@@ -59,6 +58,7 @@ function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationSt
|
||||
|
||||
function buildApp(options: {
|
||||
readonly allowed?: boolean;
|
||||
readonly now?: () => Date;
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly store?: AuthorizationStore;
|
||||
}) {
|
||||
@@ -72,6 +72,7 @@ function buildApp(options: {
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
evaluator,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
registry: CORE_PERMISSION_REGISTRY,
|
||||
runtime,
|
||||
store,
|
||||
@@ -103,15 +104,20 @@ describe("GET /api/v1/grants", () => {
|
||||
userId: subjectUserId,
|
||||
},
|
||||
];
|
||||
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue(grants) });
|
||||
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue({ grants }) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
const body = (await response.json()) as { grants: unknown[] };
|
||||
const body = (await response.json()) as { grants: unknown[]; nextCursor: string | null };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.grants).toHaveLength(1);
|
||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { sourceKind: "explicit" });
|
||||
expect(body.nextCursor).toBeNull();
|
||||
// The default page size is applied server-side; `sourceKind` filtering stays server-side too.
|
||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||
limit: 50,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing or malformed userId query parameter", async () => {
|
||||
@@ -184,7 +190,6 @@ describe("POST /api/v1/grants", () => {
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
id: grantId,
|
||||
permission: registeredPermission,
|
||||
scope: parseAuthorizationScope("calendar-event:123"),
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
@@ -192,11 +197,7 @@ describe("POST /api/v1/grants", () => {
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({
|
||||
permission: registeredPermission,
|
||||
scope: "calendar-event:123",
|
||||
subjectUserId,
|
||||
}),
|
||||
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
@@ -208,11 +209,14 @@ describe("POST /api/v1/grants", () => {
|
||||
expect.objectContaining({
|
||||
actor: { type: "user", userId: manager.userId },
|
||||
permission: registeredPermission,
|
||||
scope: "calendar-event:123",
|
||||
sourceKind: "explicit",
|
||||
userId: subjectUserId,
|
||||
}),
|
||||
);
|
||||
// The generic HTTP surface creates unscoped explicit grants only.
|
||||
expect(store.createGrant).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ scope: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
|
||||
@@ -512,3 +516,366 @@ describe("Core grant mutation request body limit", () => {
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 3 request-contract regressions. Every case here asserts both the sanitized public response
|
||||
* *and* that the store was never reached, because the point of each fix is that a malformed or
|
||||
* unsupported request is decided at the request boundary rather than turning into database work or
|
||||
* an indistinguishable infrastructure failure.
|
||||
*/
|
||||
describe("explicit-grant request invariants", () => {
|
||||
const validExpiry = "2099-01-01T00:00:00.000Z";
|
||||
|
||||
async function post(app: Hono, body: unknown) {
|
||||
return await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects a scope field in any form, before any store mutation", async () => {
|
||||
// Core keeps scoped-grant semantics for trusted internal callers, but no HTTP middleware
|
||||
// supplies a scope to the evaluator, so a scope accepted here could never authorize anything.
|
||||
for (const scope of ["calendar-event:123", "", " ", null, 42, {}, []]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await post(app, { permission: registeredPermission, scope, subjectUserId });
|
||||
|
||||
expect(response.status, `scope ${JSON.stringify(scope)}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts exactly one canonical expiration representation", async () => {
|
||||
const createdGrant = {
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
expiresAt: new Date(validExpiry),
|
||||
id: grantId,
|
||||
permission: registeredPermission,
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await post(app, {
|
||||
expiresAt: validExpiry,
|
||||
permission: registeredPermission,
|
||||
subjectUserId,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(store.createGrant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expiresAt: new Date(validExpiry) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects normalized-impossible, locale, offset, imprecise, padded, and past expirations", async () => {
|
||||
const now = new Date("2026-07-26T12:00:00.000Z");
|
||||
for (const expiresAt of [
|
||||
// Normalized impossible date: `new Date` silently rewrote this to March 3rd.
|
||||
"2026-02-31T00:00:00.000Z",
|
||||
"2026-13-01T00:00:00.000Z",
|
||||
// Locale-shaped and date-only forms.
|
||||
"March 5, 2027",
|
||||
"2027-01-01",
|
||||
// Missing milliseconds, excess fractional precision, offsets, lowercase separators.
|
||||
"2027-01-01T00:00:00Z",
|
||||
"2027-01-01T00:00:00.123456Z",
|
||||
"2027-01-01T00:00:00.000+02:00",
|
||||
"2027-01-01t00:00:00.000z",
|
||||
"2027-01-01T00:00:00.000+00:00",
|
||||
// Whitespace padding and trailing text.
|
||||
" 2027-01-01T00:00:00.000Z",
|
||||
"2027-01-01T00:00:00.000Z ",
|
||||
"2027-01-01T00:00:00.000Z extra",
|
||||
// Past and exactly-now are both dead on arrival.
|
||||
"2020-01-01T00:00:00.000Z",
|
||||
"2026-07-26T12:00:00.000Z",
|
||||
// Non-strings.
|
||||
42,
|
||||
null,
|
||||
true,
|
||||
]) {
|
||||
const { app, store } = buildApp({ now: () => now });
|
||||
const response = await post(app, {
|
||||
expiresAt,
|
||||
permission: registeredPermission,
|
||||
subjectUserId,
|
||||
});
|
||||
|
||||
expect(response.status, `expiresAt ${JSON.stringify(expiresAt)}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a sanitized 409 for an equivalent grant that is already effective", async () => {
|
||||
const store = fakeStore({
|
||||
createGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await post(app, { permission: registeredPermission, subjectUserId });
|
||||
const raw = await response.text();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
// The body is the stable code and nothing else — no existing grant id, actor, scope, source
|
||||
// reference, expiry, SQL, constraint name, or driver text.
|
||||
expect(Object.keys(JSON.parse(raw))).toEqual(["code"]);
|
||||
for (const leaked of [
|
||||
grantId,
|
||||
manager.userId,
|
||||
subjectUserId,
|
||||
"calendar-event",
|
||||
"constraint",
|
||||
"select ",
|
||||
"insert ",
|
||||
"ekklesia_core_grant",
|
||||
"Error",
|
||||
"at ",
|
||||
]) {
|
||||
expect(raw, `must not leak ${leaked}`).not.toContain(leaked);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-canonical grant id on revoke without ever calling the store", async () => {
|
||||
for (const raw of [
|
||||
"not-a-uuid",
|
||||
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
|
||||
"%200b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
|
||||
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
|
||||
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f700",
|
||||
"0b36b1b69a294c4e9e2d4c0e4c5e6f70",
|
||||
]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(`/api/v1/grants/${raw}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status, `grantId ${raw}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("still returns the sanitized 404 for a canonical but unknown grant id", async () => {
|
||||
const store = fakeStore({
|
||||
revokeGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||
expect(store.revokeGrant).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit-grant listing pagination", () => {
|
||||
function grantAt(index: number, iso: string) {
|
||||
return {
|
||||
createdAt: new Date(iso),
|
||||
id: parseGrantId(`00000000-0000-4000-8000-${String(index).padStart(12, "0")}`),
|
||||
permission: registeredPermission,
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
}
|
||||
|
||||
it("applies the default limit of 50 and accepts the 1 and 100 boundaries", async () => {
|
||||
for (const [query, expected] of [
|
||||
["", 50],
|
||||
["&limit=1", 1],
|
||||
["&limit=50", 50],
|
||||
["&limit=100", 100],
|
||||
] as const) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}${query}`);
|
||||
|
||||
expect(response.status, query).toBe(200);
|
||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||
limit: expected,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects every non-canonical or out-of-range limit before any store work", async () => {
|
||||
for (const limit of [
|
||||
"0",
|
||||
"101",
|
||||
"-1",
|
||||
"+5",
|
||||
"1.0",
|
||||
"1.5",
|
||||
"01",
|
||||
"005",
|
||||
" 5",
|
||||
"5 ",
|
||||
"1e2",
|
||||
"",
|
||||
"abc",
|
||||
"0x10",
|
||||
"Infinity",
|
||||
"NaN",
|
||||
]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(
|
||||
`/api/v1/grants?userId=${subjectUserId}&limit=${encodeURIComponent(limit)}`,
|
||||
);
|
||||
|
||||
expect(response.status, `limit ${JSON.stringify(limit)}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown and duplicated query keys", async () => {
|
||||
for (const query of [
|
||||
`userId=${subjectUserId}&sort=asc`,
|
||||
`userId=${subjectUserId}&offset=10`,
|
||||
`userId=${subjectUserId}&sourceKind=relationship`,
|
||||
`userId=${subjectUserId}&userId=${subjectUserId}`,
|
||||
`userId=${subjectUserId}&limit=5&limit=6`,
|
||||
`userId=${subjectUserId}&cursor=a&cursor=b`,
|
||||
`userId=${subjectUserId}&cursor=`,
|
||||
]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(`/api/v1/grants?${query}`);
|
||||
|
||||
expect(response.status, query).toBe(400);
|
||||
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits a cursor only when another page exists, and traverses without duplicates or omissions", async () => {
|
||||
// Three rows sharing one `created_at`, which is exactly the case a timestamp-only keyset
|
||||
// boundary would skip or repeat.
|
||||
const sameInstant = "2026-01-01T00:00:00.000Z";
|
||||
const all = [grantAt(3, sameInstant), grantAt(2, sameInstant), grantAt(1, sameInstant)];
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
do {
|
||||
const store = fakeStore({
|
||||
listGrantsForUser: vi.fn().mockImplementation((_userId, listOptions) => {
|
||||
const startIndex =
|
||||
listOptions.after === undefined
|
||||
? 0
|
||||
: all.findIndex((grant) => grant.id === listOptions.after.id) + 1;
|
||||
const slice = all.slice(startIndex, startIndex + listOptions.limit);
|
||||
const more = startIndex + listOptions.limit < all.length;
|
||||
const last = slice.at(-1);
|
||||
return Promise.resolve({
|
||||
grants: slice,
|
||||
...(more && last ? { nextBoundary: { createdAt: last.createdAt, id: last.id } } : {}),
|
||||
});
|
||||
}),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
const url: string = `/api/v1/grants?userId=${subjectUserId}&limit=1${
|
||||
cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`
|
||||
}`;
|
||||
const response = await app.request(url);
|
||||
const body = (await response.json()) as {
|
||||
grants: { id: string }[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(body.grants).toHaveLength(1);
|
||||
seen.push(body.grants[0]?.id as string);
|
||||
cursor = body.nextCursor;
|
||||
pages += 1;
|
||||
} while (cursor !== null && pages < 10);
|
||||
|
||||
expect(pages).toBe(3);
|
||||
expect(seen).toEqual(all.map((grant) => grant.id));
|
||||
expect(new Set(seen).size).toBe(3);
|
||||
// The final page ends the traversal explicitly.
|
||||
expect(cursor).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a cursor issued for a different subject, and every malformed encoding", async () => {
|
||||
const boundary = { createdAt: new Date("2026-01-01T00:00:00.000Z"), id: grantId };
|
||||
const store = fakeStore({
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({
|
||||
grants: [grantAt(1, boundary.createdAt.toISOString())],
|
||||
nextBoundary: boundary,
|
||||
}),
|
||||
});
|
||||
const { app: issuingApp } = buildApp({ store });
|
||||
const issued = (await (
|
||||
await issuingApp.request(`/api/v1/grants?userId=${subjectUserId}&limit=1`)
|
||||
).json()) as { nextCursor: string };
|
||||
expect(issued.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/u);
|
||||
|
||||
const encode = (payload: unknown) =>
|
||||
Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||
const canonical = {
|
||||
g: grantId,
|
||||
t: "2026-01-01T00:00:00.000Z",
|
||||
u: subjectUserId,
|
||||
v: 1,
|
||||
};
|
||||
|
||||
for (const [label, cursor] of [
|
||||
// A cursor minted for a different subject must not be replayable against this one.
|
||||
["other subject", encode({ ...canonical, u: "someone_else_1" })],
|
||||
["wrong version", encode({ ...canonical, v: 2 })],
|
||||
["extra field", encode({ ...canonical, extra: "x" })],
|
||||
["missing field", encode({ g: canonical.g, t: canonical.t, u: canonical.u })],
|
||||
["invalid timestamp", encode({ ...canonical, t: "2026-01-01" })],
|
||||
["non-canonical timestamp", encode({ ...canonical, t: "2026-01-01T00:00:00Z" })],
|
||||
["invalid grant id", encode({ ...canonical, g: "not-a-uuid" })],
|
||||
["array payload", encode([canonical])],
|
||||
["not json", Buffer.from("nonsense", "utf8").toString("base64url")],
|
||||
["padded base64", `${Buffer.from(JSON.stringify(canonical), "utf8").toString("base64")}`],
|
||||
["malformed base64url", "!!!not-base64!!!"],
|
||||
["trailing data", `${encode(canonical)}AA`],
|
||||
] as const) {
|
||||
const { app, store: pagedStore } = buildApp({});
|
||||
const response = await app.request(
|
||||
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(cursor)}`,
|
||||
);
|
||||
const raw = await response.text();
|
||||
|
||||
expect(response.status, label).toBe(400);
|
||||
expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" });
|
||||
// A rejected cursor reveals nothing it decoded, and does zero store/database work.
|
||||
expect(raw).not.toContain(grantId);
|
||||
expect(raw).not.toContain("someone_else_1");
|
||||
expect(pagedStore.listGrantsForUser).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
// The cursor the API itself issued is accepted.
|
||||
const { app: acceptingApp, store: acceptingStore } = buildApp({});
|
||||
const accepted = await acceptingApp.request(
|
||||
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(issued.nextCursor)}`,
|
||||
);
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(acceptingStore.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||
after: boundary,
|
||||
limit: 50,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ function fakeStore(): AuthorizationStore {
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user