Files
Ekklesia/core/tests/authorization-evaluator.test.ts
T
ekklesia df2af0a3e0
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 11s
CI / migration-smoke (pull_request) Successful in 27s
fix: enforce registered authorization permissions
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.
2026-07-23 11:44:45 -05:00

545 lines
19 KiB
TypeScript

import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
parseAuthorizationScope,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type ActiveGrantQuery,
AuthorizationEvaluationError,
type AuthorizationGrantPort,
type AuthorizationPlatformOwnerPort,
type AuthorizeRequest,
createAuthorizationEvaluator,
} from "../src/authorization/index.js";
interface FakeGrant {
readonly userId: string;
readonly permission: string;
readonly scope?: string;
readonly expiresAt?: Date;
readonly revokedAt?: Date;
}
/** An in-memory double implementing the exact scope/expiry/revocation matching semantics the real
* Postgres store implements in SQL, so the evaluator's own decision logic can be exercised in
* isolation. The live integration suite separately proves the SQL performs the same matching. */
class FakeGrantStore implements AuthorizationGrantPort {
readonly grants: FakeGrant[] = [];
readonly calls: ActiveGrantQuery[] = [];
async hasActiveGrant(query: ActiveGrantQuery): Promise<boolean> {
this.calls.push(query);
return this.grants.some((grant) => {
if (grant.userId !== query.userId || grant.permission !== query.permission) {
return false;
}
if (grant.revokedAt !== undefined) {
return false;
}
if (grant.expiresAt !== undefined && grant.expiresAt <= query.now) {
return false;
}
if (grant.scope === undefined) {
return true; // an unscoped grant satisfies any request for the same permission
}
return grant.scope === query.scope; // a scoped grant never satisfies an unscoped request
});
}
}
class FakeOwnerStore implements AuthorizationPlatformOwnerPort {
private readonly owners = new Set<string>();
readonly calls: string[] = [];
makeOwner(userId: string): void {
this.owners.add(userId);
}
async isPlatformOwner(userId: string): Promise<boolean> {
this.calls.push(userId);
return this.owners.has(userId);
}
}
const userId = parseEkklesiaUserId("user_1");
const otherUserId = parseEkklesiaUserId("user_2");
const permission = parsePermissionId("calendar.events.read");
const scope = parseAuthorizationScope("calendar-event:123");
const otherScope = parseAuthorizationScope("calendar-event:456");
describe("Core authorization evaluator", () => {
let grants: FakeGrantStore;
let owners: FakeOwnerStore;
beforeEach(() => {
grants = new FakeGrantStore();
owners = new FakeOwnerStore();
});
function evaluator(now?: () => Date) {
return createAuthorizationEvaluator({
grants,
platformOwners: owners,
...(now ? { now } : {}),
});
}
describe("public baseline", () => {
it("allows an anonymous principal", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: true });
});
it("allows an authenticated principal", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("never queries grants or platform-owner state for a public check", async () => {
await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an anonymous principal when the permission id is malformed, without throwing or querying grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission: "not a valid permission id",
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal when the permission id is malformed", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("authenticated baseline", () => {
it("allows an authenticated principal", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("rejects an anonymous principal", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
});
it("never queries grants or platform-owner state for an authenticated check", async () => {
await evaluator().authorize({
baseline: "authenticated",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal when the permission id is malformed, without throwing or querying grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("still denies an anonymous principal when the permission id is malformed too", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission: "not a valid permission id",
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("restricted baseline", () => {
it("denies an anonymous principal without ever consulting grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal with no grant and no ownership", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("allows an authenticated principal holding an active explicit grant", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("allows an authenticated principal holding an active relationship-derived grant", async () => {
// The evaluator only distinguishes "active grant or not" through the port; source kind is a
// store/audit concern, not an evaluation input — proven here by a grant the fake store
// exposes with no source-kind field at all still being honored.
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("matches permissions exactly and does not treat a namespace prefix as inherited authority", async () => {
grants.grants.push({ permission: parsePermissionId("calendar.events.read.detail"), userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies a grant that belongs to a different user", async () => {
grants.grants.push({ permission, userId: otherUserId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("lets an unscoped grant satisfy an unscoped request", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("lets an unscoped grant satisfy a scoped request for the same permission", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("lets a scoped grant satisfy a request for the exact matching scope", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("denies a scoped grant against a different scope", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope: otherScope,
});
expect(decision).toEqual({ allowed: false });
});
it("never lets a scoped grant satisfy an unscoped request", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies an expired grant", async () => {
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
grants.grants.push({
expiresAt: new Date("2025-12-31T00:00:00.000Z"),
permission,
userId,
});
const decision = await evaluator(() => fixedNow).authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("allows a grant that has not yet expired, evaluated deterministically against an injected clock", async () => {
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
grants.grants.push({
expiresAt: new Date("2026-01-02T00:00:00.000Z"),
permission,
userId,
});
const decision = await evaluator(() => fixedNow).authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("denies a revoked grant even though it has not expired", async () => {
grants.grants.push({
expiresAt: new Date(Date.now() + 86_400_000),
permission,
revokedAt: new Date(),
userId,
});
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies an unknown or malformed permission without throwing", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
});
it("denies a wildcard-shaped permission without throwing", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: "media.*",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies a non-string permission (number, object, null, undefined) without throwing", async () => {
for (const malformed of [42, {}, null, undefined, ["calendar.events.read"]]) {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: malformed,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
}
});
it("denies a malformed scope without throwing", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope: "",
});
expect(decision).toEqual({ allowed: false });
});
it("allows a platform owner with no matching grant at all", async () => {
owners.makeOwner(userId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("allows a platform owner for a scoped restricted permission with no scoped grant", async () => {
owners.makeOwner(userId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("does not grant ownership authority to a different user", async () => {
owners.makeOwner(otherUserId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
});
describe("an unknown or malformed baseline", () => {
it("denies rather than defaulting to any known baseline", async () => {
for (const malformed of ["superuser", "", 42, null, undefined, "Public", "PUBLIC"]) {
const decision = await evaluator().authorize({
baseline: malformed,
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
}
});
});
describe("a client cannot inject owner or grant state", () => {
it("ignores extra caller-supplied fields such as isOwner/hasGrant and re-derives authority from the injected ports", async () => {
// Simulates a caller that smuggles extra fields the AuthorizeRequest type does not
// declare — going through `unknown` rather than `any` proves the object still satisfies
// AuthorizeRequest structurally; the extra fields are simply never read.
const hostileRequest = {
baseline: "restricted",
hasGrant: true,
isOwner: true,
permission,
principal: createAuthenticatedPrincipal(userId),
} as unknown as AuthorizeRequest;
const decision = await evaluator().authorize(hostileRequest);
expect(decision).toEqual({ allowed: false });
// The evaluator still had to actually ask the injected ports — it did not shortcut on the
// hostile flags.
expect(grants.calls).toHaveLength(1);
expect(owners.calls).toHaveLength(1);
});
});
describe("decision contract", () => {
it("returns a frozen, minimal decision with no extra fields", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(Object.isFrozen(decision)).toBe(true);
expect(Object.keys(decision)).toEqual(["allowed"]);
});
});
describe("sanitized failures", () => {
it("propagates a genuine grant-lookup infrastructure failure rather than silently denying", async () => {
const failingGrants: AuthorizationGrantPort = {
hasActiveGrant: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
};
const failingEvaluator = createAuthorizationEvaluator({
grants: failingGrants,
platformOwners: owners,
});
await expect(
failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
}),
).rejects.toMatchObject({
code: "AUTHORIZATION_EVALUATION_FAILED",
});
});
it("propagates a genuine platform-owner lookup infrastructure failure rather than silently denying", async () => {
const failingOwners: AuthorizationPlatformOwnerPort = {
isPlatformOwner: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
};
const failingEvaluator = createAuthorizationEvaluator({
grants,
platformOwners: failingOwners,
});
await expect(
failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
}),
).rejects.toBeInstanceOf(AuthorizationEvaluationError);
});
it("never leaks a raw infrastructure error message through the thrown error", async () => {
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
const failingGrants: AuthorizationGrantPort = {
hasActiveGrant: vi.fn().mockRejectedValue(new Error(hostileMessage)),
};
const failingEvaluator = createAuthorizationEvaluator({
grants: failingGrants,
platformOwners: owners,
});
let captured: unknown;
try {
await failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
} catch (error) {
captured = error;
}
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain("hunter2");
expect(publicText).not.toContain("db.internal.example");
});
});
});