From fc801fa12bf60a9167d76cac6f930714f51c0c15 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 10 Aug 2026 20:05:56 +0200 Subject: [PATCH 1/2] feat(permissions): manage the person-status grant domain (ct.status) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CT's `status` permission domain grants a right to every person carrying that person status — the only instance-wide lever available, since per-person domains are permanently out of scope (engine/guard.ts). Until now it could only be clicked in the admin UI, so eqrm's instance-wide grants lived entirely outside the config. - DomainType gains "status"; `ct.status({ key, personStatus | id, grants })`. - New `person-status` ref kind resolving against `GET /statuses` (a flat `[{id, name}]` catalog — live-verified on eqrm prod). Distinct from the GROUP status dimension, which still has no catalog at all (#67) and stays numeric. - The numeric-scope guard accepted only positive integers. It now accepts 0 (a real dataId — campus "Mainz" is id 0 on eqrm prod) and -1 (CT's "all values of this dimension" sentinel, e.g. every external system for `churchcore:login to external system`). CT reads -1 back verbatim, so a declared -1 diffs to a no-op instead of churning. - `ct adopt grants status ` emits a paste-ready `ct.status` block. Plan/apply needed no changes — both are domain-type agnostic and already address `/permissions//`. Verified against eqrm prod: `ct adopt grants status 6 --env prod` round-trips the live `churchcore:login to external system` grant (scopeField `oauth_client`, dataId -1). Claude-Session: https://claude.ai/code/session_01VDTyxvXYSAUSSZi8ceT6zs --- README.md | 14 ++++++-- docs/permissions.md | 59 ++++++++++++++++++++++++++++------ examples/permissions.config.ts | 14 ++++++++ src/commands/adopt-grants.ts | 8 ++--- src/config/context.ts | 31 ++++++++++++++++-- src/permissions/adopt.ts | 1 + src/permissions/grants.ts | 7 +++- src/permissions/scope.ts | 17 ++++++++-- src/resolve/refs.ts | 12 +++++-- src/resolve/resolver.ts | 5 +++ tests/context.test.ts | 39 ++++++++++++++++++++++ tests/permission-plan.test.ts | 36 +++++++++++++++++++++ tests/permission-scope.test.ts | 20 ++++++++++-- tests/resolver.test.ts | 24 ++++++++++++++ 14 files changed, 262 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index d07c8d8..fe99596 100644 --- a/README.md +++ b/README.md @@ -231,9 +231,9 @@ runnable example. ### Permissions -`ct.groupRole` / `ct.groupTypeRole` declare ChurchTools permission grants -(group-role and group-type-role rights) as code, reconciled with the same -`plan`/`apply` workflow: +`ct.groupRole` / `ct.groupTypeRole` / `ct.status` declare ChurchTools permission +grants (group-role, group-type-role and person-status rights) as code, +reconciled with the same `plan`/`apply` workflow: ```ts ct.groupTypeRole({ @@ -244,6 +244,14 @@ ct.groupTypeRole({ { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped to a managed group ], }); + +ct.status({ + key: "core_external_login", + personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#89) + // A status grant reaches EVERY person carrying that status — the instance-wide lever. + // -1 is CT's "all values of this dimension" sentinel (here: every external system). + grants: [{ right: "churchcore:login to external system", scope: [-1] }], +}); ``` Right names (`"module:right"`) are validated against a static, offline diff --git a/docs/permissions.md b/docs/permissions.md index 3137287..c148674 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -1,10 +1,10 @@ -# Permissions (`ct.groupRole` / `ct.groupTypeRole`) +# Permissions (`ct.groupRole` / `ct.groupTypeRole` / `ct.status`) -Declare ChurchTools permission grants — group-role and group-type-role rights — -as code, and reconcile them idempotently with the same `ct plan` / `ct apply` -workflow used for structural resources (issue #13). +Declare ChurchTools permission grants — group-role, group-type-role and +person-status rights — as code, and reconcile them idempotently with the same +`ct plan` / `ct apply` workflow used for structural resources (issue #13). -## The two DSL functions +## The three DSL functions ```ts export default (ct) => { @@ -24,10 +24,17 @@ export default (ct) => { // "edit group memberships of group" is a scoped right, so it takes a `scope: [...]`. grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }], }); + + ct.status({ + key: "core_external_login", + personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#89) + // -1 is ChurchTools' "all values of this dimension" sentinel (here: every external system). + grants: [{ right: "churchcore:login to external system", scope: [-1] }], + }); }; ``` -Both take `{ key, , grants }`, where `` is either a logical +All three take `{ key, , grants }`, where `` is either a logical reference or a numeric `id`: - **`key`** — the logical key, unique across the whole config (shared @@ -44,6 +51,10 @@ reference or a numeric `id`: Declaring both a logical form and a numeric `id` is a conflict and throws. See "domainId semantics" for the resolution assumption still to be confirmed live. + - `ct.status` — `personStatus: ""` resolves against the live + `/statuses` catalog per host, or `id: ` targets one directly. + **Person** statuses ("0 - First", "3 - Group Active", …), not group statuses + — see "domainId semantics". - **`grants`** — an array of `Grant`s, each either: - a bare string, `"module:right"` — an **unscoped** grant, or - an object `{ right: "module:right", scope: (string | number)[] }` — a @@ -121,6 +132,20 @@ The two DSL functions manage two different ChurchTools "domain types," and specific group's specific role, *not* the group's id and *not* the role's id. Declare it portably as `group: "", role: ""` (resolved per host, #25) or directly as `id: `. +- **`status`** (`ct.status`) — the domain is a **person status's own id** + (`GET /statuses`: `0 Unbekannt`, `1 0 - First`, …). A grant here applies to + **every person carrying that status**, which makes it the only instance-wide + lever CT offers short of granting per person — and people domains are + permanently out of scope (`src/engine/guard.ts`). Declare it portably as + `personStatus: ""` (#89) or directly as `id: `. Note `id: 0` + is a real, declarable domain ("Unbekannt"), so the numeric guard is a type + check, not a truthiness one. + + > **Person status ≠ group status.** `groupStatusId` (`ct.group`) is a + > different dimension with **no** REST catalog at all (#67) and must always be + > written as a number. Person statuses do have one (`GET /statuses`, flat + > array of `{id, name}` — live-verified 2026-08-10 on eqrm prod), so they + > resolve by name like campuses and group types. > **ASSUMPTION — verify once on a live instance (`eqrm-dev`).** The reference > form resolves by reading the group's own role list @@ -222,6 +247,20 @@ therefore be a plain number instead of a string: { right: "churchdb:security level view own data", scope: [1, 2, 3, 5] }, ``` +A numeric entry must be an integer `>= 0`, or the special value `-1` — +ChurchTools' **"all values of this dimension" sentinel**. CT stores and reads +`-1` back verbatim (it is expanded only in the derived `/permissions/global` +view), so a declared `-1` diffs against a live `-1` and stays a clean no-op: + +```ts +// every external system, present and future +{ right: "churchcore:login to external system", scope: [-1] }, +``` + +Note `0` is a legitimate dataId on several dimensions (campus "Mainz" is id 0 on +eqrm prod), so it is accepted like any other — only values below `-1` and +non-integers are rejected. + Numeric entries pass straight through with no state lookup, no pending resolution, and no re-resolution at apply time (their `dataId` is already final). They can be freely mixed with logical group keys in the same `scope` @@ -235,7 +274,7 @@ To bring an instance's existing rights under management without hand-transcribin them, read the live rows and emit a paste-ready config block: ```bash -ct adopt grants group_type_role 42 # or: group_role, and the hyphenated group-type-role +ct adopt grants group_type_role 42 # or: group_role / status, and the hyphenated group-type-role ``` It fetches `GET /permissions//`, runs the rows through the @@ -276,8 +315,10 @@ to be accepted by `ct plan` (the round trip is locked by tests): direct grants — **including** the writable `authId >= 10000` `churchdb:+…` member rights CT lets you set on `group_type_role` — are emitted as active grants (no authId cutoff; #65). -- **Only `group_role` / `group_type_role`** are valid; people domains are - refused (the same hard boundary as everywhere else). +- **Only `group_role` / `group_type_role` / `status`** are valid; people domains + are refused (the same hard boundary as everywhere else). A `status` block is + emitted with a numeric `id:` — rename it to the portable + `personStatus: ""` form when you paste it in. > **Warning — comment-only grants are pending revocations.** Reconciliation is > set-based: a live grant absent from the pasted declaration lands in diff --git a/examples/permissions.config.ts b/examples/permissions.config.ts index 95025bf..633d44e 100644 --- a/examples/permissions.config.ts +++ b/examples/permissions.config.ts @@ -47,4 +47,18 @@ export default (ct: ConfigContext): void => { role: "Leiter", grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }], }); + + // status (#89): the domain is a PERSON status, declared by name and resolved + // against the live `/statuses` catalog per host (numeric escape hatch: + // `id: `). A grant here reaches EVERY person carrying that status, + // so this is the instance-wide lever — there is deliberately no per-person + // domain (people are never managed by this tool). + // + // `scope: [-1]` is ChurchTools' "all values of this dimension" sentinel — here + // "every external system". CT reads -1 back verbatim, so it stays a no-op. + ct.status({ + key: "core_external_login", + personStatus: "5 - Core", + grants: [{ right: "churchcore:login to external system", scope: [-1] }], + }); }; diff --git a/src/commands/adopt-grants.ts b/src/commands/adopt-grants.ts index b0f9a13..025058c 100644 --- a/src/commands/adopt-grants.ts +++ b/src/commands/adopt-grants.ts @@ -16,15 +16,15 @@ interface AdoptGrantsOptions { /** Accept the DSL's `group_role` and the hyphenated CLI-friendly `group-role`; reject anything else. */ function normalizeDomainType(raw: string): DomainType { const t = raw.trim().replace(/-/g, "_"); - if (t === "group_role" || t === "group_type_role") return t; + if (t === "group_role" || t === "group_type_role" || t === "status") return t; throw new Error( - `Invalid domain type "${raw}" — expected "group_role" or "group_type_role" (people domains are never managed).`, + `Invalid domain type "${raw}" — expected "group_role", "group_type_role" or "status" (people domains are never managed).`, ); } /** * `ct adopt grants ` — read the live permission rows for a domain and print - * a paste-ready `ct.groupRole` / `ct.groupTypeRole` config block. Grants are NOT state-tracked, so + * a paste-ready `ct.groupRole` / `ct.groupTypeRole` / `ct.status` config block. Grants are NOT state-tracked, so * this prints config only; it never writes the state file (contrast `ct adopt `). */ export function adoptGrantsCommand(): Command { @@ -32,7 +32,7 @@ export function adoptGrantsCommand(): Command { .description( "Print a paste-ready grants config block from a live domain's permission rows (does not write state)", ) - .argument("", "group_role | group_type_role") + .argument("", "group_role | group_type_role | status") .argument("", "the domainId of the permission domain object") .option( "-s, --state ", diff --git a/src/config/context.ts b/src/config/context.ts index 8151608..cb5d8ce 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -126,6 +126,8 @@ export interface PermissionInput { group?: string; /** `group_role`: the role name (paired with `group`) — resolves to the pairing domainId (#25). */ role?: string; + /** `status`: the PERSON status by name/key (`/statuses`) — sugars into a Ref-valued domainId (#89). */ + personStatus?: string; grants: Grant[]; } @@ -153,8 +155,17 @@ function domainKeyPart(domainId: number | Ref): string { * - `group_type_role`: numeric `id`, or logical `groupType: ""` → `ref.groupType(...)`. * - `group_role`: numeric `id`, or logical `group` + `role` → `ref.groupRole(...)` (the resolver * maps the pair to its pairing domainId at plan time; see #25). + * - `status`: numeric `id`, or logical `personStatus: ""` → `ref.personStatus(...)`, resolved + * against the `/statuses` catalog (#89). * Declaring both a numeric `id` and a logical form is a conflict. */ +/** The logical field name each domain type offers, for the "provide id or ..." error message. */ +const LOGICAL_FIELD: Record = { + group_type_role: '"groupType"', + group_role: '"group" + "role"', + status: '"personStatus"', +}; + function resolveDomainInput(domainType: DomainType, input: PermissionInput): number | Ref { const hasId = input.id !== undefined; const bothError = (logical: string): Error => @@ -168,6 +179,13 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num throw new Error(`${domainType} "${input.key}": "groupType" must be a non-empty group-type key.`); return ref.groupType(input.groupType); } + } else if (domainType === "status") { + if (input.personStatus !== undefined) { + if (hasId) throw bothError('"personStatus"'); + if (typeof input.personStatus !== "string" || !input.personStatus) + throw new Error(`${domainType} "${input.key}": "personStatus" must be a non-empty person-status key.`); + return ref.personStatus(input.personStatus); + } } else { // group_role if (input.group !== undefined || input.role !== undefined) { @@ -177,10 +195,11 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num return ref.groupRole(input.group, input.role); } } + // A person status id may legitimately be 0 ("Unbekannt"), so this guard must stay a type/finite + // check — never a truthiness one. if (typeof input.id !== "number" || !Number.isFinite(input.id)) { - const logical = domainType === "group_type_role" ? '"groupType"' : '"group" + "role"'; throw new Error( - `${domainType} "${input.key}": provide a numeric "id" (the domainId) or the logical ${logical} form.`, + `${domainType} "${input.key}": provide a numeric "id" (the domainId) or the logical ${LOGICAL_FIELD[domainType]} form.`, ); } return input.id; @@ -198,6 +217,13 @@ export interface ConfigContext { roleDefinition(input: ResourceInput): void; groupRole(input: PermissionInput): void; groupTypeRole(input: PermissionInput): void; + /** + * Grants on a PERSON status (`status` domain, #89) — they apply to every person carrying that + * status, so this is the instance-wide lever. Addressed by `personStatus: ""` (resolved + * against `/statuses`) or the numeric `id:` escape hatch. Note that person statuses are a different + * dimension from group statuses (`groupStatusId`), which have no catalog at all (#67). + */ + status(input: PermissionInput): void; } export type ConfigModule = (ct: ConfigContext) => void | Promise; @@ -458,6 +484,7 @@ export function createContext(): { roleDefinition: define("group-role"), groupRole: definePermission("group_role"), groupTypeRole: definePermission("group_type_role"), + status: definePermission("status"), }; return { ct, resources, permissions }; } diff --git a/src/permissions/adopt.ts b/src/permissions/adopt.ts index 9c83d24..1f447b0 100644 --- a/src/permissions/adopt.ts +++ b/src/permissions/adopt.ts @@ -23,6 +23,7 @@ import { normalizeActual, type DomainType, type GrantTuple, type RawPermission } const DSL_FN: Record = { group_role: "ct.groupRole", group_type_role: "ct.groupTypeRole", + status: "ct.status", }; /** diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts index 4cc1281..546a41f 100644 --- a/src/permissions/grants.ts +++ b/src/permissions/grants.ts @@ -3,7 +3,12 @@ * Actuals exclude the self-re-adding system baseline (modifiedPid === -1) and inherited rows, * so reconciliation owns only user-authored grants and never fights the platform. */ -export type DomainType = "group_role" | "group_type_role"; +/** + * The permission domains this tool reconciles. `status` is CT's PERSON-status domain (#89) — a grant + * there applies to every person carrying that status, which is the only instance-wide lever CT offers + * short of granting per person (a people domain, permanently out of scope — see engine/guard.ts). + */ +export type DomainType = "group_role" | "group_type_role" | "status"; // What reconciliation owns vs. leaves untouched is decided SOLELY by `normalizeActual` below: // it drops the self-re-adding system baseline (`modifiedPid === -1`) and any `isInherited` row, so diff --git a/src/permissions/scope.ts b/src/permissions/scope.ts index 396a40c..09f2a1b 100644 --- a/src/permissions/scope.ts +++ b/src/permissions/scope.ts @@ -10,6 +10,13 @@ import type { GrantTuple } from "./grants.js"; */ export interface ScopeResolution { key: string; id: number | null; numeric?: boolean } +/** + * ChurchTools' "every value of this dimension" dataId. CT both accepts it on write and reads it back + * verbatim, expanding it only in the derived `/permissions/global` view — so a declared `-1` diffs + * against a live `-1` and stays a clean no-op. + */ +export const ALL_SCOPE_SENTINEL = -1; + /** * Resolve a scope array against DESIRED ∪ STATE. Each entry is either: * @@ -40,8 +47,14 @@ export function resolveScope( const pending: ScopeResolution[] = []; for (const key of scopeKeys) { if (typeof key === "number") { - if (!Number.isInteger(key) || key <= 0) { - throw new Error(`Invalid numeric scope entry ${JSON.stringify(key)} — a numeric scope must be a positive integer dataId.`); + // `-1` is ChurchTools' ALL sentinel (verified live 2026-08-10: `churchcore:login to external + // system` with `dataId: -1` reads back through `/permissions/global` expanded to every external + // system id). `0` is a real dataId on more than one dimension (campus "Mainz" is id 0 on eqrm + // prod). So the floor is -1, not 1 — anything below that is a typo, not a sentinel. + if (!Number.isInteger(key) || key < ALL_SCOPE_SENTINEL) { + throw new Error( + `Invalid numeric scope entry ${JSON.stringify(key)} — a numeric scope must be an integer dataId (>= 0), or ${ALL_SCOPE_SENTINEL} for ChurchTools' "all" sentinel.`, + ); } resolved.push({ key: String(key), id: key, numeric: true }); continue; diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index 8ed6288..61afcf4 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -22,6 +22,7 @@ export type RefKind = | "campus" | "group-type" | "group-status" + | "person-status" | "role-def" | "group" | "group-role" @@ -42,10 +43,10 @@ export const GROUP_STATUS_NO_CATALOG = `group statuses have no REST catalog (GET /group/memberstatus is a different dimension: member ` + `statuses, string ids — verified 2026-07-10). Declare a numeric "groupStatusId" instead (e.g. "groupStatusId: 1").`; -/** Simple key-addressed reference: campus / group type / group status / role definition / group. */ +/** Simple key-addressed reference: campus / group type / group status / person status / role definition / group. */ export interface SimpleRef { __ctRef: true; - kind: "campus" | "group-type" | "group-status" | "role-def" | "group"; + kind: "campus" | "group-type" | "group-status" | "person-status" | "role-def" | "group"; key: string; } @@ -99,6 +100,13 @@ export const ref = { campus: (key: string): SimpleRef => ({ __ctRef: true, kind: "campus", key: requireKey("campus", key) }), groupType: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-type", key: requireKey("group-type", key) }), status: (key: string): SimpleRef => ({ __ctRef: true, kind: "group-status", key: requireKey("group-status", key) }), + /** + * A PERSON status (`/statuses` — "0 - First", "3 - Group Active", …), the domain of a `status` + * permission declaration. Unrelated to {@link ref.status} (GROUP status, `groupStatusId`), which + * has no catalog at all (#67) — person statuses do, so this one resolves by name like any other + * master-data ref. + */ + personStatus: (key: string): SimpleRef => ({ __ctRef: true, kind: "person-status", key: requireKey("person-status", key) }), roleDef: (key: string): SimpleRef => ({ __ctRef: true, kind: "role-def", key: requireKey("role-def", key) }), group: (key: string): SimpleRef => ({ __ctRef: true, kind: "group", key: requireKey("group", key) }), /** diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 9b7381d..6718989 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -71,6 +71,11 @@ const REF_KIND_TYPE: Partial> = { const CATALOG_PATH: Partial> = { campus: "/campuses", "group-type": "/group/grouptypes", + // PERSON statuses — the domain of a `status` permission declaration (#89). Unlike GROUP statuses + // (see the note above), these DO have a flat REST catalog: `GET /statuses` returns + // `[{id, name, shorty, …}]` — live-verified 2026-08-10 on eqrm prod. (`/person/masterdata` carries + // the same rows under a `statuses` key, but nested; this catalog reader expects a top-level array.) + "person-status": "/statuses", "role-def": "/group/roles", }; diff --git a/tests/context.test.ts b/tests/context.test.ts index 283be9f..a03c79e 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -520,4 +520,43 @@ describe("permission declarations", () => { }), ).rejects.toThrow(/Duplicate permission target.*group-type:mt/s); }); + + // The PERSON-status permission domain (#89) — the instance-wide grant lever. + it("collects a status declaration and sugars `personStatus` into a person-status Ref", async () => { + const { permissions } = await evaluateConfig((ct: ConfigContext) => + ct.status({ + key: "core_login", + personStatus: "5 - Core", + grants: [{ right: "churchcore:login to external system", scope: [-1] }], + }), + ); + expect(permissions[0]).toMatchObject({ + key: "core_login", + domainType: "status", + domainId: { __ctRef: true, kind: "person-status", key: "5 - Core" }, + }); + }); + + // Status id 0 ("Unbekannt") is a legitimate domain — the numeric guard must be a type check, + // not a truthiness one, or that whole status becomes undeclarable. + it("accepts a numeric status domainId of 0", async () => { + const { permissions } = await evaluateConfig((ct: ConfigContext) => + ct.status({ key: "unknown_status", id: 0, grants: [] }), + ); + expect(permissions[0]?.domainId).toBe(0); + }); + + it("rejects declaring both a numeric id and personStatus on a status domain", async () => { + await expect( + evaluateConfig((ct: ConfigContext) => + ct.status({ key: "s", id: 6, personStatus: "5 - Core", grants: [] }), + ), + ).rejects.toThrow(/either "id".*or "personStatus".*not both/); + }); + + it("names personStatus as the logical form when a status domain has neither", async () => { + await expect( + evaluateConfig((ct: ConfigContext) => ct.status({ key: "s", grants: [] } as never)), + ).rejects.toThrow(/provide a numeric "id".*or the logical "personStatus" form/); + }); }); diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index 3935e88..d6d6222 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -214,4 +214,40 @@ describe("buildPermissionPlan", () => { [], undefined, CATALOG_META!.ctVersion); expect(warnings).toEqual([]); }); + // The PERSON-status domain end to end (#89): a `personStatus` ref resolves against /statuses, the + // planner bulk-fetches /permissions/status, and the live `dataId: -1` ALL sentinel round-trips to + // a clean no-op — which is the whole point (an instance-wide grant that churned every plan would + // rewrite everyone's rights on every apply). + it("resolves a status domain by person-status name and reconciles the -1 ALL sentinel idempotently", async () => { + const client = { get: vi.fn(async (path: string) => { + if (path === "/statuses") return [{ id: 4, name: "3 - Group Active" }, { id: 6, name: "5 - Core" }]; + if (path === "/permissions/status") return [ + { domainType: "status", domainId: 6, authId: 18, dataId: -1, type: "grant", meta: { modifiedPid: 1 } }, + ]; + throw new Error(`unexpected path ${path}`); + }) }; + const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { key: "core_login", domainType: "status", domainId: ref.personStatus("5 - Core"), + grants: [{ right: "churchcore:login to external system", scope: [-1] }] }, + ]); + expect(fetchErrors).toEqual([]); + expect(warnings).toEqual([]); + expect(items[0]?.domainId).toBe(6); // resolved from the /statuses catalog + expect(items[0]?.diff.toPut).toEqual([]); // live -1 row matches the declaration + expect(items[0]?.diff.toDelete).toEqual([]); + }); + + it("proposes the status grant on a status that does not carry it yet", async () => { + const client = { get: vi.fn(async (path: string) => { + if (path === "/statuses") return [{ id: 4, name: "3 - Group Active" }]; + if (path === "/permissions/status") return []; + throw new Error(`unexpected path ${path}`); + }) }; + const { items } = await buildPermissionPlan(client as never, state, [ + { key: "group_active_login", domainType: "status", domainId: ref.personStatus("3 - Group Active"), + grants: [{ right: "churchcore:login to external system", scope: [-1] }] }, + ]); + expect(items[0]?.domainId).toBe(4); + expect(items[0]?.diff.toPut).toEqual([{ authId: 18, dataId: [-1], type: "grant" }]); + }); }); diff --git a/tests/permission-scope.test.ts b/tests/permission-scope.test.ts index 109ee3f..279ceb8 100644 --- a/tests/permission-scope.test.ts +++ b/tests/permission-scope.test.ts @@ -42,9 +42,25 @@ describe("resolveScope", () => { ]); }); - it("rejects a non-positive-integer numeric scope entry", () => { - expect(() => resolveScope([0], state)).toThrow(/numeric scope/i); + it("rejects a non-integer or below-sentinel numeric scope entry", () => { expect(() => resolveScope([-3], state)).toThrow(/numeric scope/i); expect(() => resolveScope([1.5], state)).toThrow(/numeric scope/i); }); + + // 0 is a real dataId on more than one CT dimension — campus "Mainz" is id 0 on eqrm prod — so it + // must pass through like any other, not be rejected as falsy. + it("accepts dataId 0 as an ordinary scope entry", () => { + expect(resolveScope([0], state)).toEqual([{ key: "0", id: 0, numeric: true }]); + }); + + // -1 is ChurchTools' "all values of this dimension" sentinel (e.g. `churchcore:login to external + // system` granted for every external system). CT reads it back verbatim, so it must survive the + // round trip unchanged or the grant would churn on every plan. + it("accepts the -1 ALL sentinel and sorts it first", () => { + expect(resolveScope([-1], state)).toEqual([{ key: "-1", id: -1, numeric: true }]); + expect(resolveScope([3, -1], state)).toEqual([ + { key: "-1", id: -1, numeric: true }, + { key: "3", id: 3, numeric: true }, + ]); + }); }); diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index 92fd980..acef4a7 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -47,6 +47,30 @@ describe("Resolver.resolve", () => { expect(await r.resolve(ref.groupType("ministry_team"), "site")).toBe(2); }); + // PERSON statuses DO have a flat catalog (`GET /statuses`), unlike GROUP statuses in the test below (#89). + it("resolves a person status from the /statuses catalog by slug(name)", async () => { + const client = fakeClient({ + "/statuses": [ + { id: 0, name: "Unbekannt" }, + { id: 4, name: "3 - Group Active" }, + { id: 6, name: "5 - Core" }, + ], + }); + const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + expect(await r.resolve(ref.personStatus("3_group_active"), "site")).toBe(4); + // Exact-name fallback, for a name that does not survive slugging cleanly. + expect(await r.resolve(ref.personStatus("5 - Core"), "site")).toBe(6); + // Status id 0 must come back as 0, not be mistaken for "unresolved". + expect(await r.resolve(ref.personStatus("unbekannt"), "site")).toBe(0); + expect(client.calls["/statuses"]).toBe(1); // one fetch, memoized across all three + }); + + it("errors on a person-status ref with no catalog match", async () => { + const client = fakeClient({ "/statuses": [{ id: 0, name: "Unbekannt" }] }); + const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + await expect(r.resolve(ref.personStatus("5_core"), "site")).rejects.toThrow(/5_core/); + }); + it("has no group-status catalog — a group-status ref is a hard error, never resolved against /group/memberstatus (#67)", async () => { // /group/memberstatus IS mocked here (as a member-statuses catalog would be on a live host), to // prove the resolver never even looks at it for a group-status ref — group statuses have no From 91361274ac93c5c7d2b7b1c7660bf3eeb4d2b3d4 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Mon, 10 Aug 2026 20:22:05 +0200 Subject: [PATCH 2/2] docs(permissions): point the person-status notes at #90, not the Handbuch issue The `status` domain work is PR #90 / issue #91; #89 is the unrelated docs-publishing issue. Claude-Session: https://claude.ai/code/session_01KPxjSEkqMiU7WE3vg6C3am --- README.md | 2 +- docs/permissions.md | 4 ++-- examples/permissions.config.ts | 2 +- src/config/context.ts | 6 +++--- src/permissions/grants.ts | 2 +- src/resolve/resolver.ts | 2 +- tests/context.test.ts | 2 +- tests/permission-plan.test.ts | 2 +- tests/resolver.test.ts | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fe99596..01d902f 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ ct.groupTypeRole({ ct.status({ key: "core_external_login", - personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#89) + personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90) // A status grant reaches EVERY person carrying that status — the instance-wide lever. // -1 is CT's "all values of this dimension" sentinel (here: every external system). grants: [{ right: "churchcore:login to external system", scope: [-1] }], diff --git a/docs/permissions.md b/docs/permissions.md index c148674..e808a7c 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -27,7 +27,7 @@ export default (ct) => { ct.status({ key: "core_external_login", - personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#89) + personStatus: "5 - Core", // domain BY PERSON-STATUS NAME — resolved against /statuses (#90) // -1 is ChurchTools' "all values of this dimension" sentinel (here: every external system). grants: [{ right: "churchcore:login to external system", scope: [-1] }], }); @@ -137,7 +137,7 @@ The two DSL functions manage two different ChurchTools "domain types," and **every person carrying that status**, which makes it the only instance-wide lever CT offers short of granting per person — and people domains are permanently out of scope (`src/engine/guard.ts`). Declare it portably as - `personStatus: ""` (#89) or directly as `id: `. Note `id: 0` + `personStatus: ""` (#90) or directly as `id: `. Note `id: 0` is a real, declarable domain ("Unbekannt"), so the numeric guard is a type check, not a truthiness one. diff --git a/examples/permissions.config.ts b/examples/permissions.config.ts index 633d44e..466b7ed 100644 --- a/examples/permissions.config.ts +++ b/examples/permissions.config.ts @@ -48,7 +48,7 @@ export default (ct: ConfigContext): void => { grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }], }); - // status (#89): the domain is a PERSON status, declared by name and resolved + // status (#90): the domain is a PERSON status, declared by name and resolved // against the live `/statuses` catalog per host (numeric escape hatch: // `id: `). A grant here reaches EVERY person carrying that status, // so this is the instance-wide lever — there is deliberately no per-person diff --git a/src/config/context.ts b/src/config/context.ts index cb5d8ce..6b64316 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -126,7 +126,7 @@ export interface PermissionInput { group?: string; /** `group_role`: the role name (paired with `group`) — resolves to the pairing domainId (#25). */ role?: string; - /** `status`: the PERSON status by name/key (`/statuses`) — sugars into a Ref-valued domainId (#89). */ + /** `status`: the PERSON status by name/key (`/statuses`) — sugars into a Ref-valued domainId (#90). */ personStatus?: string; grants: Grant[]; } @@ -156,7 +156,7 @@ function domainKeyPart(domainId: number | Ref): string { * - `group_role`: numeric `id`, or logical `group` + `role` → `ref.groupRole(...)` (the resolver * maps the pair to its pairing domainId at plan time; see #25). * - `status`: numeric `id`, or logical `personStatus: ""` → `ref.personStatus(...)`, resolved - * against the `/statuses` catalog (#89). + * against the `/statuses` catalog (#90). * Declaring both a numeric `id` and a logical form is a conflict. */ /** The logical field name each domain type offers, for the "provide id or ..." error message. */ @@ -218,7 +218,7 @@ export interface ConfigContext { groupRole(input: PermissionInput): void; groupTypeRole(input: PermissionInput): void; /** - * Grants on a PERSON status (`status` domain, #89) — they apply to every person carrying that + * Grants on a PERSON status (`status` domain, #90) — they apply to every person carrying that * status, so this is the instance-wide lever. Addressed by `personStatus: ""` (resolved * against `/statuses`) or the numeric `id:` escape hatch. Note that person statuses are a different * dimension from group statuses (`groupStatusId`), which have no catalog at all (#67). diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts index 546a41f..d075346 100644 --- a/src/permissions/grants.ts +++ b/src/permissions/grants.ts @@ -4,7 +4,7 @@ * so reconciliation owns only user-authored grants and never fights the platform. */ /** - * The permission domains this tool reconciles. `status` is CT's PERSON-status domain (#89) — a grant + * The permission domains this tool reconciles. `status` is CT's PERSON-status domain (#90) — a grant * there applies to every person carrying that status, which is the only instance-wide lever CT offers * short of granting per person (a people domain, permanently out of scope — see engine/guard.ts). */ diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 6718989..90857a4 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -71,7 +71,7 @@ const REF_KIND_TYPE: Partial> = { const CATALOG_PATH: Partial> = { campus: "/campuses", "group-type": "/group/grouptypes", - // PERSON statuses — the domain of a `status` permission declaration (#89). Unlike GROUP statuses + // PERSON statuses — the domain of a `status` permission declaration (#90). Unlike GROUP statuses // (see the note above), these DO have a flat REST catalog: `GET /statuses` returns // `[{id, name, shorty, …}]` — live-verified 2026-08-10 on eqrm prod. (`/person/masterdata` carries // the same rows under a `statuses` key, but nested; this catalog reader expects a top-level array.) diff --git a/tests/context.test.ts b/tests/context.test.ts index a03c79e..0bea769 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -521,7 +521,7 @@ describe("permission declarations", () => { ).rejects.toThrow(/Duplicate permission target.*group-type:mt/s); }); - // The PERSON-status permission domain (#89) — the instance-wide grant lever. + // The PERSON-status permission domain (#90) — the instance-wide grant lever. it("collects a status declaration and sugars `personStatus` into a person-status Ref", async () => { const { permissions } = await evaluateConfig((ct: ConfigContext) => ct.status({ diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index d6d6222..6423056 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -214,7 +214,7 @@ describe("buildPermissionPlan", () => { [], undefined, CATALOG_META!.ctVersion); expect(warnings).toEqual([]); }); - // The PERSON-status domain end to end (#89): a `personStatus` ref resolves against /statuses, the + // The PERSON-status domain end to end (#90): a `personStatus` ref resolves against /statuses, the // planner bulk-fetches /permissions/status, and the live `dataId: -1` ALL sentinel round-trips to // a clean no-op — which is the whole point (an instance-wide grant that churned every plan would // rewrite everyone's rights on every apply). diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index acef4a7..a585964 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -47,7 +47,7 @@ describe("Resolver.resolve", () => { expect(await r.resolve(ref.groupType("ministry_team"), "site")).toBe(2); }); - // PERSON statuses DO have a flat catalog (`GET /statuses`), unlike GROUP statuses in the test below (#89). + // PERSON statuses DO have a flat catalog (`GET /statuses`), unlike GROUP statuses in the test below (#90). it("resolves a person status from the /statuses catalog by slug(name)", async () => { const client = fakeClient({ "/statuses": [