Two related fail-closed gaps let a well-formed but never-declared permission id slip through: - POST /api/v1/grants created explicit grants for any well-formed PermissionId without checking the injected permission registry, allowing dormant grants for invented identifiers that could become effective the moment those identifiers were registered later. - createAuthorizationEvaluator validated the permission id only inside the restricted-baseline branch, so a malformed permission combined with the public or authenticated baseline could still return allowed. Both now fail closed: the grants route checks the target permission against the trusted registry before calling store.createGrant (sanitized 403, no store or audit write), and the evaluator validates permission shape before any baseline is allowed to return an allowed decision, without adding a registry dependency to the evaluator itself. The live integration lifecycle test switched from an unregistered calendar.events.read to the registered core.authorization.grants.manage permission, since module permission registration does not exist yet.
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import {
|
|
createAuthenticatedPrincipal,
|
|
parseAuthorizationScope,
|
|
parseEkklesiaUserId,
|
|
parseGrantId,
|
|
parsePermissionId,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationEvaluator,
|
|
type AuthorizationStore,
|
|
AuthorizationStoreError,
|
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
|
CORE_PERMISSION_REGISTRY,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import { Hono } from "hono";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createGrantsRoutes } from "../src/grants-routes.js";
|
|
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
const untrustedOrigin = "https://evil.example.test";
|
|
const manager = createAuthenticatedPrincipal("manager_1");
|
|
const subjectUserId = parseEkklesiaUserId("subject_1");
|
|
// Well-formed but never declared in `CORE_PERMISSION_REGISTRY` — used to prove the routes reject
|
|
// (or, for listing, still return) an unregistered permission id rather than assuming registration.
|
|
const permission = parsePermissionId("calendar.events.read");
|
|
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
|
|
// `store.createGrant`/`store.revokeGrant` can do so without also depending on module permission
|
|
// registration, which does not exist yet.
|
|
const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
|
|
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
|
|
|
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
|
return {
|
|
handleAuthRequest: vi.fn(),
|
|
resolvePrincipal: vi.fn().mockResolvedValue(manager),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationStore {
|
|
return {
|
|
createGrant: vi.fn(),
|
|
grantPlatformOwner: vi.fn(),
|
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
|
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
|
revokeGrant: vi.fn(),
|
|
revokePlatformOwner: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildApp(options: {
|
|
readonly allowed?: boolean;
|
|
readonly runtime?: EkklesiaIdentityRuntime;
|
|
readonly store?: AuthorizationStore;
|
|
}) {
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: options.allowed ?? true }),
|
|
};
|
|
const runtime = options.runtime ?? fakeRuntime();
|
|
const store = options.store ?? fakeStore();
|
|
const app = new Hono();
|
|
app.route(
|
|
"/",
|
|
createGrantsRoutes({
|
|
evaluator,
|
|
registry: CORE_PERMISSION_REGISTRY,
|
|
runtime,
|
|
store,
|
|
trustedOrigins: new Set([trustedOrigin]),
|
|
}),
|
|
);
|
|
return { app, evaluator, runtime, store };
|
|
}
|
|
|
|
describe("GET /api/v1/grants", () => {
|
|
it("denies an anonymous or non-manager authenticated caller (evaluator says no)", async () => {
|
|
const { app, evaluator } = buildApp({ allowed: false });
|
|
|
|
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(evaluator.authorize).toHaveBeenCalledWith(
|
|
expect.objectContaining({ baseline: "restricted" }),
|
|
);
|
|
});
|
|
|
|
it("allows a caller the evaluator authorizes — whether by platform ownership or a delegated grants.manage grant, the route cannot tell and does not need to (see core/tests/authorization-evaluator.test.ts for that distinction)", async () => {
|
|
const grants = [
|
|
{
|
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
id: grantId,
|
|
permission,
|
|
sourceKind: "explicit" as const,
|
|
userId: subjectUserId,
|
|
},
|
|
];
|
|
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[] };
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.grants).toHaveLength(1);
|
|
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { sourceKind: "explicit" });
|
|
});
|
|
|
|
it("rejects a missing or malformed userId query parameter", async () => {
|
|
const { app, store } = buildApp({});
|
|
|
|
const response = await app.request("/api/v1/grants?userId=");
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
|
const store = fakeStore({
|
|
listGrantsForUser: vi
|
|
.fn()
|
|
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED")),
|
|
});
|
|
const { app } = buildApp({ store });
|
|
|
|
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_LIST_FAILED" });
|
|
});
|
|
});
|
|
|
|
describe("POST /api/v1/grants", () => {
|
|
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
|
|
const { app, evaluator } = buildApp({});
|
|
|
|
const response = await app.request("/api/v1/grants", {
|
|
body: JSON.stringify({ permission, subjectUserId }),
|
|
headers: { "content-type": "application/json" },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an untrusted Origin header the same way", async () => {
|
|
const { app } = buildApp({});
|
|
|
|
const response = await app.request("/api/v1/grants", {
|
|
body: JSON.stringify({ permission, subjectUserId }),
|
|
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
});
|
|
|
|
it("denies a caller the evaluator does not authorize", async () => {
|
|
const { app, store } = buildApp({ allowed: false });
|
|
|
|
const response = await app.request("/api/v1/grants", {
|
|
body: JSON.stringify({ permission, subjectUserId }),
|
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(store.createGrant).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates an explicit grant, deriving sourceKind and actor server-side from the authenticated principal, never from the request body", async () => {
|
|
const createdGrant = {
|
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
id: grantId,
|
|
permission: registeredPermission,
|
|
scope: parseAuthorizationScope("calendar-event:123"),
|
|
sourceKind: "explicit" as const,
|
|
userId: subjectUserId,
|
|
};
|
|
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
|
|
const { app } = buildApp({ store });
|
|
|
|
const response = await app.request("/api/v1/grants", {
|
|
body: JSON.stringify({
|
|
permission: registeredPermission,
|
|
scope: "calendar-event:123",
|
|
subjectUserId,
|
|
}),
|
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
const body = (await response.json()) as { grant: { id: string } };
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(body.grant.id).toBe(grantId);
|
|
expect(store.createGrant).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
actor: { type: "user", userId: manager.userId },
|
|
permission: registeredPermission,
|
|
scope: "calendar-event:123",
|
|
sourceKind: "explicit",
|
|
userId: subjectUserId,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
|
|
const { app, store } = buildApp({});
|
|
|
|
const response = await app.request("/api/v1/grants", {
|
|
body: JSON.stringify({ permission, subjectUserId }),
|
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
expect(store.createGrant).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
"actor",
|
|
"owner",
|
|
"isOwner",
|
|
"sourceKind",
|
|
"sourceRef",
|
|
"id",
|
|
"grantId",
|
|
"createdAt",
|
|
"revokedAt",
|
|
"createdByActorId",
|
|
"effectivePermissions",
|
|
])("rejects a request body carrying a disallowed field (%s), even alongside otherwise-valid fields", async (field) => {
|
|
const { app, store } = buildApp({});
|
|
|
|
const response = await app.request("/api/v1/grants", {
|
|
body: JSON.stringify({ [field]: "hostile-value", permission, subjectUserId }),
|
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(store.createGrant).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a malformed permission, subject id, scope, or expiresAt", async () => {
|
|
const { app, store } = buildApp({});
|
|
|
|
const responses = await Promise.all(
|
|
[
|
|
{ permission: "not-namespaced", subjectUserId },
|
|
{ permission, subjectUserId: "" },
|
|
{ permission, scope: "", subjectUserId },
|
|
{ expiresAt: "not-a-date", permission, subjectUserId },
|
|
].map((body) =>
|
|
app.request("/api/v1/grants", {
|
|
body: JSON.stringify(body),
|
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
|
method: "POST",
|
|
}),
|
|
),
|
|
);
|
|
|
|
for (const response of responses) {
|
|
expect(response.status).toBe(400);
|
|
}
|
|
expect(store.createGrant).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
|
const store = fakeStore({
|
|
createGrant: vi
|
|
.fn()
|
|
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED")),
|
|
});
|
|
const { app } = buildApp({ store });
|
|
|
|
const response = await app.request("/api/v1/grants", {
|
|
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
|
|
});
|
|
});
|
|
|
|
describe("POST /api/v1/grants/:grantId/revoke", () => {
|
|
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
|
|
const { app, evaluator } = buildApp({});
|
|
|
|
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { method: "POST" });
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("revokes an explicit grant, deriving the actor server-side from the authenticated principal", async () => {
|
|
const store = fakeStore({ revokeGrant: vi.fn().mockResolvedValue(undefined) });
|
|
const { app } = buildApp({ store });
|
|
|
|
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ success: true });
|
|
expect(store.revokeGrant).toHaveBeenCalledWith({
|
|
actor: { type: "user", userId: manager.userId },
|
|
grantId,
|
|
sourceKind: "explicit",
|
|
});
|
|
});
|
|
|
|
it("always constrains revocation to sourceKind explicit, so a relationship-derived grant can never be targeted by id", async () => {
|
|
// The store itself enforces this by treating a sourceKind mismatch exactly like a missing
|
|
// grant; this test proves the route always asks for that constraint, and correctly surfaces
|
|
// the resulting "not found" as 404 rather than leaking that the grant exists but is the wrong
|
|
// kind.
|
|
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).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sourceKind: "explicit" }),
|
|
);
|
|
});
|
|
|
|
it("rejects a malformed grant id", async () => {
|
|
const { app, store } = buildApp({});
|
|
|
|
const response = await app.request("/api/v1/grants/%20/revoke", {
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(store.revokeGrant).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
|
const store = fakeStore({
|
|
revokeGrant: vi
|
|
.fn()
|
|
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED")),
|
|
});
|
|
const { app } = buildApp({ store });
|
|
|
|
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
|
});
|
|
});
|