feat: add core authorization foundation
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user