Files
Ekklesia/core/tests/authorization-evaluator.test.ts
T

501 lines
17 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);
});
});
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);
});
});
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");
});
});
});