feat: add core authorization foundation

This commit is contained in:
ekklesia
2026-07-23 10:29:01 -05:00
parent 3fa1c4cdec
commit 72e082ff2f
23 changed files with 3029 additions and 3 deletions
+119
View File
@@ -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;
}
+21 -1
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export * from "./authorization.js";
export * from "./identifiers.js";
export * from "./identity.js";
export * from "./module-lifecycle.js";