381 lines
14 KiB
TypeScript
381 lines
14 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. This suite exercises `createGrantsRoutes` in
|
|
// isolation and intentionally injects `CORE_PERMISSION_REGISTRY` directly rather than a composed
|
|
// platform registry — the routes have no notion of "Core" versus "module" permissions, they only
|
|
// ever consult whichever registry they are given. Coverage proving a module-declared permission
|
|
// works through the exact same route code lives in
|
|
// `apps/api/tests/authorization-middleware.test.ts` (unit) and the "installed module permission
|
|
// end-to-end" scenario in `apps/api/tests/api-http.integration.ts` (live).
|
|
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" });
|
|
});
|
|
});
|