Establishes the one canonical PermissionDeclaration contract (packages/contracts/src/authorization.ts) — id/baseline/description, strict, unknown-property-rejecting — consumed by both Core's authorization registry and module manifests, replacing Core's previously separate interface. Bumps the canonical module manifest to manifestVersion 3: permissions become complete declarations instead of bare identifier strings, with manifest-owned namespace and duplicate-id enforcement. Version 2 and bare string-array permissions are rejected outright; there is no compatibility adapter. Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts), a pure, synchronous composer that builds one platform permission registry from Core's own declarations plus every already-discovered module's, in deterministic order, reusing createPermissionRegistry and rejecting any module-contributed permission in the reserved core.* namespace. core/src/authorization remains independent of core/src/modules; only the module integration layer depends on authorization. Updates the module template, module-development docs, authorization docs, and existing manifest fixtures for manifestVersion 3. Does not wire the combined registry into apps/api — that is pass 2.
159 lines
7.0 KiB
TypeScript
159 lines
7.0 KiB
TypeScript
import { z } from "zod";
|
|
import { permissionIdSchema } from "./identifiers.js";
|
|
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>;
|
|
|
|
/**
|
|
* The one authoritative shape of a *registered* permission declaration — what both Core's
|
|
* authorization registry (`core/src/authorization/permissions.ts`) and a module manifest's
|
|
* `permissions` array parse into, so a permission a module declares and a permission Core's
|
|
* registry enforces are provably the same structure, never two independently maintained
|
|
* interfaces that could quietly drift apart. `.strict()` rejects any property beyond these three —
|
|
* there is no room for a module or a future caller to smuggle extra, unvalidated metadata into a
|
|
* declaration Core treats as trusted.
|
|
*
|
|
* This schema says nothing about *registration*: parsing a value into a `PermissionDeclaration`
|
|
* only proves it is shaped correctly, not that any registry has accepted it. Registration remains
|
|
* exclusively `PermissionRegistry`'s responsibility (`core/src/authorization/permissions.ts`) —
|
|
* built by composing exactly the declarations a trusted caller supplies, Core's own plus every
|
|
* discovered module's.
|
|
*/
|
|
export const permissionDeclarationSchema = z
|
|
.object({
|
|
id: permissionIdSchema,
|
|
baseline: permissionBaselineSchema,
|
|
/** Operator/audit-facing text only. Authorization never reads, parses, or otherwise
|
|
* interprets this value — changing it can never change what a permission allows. */
|
|
description: z.string().trim().min(1).max(240),
|
|
})
|
|
.strict();
|
|
|
|
export type PermissionDeclarationInput = z.input<typeof permissionDeclarationSchema>;
|
|
export type PermissionDeclaration = z.output<typeof permissionDeclarationSchema>;
|
|
|
|
export function parsePermissionDeclaration(input: unknown): PermissionDeclaration {
|
|
return permissionDeclarationSchema.parse(input);
|
|
}
|
|
|
|
export function safeParsePermissionDeclaration(
|
|
input: unknown,
|
|
): z.ZodSafeParseResult<PermissionDeclaration> {
|
|
return permissionDeclarationSchema.safeParse(input);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|