feat: add core authorization foundation
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { z } from "zod";
|
||||
import type { EkklesiaUserId } from "./identity.js";
|
||||
import { parseEkklesiaUserId } from "./identity.js";
|
||||
|
||||
/**
|
||||
* Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId` and
|
||||
* `PermissionId`: a plain `string` is not assignable to `GrantId` without passing through
|
||||
* `parseGrantId`/`safeParseGrantId`. Ekklesia does not assume any particular format; Core's
|
||||
* Postgres-backed store happens to generate a UUID, but callers must treat the value as opaque.
|
||||
*/
|
||||
export const grantIdSchema = z.string().trim().min(1).max(128).brand<"GrantId">();
|
||||
|
||||
export type GrantId = z.infer<typeof grantIdSchema>;
|
||||
|
||||
export function parseGrantId(input: unknown): GrantId {
|
||||
return grantIdSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParseGrantId(input: unknown): z.ZodSafeParseResult<GrantId> {
|
||||
return grantIdSchema.safeParse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic, Core-opaque resource scope narrowing a grant or an authorization check to one
|
||||
* resource (for example a specific calendar event or media item). Core never interprets the
|
||||
* value beyond exact string equality — the owning module defines what a scope string means, if
|
||||
* anything. Not branded: unlike a permission or a grant, a scope carries no identity of its own
|
||||
* within Core; it is simply an opaque comparison key.
|
||||
*/
|
||||
export const authorizationScopeSchema = z.string().trim().min(1).max(256);
|
||||
|
||||
export type AuthorizationScope = z.infer<typeof authorizationScopeSchema>;
|
||||
|
||||
export function parseAuthorizationScope(input: unknown): AuthorizationScope {
|
||||
return authorizationScopeSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParseAuthorizationScope(
|
||||
input: unknown,
|
||||
): z.ZodSafeParseResult<AuthorizationScope> {
|
||||
return authorizationScopeSchema.safeParse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every permission declaration has exactly one baseline. `public` allows any principal, anonymous
|
||||
* or authenticated. `authenticated` requires a valid authenticated principal and nothing more.
|
||||
* `restricted` requires an active matching grant or platform-owner authority — baseline alone
|
||||
* never satisfies it.
|
||||
*/
|
||||
export const permissionBaselineSchema = z.enum(["public", "authenticated", "restricted"]);
|
||||
|
||||
export type PermissionBaseline = z.infer<typeof permissionBaselineSchema>;
|
||||
|
||||
/**
|
||||
* Distinguishes a grant an operator/system created directly (`explicit`) from one a module
|
||||
* created as a side effect of its own business operation, attributed to some source the module
|
||||
* understands (`relationship`) — for example adding a person to a volunteer roster. Core never
|
||||
* interprets which relationship produced the grant; it only records the distinction and an
|
||||
* opaque source reference for audit purposes.
|
||||
*/
|
||||
export const grantSourceKindSchema = z.enum(["explicit", "relationship"]);
|
||||
|
||||
export type GrantSourceKind = z.infer<typeof grantSourceKindSchema>;
|
||||
|
||||
/** A Core-opaque reference to whatever produced a `relationship` grant (or, optionally, extra
|
||||
* context for an `explicit` one) — for example a module-defined identifier. Never interpreted by
|
||||
* Core, never a foreign key into another module's tables. */
|
||||
export const grantSourceRefSchema = z.string().trim().min(1).max(256);
|
||||
|
||||
export type GrantSourceRef = z.infer<typeof grantSourceRefSchema>;
|
||||
|
||||
const authorizationActorTypeSchema = z.enum(["user", "system"]);
|
||||
|
||||
export type AuthorizationActorType = z.infer<typeof authorizationActorTypeSchema>;
|
||||
|
||||
export interface AuthorizationUserActor {
|
||||
readonly type: "user";
|
||||
readonly userId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
export interface AuthorizationSystemActor {
|
||||
readonly type: "system";
|
||||
}
|
||||
|
||||
/**
|
||||
* Who performed a security-relevant authorization mutation (a grant or platform-owner change).
|
||||
* Mirrors `EkklesiaPrincipal`'s discriminated-union style. `system` covers Core-internal or
|
||||
* automated actions with no specific human actor (for example a future bootstrap command run by
|
||||
* an operator with no prior Ekklesia identity); it never means "unauthenticated" or "anonymous
|
||||
* caller" — every mutating call site chooses this deliberately.
|
||||
*/
|
||||
export type AuthorizationActor = AuthorizationUserActor | AuthorizationSystemActor;
|
||||
|
||||
export const SYSTEM_ACTOR: AuthorizationSystemActor = Object.freeze({ type: "system" });
|
||||
|
||||
export function createUserActor(userId: unknown): AuthorizationUserActor {
|
||||
return Object.freeze({ type: "user", userId: parseEkklesiaUserId(userId) });
|
||||
}
|
||||
|
||||
export function isUserActor(actor: AuthorizationActor): actor is AuthorizationUserActor {
|
||||
return actor.type === "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* The only public result of an authorization check. Deliberately minimal and frozen: it never
|
||||
* carries a reason, a matched grant, a permission echo, or any other detail a caller could use to
|
||||
* infer another user's grants or Core's internal evaluation path. `AUTHORIZATION_ALLOWED` and
|
||||
* `AUTHORIZATION_DENIED` are the only two values ever produced.
|
||||
*/
|
||||
export interface AuthorizationDecision {
|
||||
readonly allowed: boolean;
|
||||
}
|
||||
|
||||
export const AUTHORIZATION_ALLOWED: AuthorizationDecision = Object.freeze({ allowed: true });
|
||||
export const AUTHORIZATION_DENIED: AuthorizationDecision = Object.freeze({ allowed: false });
|
||||
|
||||
export function isAuthorizationAllowed(decision: AuthorizationDecision): boolean {
|
||||
return decision.allowed;
|
||||
}
|
||||
@@ -16,11 +16,23 @@ export const capabilityIdSchema = z
|
||||
.max(160)
|
||||
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier.");
|
||||
|
||||
/**
|
||||
* Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId`: a plain
|
||||
* `string` is not assignable to `PermissionId` without passing through
|
||||
* `parsePermissionId`/`safeParsePermissionId`. The brand adds no runtime tag or wrapper — a value
|
||||
* still serializes as a plain string. This is the one permission-identifier type shared by module
|
||||
* manifest declarations (`moduleManifestSchema`'s `permissions` array) and Core's authorization
|
||||
* evaluator, so a permission a module declares and a permission Core evaluates are provably the
|
||||
* same identifier space. Namespacing (the required `.`-segments) is purely a naming convention for
|
||||
* readability and collision-avoidance — it never implies hierarchy or inheritance during
|
||||
* evaluation, and wildcard segments are not part of this format at all.
|
||||
*/
|
||||
export const permissionIdSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(160)
|
||||
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.");
|
||||
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.")
|
||||
.brand<"PermissionId">();
|
||||
|
||||
export const logicalBucketIdSchema = z
|
||||
.string()
|
||||
@@ -58,3 +70,11 @@ export type CapabilityId = z.infer<typeof capabilityIdSchema>;
|
||||
export type PermissionId = z.infer<typeof permissionIdSchema>;
|
||||
export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>;
|
||||
export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>;
|
||||
|
||||
export function parsePermissionId(input: unknown): PermissionId {
|
||||
return permissionIdSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParsePermissionId(input: unknown): z.ZodSafeParseResult<PermissionId> {
|
||||
return permissionIdSchema.safeParse(input);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./authorization.js";
|
||||
export * from "./identifiers.js";
|
||||
export * from "./identity.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AUTHORIZATION_ALLOWED,
|
||||
AUTHORIZATION_DENIED,
|
||||
type AuthorizationActor,
|
||||
type AuthorizationDecision,
|
||||
createUserActor,
|
||||
type GrantId,
|
||||
isAuthorizationAllowed,
|
||||
isUserActor,
|
||||
parseAuthorizationScope,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
type PermissionId,
|
||||
safeParseAuthorizationScope,
|
||||
safeParseGrantId,
|
||||
safeParsePermissionId,
|
||||
SYSTEM_ACTOR,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("PermissionId", () => {
|
||||
it("parses a valid lowercase dot-namespaced permission id", () => {
|
||||
expect(parsePermissionId("calendar.events.read")).toBe("calendar.events.read");
|
||||
});
|
||||
|
||||
it("is opaque at the type level: a plain string is not assignable without parsing", () => {
|
||||
// @ts-expect-error a bare string must not satisfy the branded PermissionId type.
|
||||
const notAllowed: PermissionId = "calendar.events.read";
|
||||
expect(notAllowed).toBe("calendar.events.read");
|
||||
});
|
||||
|
||||
it("rejects a non-namespaced, empty, oversized, or non-string permission id", () => {
|
||||
expect(() => parsePermissionId("read")).toThrow();
|
||||
expect(() => parsePermissionId("")).toThrow();
|
||||
expect(() => parsePermissionId("Calendar.Events.Read")).toThrow();
|
||||
expect(() => parsePermissionId("x".repeat(161))).toThrow();
|
||||
expect(() => parsePermissionId(42)).toThrow();
|
||||
expect(() => parsePermissionId(null)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a wildcard segment: there is no such thing as a wildcard permission", () => {
|
||||
expect(() => parsePermissionId("media.*")).toThrow();
|
||||
expect(() => parsePermissionId("media.*.read")).toThrow();
|
||||
expect(() => parsePermissionId("*")).toThrow();
|
||||
});
|
||||
|
||||
it("exposes a safe-parse variant that never throws", () => {
|
||||
expect(safeParsePermissionId("calendar.events.read").success).toBe(true);
|
||||
expect(safeParsePermissionId("*").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GrantId", () => {
|
||||
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
|
||||
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||
expect(id).toBe("11111111-1111-1111-1111-111111111111");
|
||||
expect(() => parseGrantId("")).toThrow();
|
||||
expect(() => parseGrantId("x".repeat(129))).toThrow();
|
||||
});
|
||||
|
||||
it("exposes a safe-parse variant that never throws", () => {
|
||||
expect(safeParseGrantId("abc").success).toBe(true);
|
||||
expect(safeParseGrantId("").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorizationScope", () => {
|
||||
it("accepts a non-empty opaque scope and rejects empty/whitespace/oversized scope", () => {
|
||||
expect(parseAuthorizationScope("calendar-event:123")).toBe("calendar-event:123");
|
||||
expect(safeParseAuthorizationScope("").success).toBe(false);
|
||||
expect(safeParseAuthorizationScope(" ").success).toBe(false);
|
||||
expect(safeParseAuthorizationScope("x".repeat(257)).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorizationActor", () => {
|
||||
it("creates a frozen user actor carrying only the opaque user id", () => {
|
||||
const actor = createUserActor("user_123");
|
||||
expect(actor).toEqual({ type: "user", userId: "user_123" });
|
||||
expect(Object.isFrozen(actor)).toBe(true);
|
||||
expect(isUserActor(actor)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid user id when creating a user actor", () => {
|
||||
expect(() => createUserActor("")).toThrow();
|
||||
expect(() => createUserActor(42)).toThrow();
|
||||
});
|
||||
|
||||
it("exposes a frozen system actor singleton distinguishable from a user actor", () => {
|
||||
expect(SYSTEM_ACTOR).toEqual({ type: "system" });
|
||||
expect(Object.isFrozen(SYSTEM_ACTOR)).toBe(true);
|
||||
expect(isUserActor(SYSTEM_ACTOR)).toBe(false);
|
||||
});
|
||||
|
||||
it("discriminates actors on type without leaking extra fields", () => {
|
||||
const actors: AuthorizationActor[] = [SYSTEM_ACTOR, createUserActor("user_123")];
|
||||
for (const actor of actors) {
|
||||
expect(["user", "system"]).toContain(actor.type);
|
||||
}
|
||||
expect(Object.keys(SYSTEM_ACTOR)).toEqual(["type"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorizationDecision", () => {
|
||||
it("exposes exactly two frozen, minimal decision singletons", () => {
|
||||
expect(AUTHORIZATION_ALLOWED).toEqual({ allowed: true });
|
||||
expect(AUTHORIZATION_DENIED).toEqual({ allowed: false });
|
||||
expect(Object.isFrozen(AUTHORIZATION_ALLOWED)).toBe(true);
|
||||
expect(Object.isFrozen(AUTHORIZATION_DENIED)).toBe(true);
|
||||
expect(Object.keys(AUTHORIZATION_ALLOWED)).toEqual(["allowed"]);
|
||||
expect(Object.keys(AUTHORIZATION_DENIED)).toEqual(["allowed"]);
|
||||
});
|
||||
|
||||
it("never carries a reason, matched grant, permission echo, or any other detail", () => {
|
||||
const decisions: AuthorizationDecision[] = [AUTHORIZATION_ALLOWED, AUTHORIZATION_DENIED];
|
||||
for (const decision of decisions) {
|
||||
expect(Object.keys(decision)).not.toContain("reason");
|
||||
expect(Object.keys(decision)).not.toContain("grantId");
|
||||
expect(Object.keys(decision)).not.toContain("permission");
|
||||
expect(Object.keys(decision)).not.toContain("code");
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a boolean helper matching the allowed field", () => {
|
||||
expect(isAuthorizationAllowed(AUTHORIZATION_ALLOWED)).toBe(true);
|
||||
expect(isAuthorizationAllowed(AUTHORIZATION_DENIED)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user