ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 <[email protected]>
223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
import {
|
|
type PermissionDeclaration,
|
|
type PermissionId,
|
|
parsePermissionId,
|
|
safeParsePermissionId,
|
|
} from "@ekklesia/contracts";
|
|
|
|
/**
|
|
* Core's registry consumes the one shared `PermissionDeclaration` shape
|
|
* (`packages/contracts/src/authorization.ts`) rather than declaring its own interface — a module
|
|
* manifest's `permissions` array parses into the exact same type, so a declaration a module ships
|
|
* and a declaration Core registers are provably interchangeable. Re-exported here so existing
|
|
* `@ekklesia/core/authorization` consumers keep importing it from this package.
|
|
*/
|
|
export type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts";
|
|
|
|
export interface PermissionRegistry {
|
|
readonly declarations: readonly PermissionDeclaration[];
|
|
/** Returns the declaration for a permission id, or `undefined` for anything malformed or not
|
|
* registered — the two are deliberately indistinguishable to callers, so "unknown permission"
|
|
* and "invalid permission" fail closed the same way. */
|
|
get(permissionId: unknown): PermissionDeclaration | undefined;
|
|
has(permissionId: unknown): boolean;
|
|
}
|
|
|
|
export type PermissionRegistryErrorCode = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
|
|
|
|
/** Thrown only at composition time (registry construction), never per-request. Carries the
|
|
* offending permission id deliberately — this is a startup-time programmer/configuration error
|
|
* message, not a runtime response, and a namespaced permission id is not sensitive. */
|
|
export class PermissionRegistryError extends Error {
|
|
readonly code: PermissionRegistryErrorCode;
|
|
readonly permissionId: string;
|
|
|
|
constructor(permissionId: string) {
|
|
super(`Permission "${permissionId}" is already registered.`);
|
|
this.name = "PermissionRegistryError";
|
|
this.code = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
|
|
this.permissionId = permissionId;
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
Object.freeze(this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds an immutable permission registry from a fixed list of declarations, rejecting any
|
|
* duplicate permission id outright at composition time. Deliberately the smallest mechanism that
|
|
* distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no
|
|
* network or filesystem I/O. The module integration layer's composer
|
|
* (`composePlatformPermissionRegistry`, `core/src/modules/permissions.ts`) merges Core's own
|
|
* declarations with every discovered module's into one combined list and calls this same
|
|
* constructor, inheriting duplicate detection for free rather than reimplementing it.
|
|
*/
|
|
export function createPermissionRegistry(
|
|
declarations: readonly PermissionDeclaration[],
|
|
): PermissionRegistry {
|
|
const byId = new Map<PermissionId, PermissionDeclaration>();
|
|
for (const declaration of declarations) {
|
|
if (byId.has(declaration.id)) {
|
|
throw new PermissionRegistryError(declaration.id);
|
|
}
|
|
byId.set(declaration.id, Object.freeze({ ...declaration }));
|
|
}
|
|
const frozenDeclarations = Object.freeze([...byId.values()]);
|
|
|
|
return Object.freeze({
|
|
declarations: frozenDeclarations,
|
|
get(permissionId: unknown): PermissionDeclaration | undefined {
|
|
const parsed = safeParsePermissionId(permissionId);
|
|
return parsed.success ? byId.get(parsed.data) : undefined;
|
|
},
|
|
has(permissionId: unknown): boolean {
|
|
const parsed = safeParsePermissionId(permissionId);
|
|
return parsed.success && byId.has(parsed.data);
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Restricted: delegated grant administration is itself a privileged action, never public or
|
|
* merely authenticated. A platform owner always passes the evaluator's existing owner bypass and
|
|
* can delegate this permission to another account deliberately via the explicit-grant API. */
|
|
export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
|
"core.authorization.grants.manage",
|
|
);
|
|
|
|
/**
|
|
* Authenticated: any signed-in user of this installation may learn which module UIs the platform is
|
|
* currently hosting, because that is exactly what a browser shell has to render before it can offer
|
|
* anything at all. It is deliberately not `public` — the anonymous artifact route (ADR 0025) is
|
|
* careful never to become an enumeration oracle, and an anonymous inventory would hand over the very
|
|
* listing that route withholds. It is deliberately not `restricted` either: a shell that rendered
|
|
* nothing until an operator granted every user this permission would make the platform unusable for
|
|
* its ordinary users, and the module UIs themselves already gate their own content on their own
|
|
* permissions.
|
|
*
|
|
* Listing a module UI is not permission to use that module. Every module route remains enforced
|
|
* against its own declared permission, unchanged.
|
|
*/
|
|
export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read");
|
|
|
|
/**
|
|
* Restricted: reorganizing the Dashboard is an administrative action, not something every signed-in
|
|
* user may do. It is deliberately a **platform** permission and never a module's own — grouping is
|
|
* Ekklesia's presentation configuration, so no module may gate, grant, or influence it (ADR 0027).
|
|
*
|
|
* Holding it confers no module authority whatsoever. Moving a module into a folder changes a
|
|
* presentation row; it grants nobody access to that module's data, and every module route stays
|
|
* enforced against its own declared permission exactly as before.
|
|
*/
|
|
export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId(
|
|
"core.dashboard.navigation.manage",
|
|
);
|
|
|
|
/**
|
|
* Restricted, and it gates **reading as well as changing**. Deciding which installed modules run is
|
|
* an administrative operation, and the read is not a milder version of it: the lifecycle view is the
|
|
* only place a *disabled* module, a *blocked* module, and the required-dependency edges between them
|
|
* are visible at all. `core.modules.ui.read` deliberately exposes the mountable set to every signed-in
|
|
* user; this exposes the parts of the installation that are deliberately not running, which is not
|
|
* the same audience. A second, weaker read permission over the same resource would be ceremony
|
|
* (ADR 0027's reasoning, applied in the other direction).
|
|
*
|
|
* It is a **platform** permission and never a module's. A module can neither declare it, gate it, nor
|
|
* observe it: whether a module runs is a decision its host makes about it, not a decision it takes
|
|
* part in (ADR 0028). Holding it confers no authority inside any module — every module route stays
|
|
* enforced against that module's own declared permission.
|
|
*/
|
|
export const CORE_MODULES_LIFECYCLE_MANAGE: PermissionId = parsePermissionId(
|
|
"core.modules.lifecycle.manage",
|
|
);
|
|
|
|
/**
|
|
* Restricted, and deliberately **separate from `core.modules.lifecycle.manage`**. The two authorize
|
|
* genuinely different actions: lifecycle decides *whether a module runs at all*, while this decides
|
|
* *how a running module behaves*. Someone trusted to tune a slideshow's timing need not be trusted
|
|
* to take modules offline, and the reverse is equally true, so folding them into one permission
|
|
* would grant authority nobody asked for in whichever direction it was used (ADR 0029).
|
|
*
|
|
* It gates reading as well as writing, because the read returns the module's declared schema
|
|
* together with every stored value — endpoints, bucket names, and whatever else an operator has
|
|
* configured. That is deployment detail, not something every signed-in user needs.
|
|
*
|
|
* **It never exposes secret material.** A `secret` key's value is deployment-supplied, is never
|
|
* persisted, and is never returned by any route; the API reports only whether one is configured.
|
|
* Holding this permission therefore confers no ability to read a credential — there is no code path
|
|
* that would serve one.
|
|
*
|
|
* Like every other Core permission it is a **platform** permission a module can neither declare,
|
|
* gate, nor observe, and holding it confers no authority inside any module.
|
|
*/
|
|
export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId(
|
|
"core.modules.configuration.manage",
|
|
);
|
|
|
|
/**
|
|
* Restricted, and deliberately a **platform** permission rather than a module's.
|
|
*
|
|
* A device is an identity the platform issues, so deciding that a screen in the foyer may exist —
|
|
* approving its pairing, seeing what has been paired, and taking it away again — is authority over
|
|
* Ekklesia itself, not over the module whose display happens to be shown. Letting a module declare
|
|
* or gate it would mean whoever administers a slideshow could mint credentials that outlive it.
|
|
*
|
|
* It gates approval, listing, and revocation together. Splitting "see the paired devices" from
|
|
* "revoke one" would be ceremony: the same person is trusted with both, and a reader who cannot act
|
|
* on what they see is not a role anyone has asked for (ADR 0027's reasoning, applied again).
|
|
*
|
|
* Holding it confers **no** authority inside any module and no ability to read a device credential
|
|
* — none is stored in a recoverable form, so there is no code path that could serve one (ADR 0036).
|
|
*/
|
|
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
|
|
|
|
/**
|
|
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
|
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
|
* exactly these plus every discovered module's declarations, in a fixed, deterministic order,
|
|
* without reconstructing or guessing Core's own list. `CORE_PERMISSION_REGISTRY` below remains the
|
|
* registry Core-only code and existing tests use directly.
|
|
*/
|
|
export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
|
|
Object.freeze({
|
|
baseline: "restricted" as const,
|
|
description: "Create, list, and revoke another user's explicit authorization grants.",
|
|
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
|
|
}),
|
|
Object.freeze({
|
|
baseline: "authenticated" as const,
|
|
description: "List the installed module UIs a browser host may mount.",
|
|
id: CORE_MODULES_UI_READ,
|
|
}),
|
|
Object.freeze({
|
|
baseline: "restricted" as const,
|
|
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
|
|
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
|
}),
|
|
Object.freeze({
|
|
baseline: "restricted" as const,
|
|
description: "Read and change which installed modules are enabled.",
|
|
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
|
}),
|
|
Object.freeze({
|
|
baseline: "restricted" as const,
|
|
description: "Read and change an installed module's own configuration values.",
|
|
id: CORE_MODULES_CONFIGURATION_MANAGE,
|
|
}),
|
|
Object.freeze({
|
|
baseline: "restricted" as const,
|
|
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
|
id: CORE_DEVICES_MANAGE,
|
|
}),
|
|
]);
|
|
|
|
/**
|
|
* Core's own permission registry, containing exactly the Core-owned permissions declared above.
|
|
* Production `apps/api` does not consult this constant directly: it enforces requests against the
|
|
* composed platform registry (`composePlatformPermissionRegistry`,
|
|
* `core/src/modules/permissions.ts`), which merges these same declarations with every installed
|
|
* module's. `CORE_PERMISSION_REGISTRY` remains for Core-only callers and tests that need Core's
|
|
* permissions in isolation, without composing in any module.
|
|
*/
|
|
export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry(
|
|
CORE_PERMISSION_DECLARATIONS,
|
|
);
|