diff --git a/docs/permissions.md b/docs/permissions.md index 286d9d4..078c1bc 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -19,7 +19,8 @@ export default (ct) => { ct.groupRole({ key: "kids_lead_grant", - id: 2882, // the internal (group, role) domainId — see below (group_role has no ref yet) + group: "kids_area", // domain BY (group, role) — resolved to the pairing domainId per host (#25) + role: "Leiter", // (or keep the numeric escape hatch: `id: 2882`) // "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"] }], }); @@ -35,11 +36,14 @@ reference or a numeric `id`: portable form, #20) or **by numeric `id`** (the escape hatch): - `ct.groupTypeRole` — `groupType: ""` resolves against the live group-type catalog per host, or `id: ` targets one directly. - - `ct.groupRole` — **`id: ` only for now.** The logical - `group: "", role: ""` form is accepted by the DSL but the - resolver rejects it at plan time (the (group, role) pairing id has no - confirmed API source — see "domainId semantics" and #25). Declaring both a - logical form and a numeric `id` is a conflict and throws. + - `ct.groupRole` — `group: "", role: ""` resolves the (group, + role) pair to its pairing domainId per host (#25), or `id: ` + targets one directly. The group must be **managed** (declared via `ct.group` + or adopted into state) and already created — a same-run group is rejected + (its pairing id is only known once it exists; pass a numeric `id` there). + 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. - **`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 @@ -69,6 +73,40 @@ fetch — with a "did you mean" hint drawn from same-module names. (Config evaluation only checks a grant's *shape*: `module:right` string or `{ right, scope }`; it does not resolve the name against the catalog.) +## Catalog lifecycle & staleness (#25) + +The catalog (`src/permissions/catalog.json`) is a snapshot of one instance's +permission master data, captured at a specific ChurchTools version. Two things +keep it honest: + +**Regeneration — one command.** Point it at a live instance and it rewrites +`catalog.json` (rights + a fresh `$meta` provenance stamp): + +```bash +CT_HOST=https://your.church.tools CT_LOGINTOKEN= npm run regenerate:permission-catalog +``` + +It logs in, calls the legacy `POST /index.php?q=churchauth/ajax` `func=getMasterData` +endpoint (the only source of the name↔authId map — see +`src/permissions/README.md`), records the instance's CT version, and writes the +file. It performs a single **read**; it never writes to the instance. Review +the `git diff` before committing. + +**Staleness & unknown rights — `ct plan` warns (never fails).** `$meta.ctVersion` +records the version the catalog was captured from. On every `plan`/`apply`: + +- If the live instance's CT version differs from `$meta.ctVersion`, `ct plan` + prints a warning — right names/authIds/scopeFields may have drifted; + regenerate to be sure. +- If a **live grant carries an `authId` the catalog cannot name** (a stale or + foreign right), `ct plan` names the `authId` + domain and **leaves the grant + untouched** — it is deliberately kept *out* of the diff so `ct apply` never + revokes a right it cannot even describe. This is idempotent: the unknown row + is excluded every run, so it neither churns nor silently disappears. + +Both are warnings, not errors: the plan still runs and the exit code stays +success. Regenerating the catalog (above) is the fix for both. + ## `domainId` semantics The two DSL functions manage two different ChurchTools "domain types," and @@ -81,18 +119,28 @@ The two DSL functions manage two different ChurchTools "domain types," and - **`group_role`** (`ct.groupRole`) — the domain is the **internal (group, role) pairing's own id** — a ChurchTools-internal id for one specific group's specific role, *not* the group's id and *not* the role's - id. **This is `id: ` only.** The logical `group` + `role` form is - reserved (and accepted by the DSL) but **not yet resolvable**: the pairing id - has no confirmed API source, so the resolver throws a clear "pass a numeric id - (see #25)" error at plan time. Find the id via the ChurchTools permission - editor / an existing `GET /permissions/group_role` response for a group+role - you already have, and hardcode it like any other domainId. + id. Declare it portably as `group: "", role: ""` (resolved per + host, #25) or directly as `id: `. + + > **ASSUMPTION — verify once on a live instance (`eqrm-dev`).** The reference + > form resolves by reading the group's own role list + > (`GET /groups/{groupId}/roles`) and taking the matched role row's `id` as + > the pairing domainId. Neither the endpoint nor the field is confirmed + > against a live instance (the assumption is pinned in a unit test and in a + > prominent comment in `src/resolve/resolver.ts`). If a live check shows the + > pairing id lives in a different field or endpoint, change the two + > constants at the top of `resolver.ts` — call sites don't change. Until + > confirmed, the numeric `id:` escape hatch is the guaranteed-correct path: + > find the id via the ChurchTools permission editor / an existing + > `GET /permissions/group_role` response, and hardcode it like any other + > domainId. Resolution runs in `buildPermissionPlan` (`src/permissions/plan.ts`): a numeric `id` passes straight through; a `groupType` reference resolves against the live -catalog. After resolution, two declarations that resolve to the **same** -`(domainType, domainId)` are rejected (they would otherwise diff against each -other's grants forever) — even if one used a name and the other a raw id. +catalog, and a `group` + `role` pair against the group's role list. After +resolution, two declarations that resolve to the **same** `(domainType, +domainId)` are rejected (they would otherwise diff against each other's grants +forever) — even if one used a name and the other a raw id. ## Scope resolution diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index 1d9038c..f47c497 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -34,11 +34,11 @@ this doc's structure. | Item | What it is | Tracking issue | Manual workaround today | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Group/group-type field decision table | Fields deliberately left unmanaged (decided out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key. The triage **shipped** as a committed decision table ([`docs/group-field-decisions.md`](group-field-decisions.md)) | [#21](https://github.com/eqrm/ct-cli/issues/21) (decided) | Set by hand; these fields are intentionally not diffed — `ct` will neither preserve nor revert them. Promote one later only with its own registry entry + tests | -| Portable/logical references | **Shipped (#20).** Configs reference master data by name/key — `campus`/`groupType`/`status` on a group, `ref.campus(...)` in ruleset `var` values, `groupType: ""` for a `group_type_role` domain — and the per-host resolver maps each to that instance's id at plan time (managed resources ∪ live catalogs). A same-run campus resolves at apply time. Numeric ids still work as an escape hatch. **Residual gap:** the `group_role` domain by (group, role) reference is still gated — see the row below | [#20](https://github.com/eqrm/ct-cli/issues/20) (done) | None needed for the shipped surface. Write logical names; run `ct plan`. For the gated `group_role` case, use a numeric `id` (next row) | +| Portable/logical references | **Shipped (#20, #25).** Configs reference master data by name/key — `campus`/`groupType`/`status` on a group, `ref.campus(...)` in ruleset `var` values, `groupType: ""` for a `group_type_role` domain, and now `group: "", role: ""` for a `group_role` domain (#25) — and the per-host resolver maps each to that instance's id at plan time (managed resources ∪ live catalogs). A same-run campus resolves at apply time. Numeric ids still work as an escape hatch | [#20](https://github.com/eqrm/ct-cli/issues/20) (done), [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | None needed for the shipped surface. Write logical names; run `ct plan`. See the `group_role` resolution assumption below (one-time live check) | | Environments (dev → prod promotion) | Named `(host, token, state file)` profiles and a `--env` flag; today one config + one state file = one host | [#22](https://github.com/eqrm/ct-cli/issues/22) | Point `CT_HOST`/state file manually at each target and re-run; keep dev and prod state files apart yourself, and be careful — nothing stops you from applying a dev-shaped config against prod today | -| Permission `group_role` domain by reference | `group_type_role` domains now resolve by name (`groupType: ""`, #20). But `group_role`'s domain is CT's internal (group, role) _pairing_ id, with **no confirmed API source** — the DSL accepts `group: "", role: ""` but the resolver rejects it at plan time with a "pass a numeric id" error | [#25](https://github.com/eqrm/ct-cli/issues/25) | Find the pairing id via the CT permission editor, or an existing `GET /permissions/group_role` response for a group+role you already have, and pass it as numeric `id` ([`docs/permissions.md`](permissions.md) "domainId semantics") | +| Permission `group_role` domain by reference **(shipped, assumption unverified)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **ASSUMPTION not yet confirmed live:** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId — endpoint/field unverified (pinned in a unit test + a comment in `src/resolve/resolver.ts`) | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verify live) | Works today by reference for managed, already-created groups. If a live check shows the pairing id is elsewhere, flip the two constants at the top of `resolver.ts`. Numeric `id:` remains the guaranteed escape hatch ([`docs/permissions.md`](permissions.md) "domainId semantics") | | ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ — **`ct adopt grants ` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/permissions.md`](permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run `ct adopt grants group_role ` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config | -| Permission catalog lifecycle | `catalog.json` is a one-off HAR-trace snapshot of a single CT version, with no staleness detection | [#25](https://github.com/eqrm/ct-cli/issues/25) | Manual regeneration procedure below (**Permission catalog lifecycle**) | +| ~~Permission catalog lifecycle~~ **(shipped)** | ~~`catalog.json` is a one-off HAR-trace snapshot with no staleness detection~~ — **shipped (#25):** `npm run regenerate:permission-catalog` rewrites it from a live instance (records the CT version in `$meta`), and `ct plan` now warns on a version mismatch or an unknown-authId live grant (which it leaves untouched, never revoking a right it cannot name). See [`docs/permissions.md`](permissions.md) "Catalog lifecycle & staleness" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run the command; heed the `ct plan` warnings | | API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a group-status write) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | ## Out of tool scope — deliberate, not a gap @@ -51,21 +51,36 @@ this doc's structure. ## Permission catalog lifecycle (regeneration procedure) -Until #25's scripted lifecycle lands, regenerate `src/permissions/catalog.json` -by hand when the instance's CT version changes materially: +**Scripted (#25) — the normal path.** Regenerate `src/permissions/catalog.json` +from a live instance with one command: + +```bash +CT_HOST=https://your.church.tools CT_LOGINTOKEN= npm run regenerate:permission-catalog +``` + +It reads `POST /index.php?q=churchauth/ajax` `func=getMasterData`, flattens +`data.auth_table[module][right]` to +`"module:right" → { authId: id, scopeField: datenfeld, revocable: !!isRevocable, desc: bezeichnung }`, +stamps the instance's CT version into `$meta`, and rewrites the file (read-only +against the instance). Review the `git diff` and commit. + +**Staleness signals.** `ct plan`/`ct apply` throw a clear "did you mean" error +for an unknown right *name* in a config; they now also **warn** (not fail) when +the live instance's CT version differs from `$meta.ctVersion`, and when a live +grant carries an `authId` the catalog cannot name (left untouched, never +revoked). Both are fixed by regenerating. + +**Manual fallback (HAR).** If you cannot run the script (no login token to +hand), capture it by hand: 1. Open the ChurchTools permission editor in a browser with devtools recording (Network tab). 2. Trigger the request: `POST /index.php?q=churchauth/ajax` with body `func=getMasterData`. 3. Export the HAR and extract `log.entries[].response` for that request. -4. Flatten `data.auth_table[module][right]` to - `"module:right" → { authId: id, scopeField: datenfeld, revocable: !!isRevocable, desc: bezeichnung }`. -5. Overwrite `src/permissions/catalog.json` and update the "Captured - from ... (CT )" note in `src/permissions/README.md`. - -`ct plan`/`ct apply` throw a clear "did you mean" error for an unknown right -name today; there is no version-mismatch warning yet (tracked under #25). +4. Flatten `data.auth_table[module][right]` as above. +5. Overwrite `src/permissions/catalog.json` (keep the `$meta` block, updating + its `ctVersion`/`capturedAt`). ## Re-audit procedure for new CT releases diff --git a/examples/permissions.config.ts b/examples/permissions.config.ts index 3cb8b7f..95025bf 100644 --- a/examples/permissions.config.ts +++ b/examples/permissions.config.ts @@ -1,7 +1,8 @@ /** * Runnable example: a group-type-role declaration with one unscoped (global) - * grant and one scoped grant. See docs/permissions.md for the full feature - * guide, and `ct get permissions-catalog` to discover right names. + * grant and one scoped grant, plus a group-role declared by (group, role) + * reference (#25). See docs/permissions.md for the full feature guide, and + * `ct get permissions-catalog` to discover right names. * * Portable references (#20): the permission domain is declared by name * (`groupType: "kids"`) instead of a hardcoded numeric domainId — the per-host @@ -33,4 +34,17 @@ export default (ct: ConfigContext): void => { { right: "churchgroup:view group", scope: ["kids_area"] }, ], }); + + // group_role by reference (#25): the domain is declared by the (group, role) + // pair instead of a numeric domainId. The group must be managed (declared + // above / adopted) and already created; the resolver maps the pair to the + // pairing domainId per host. Numeric escape hatch: `id: ` instead + // of `group`/`role`. (See docs/permissions.md "domainId semantics" for the + // resolution assumption still to be confirmed live.) + ct.groupRole({ + key: "kids_leiter_grant", + group: "kids_area", + role: "Leiter", + grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }], + }); }; diff --git a/package.json b/package.json index d8f11c9..fce68e1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", - "generate:client": "openapi-typescript \"$CT_HOST/system/runtime/swagger/openapi.json\" -o src/api/schema.d.ts" + "generate:client": "openapi-typescript \"$CT_HOST/system/runtime/swagger/openapi.json\" -o src/api/schema.d.ts", + "regenerate:permission-catalog": "tsx scripts/regenerate-permission-catalog.ts" }, "dependencies": { "commander": "^12.1.0", diff --git a/scripts/regenerate-permission-catalog.ts b/scripts/regenerate-permission-catalog.ts new file mode 100644 index 0000000..89fa7a2 --- /dev/null +++ b/scripts/regenerate-permission-catalog.ts @@ -0,0 +1,142 @@ +/** + * Regenerate `src/permissions/catalog.json` from a live ChurchTools instance (#25). + * + * The name↔authId catalog is NOT exposed by the REST API — it is only served to the permission + * editor via the legacy AJAX endpoint. This script logs in with a login token, calls that endpoint, + * flattens the master-data `auth_table` into the catalog's exact schema, stamps it with the + * instance's CT version (for `ct plan`'s staleness warning), and writes the file. + * + * Usage: + * CT_HOST=https://your.church.tools CT_LOGINTOKEN= npm run regenerate:permission-catalog + * + * - CT_HOST the instance base URL (same value `ct` uses). + * - CT_LOGINTOKEN a login token for a user who can open the permission editor + * (Settings → Permissions). Get it from the CT admin, `ct` credentials, + * or your browser session. + * + * This is a DEV script (run via tsx, a devDependency) — it is never bundled into `dist/`, and it is + * the only code that talks to the legacy `churchauth/ajax` surface. It performs a single read; it + * never writes to the instance. Review the git diff on `catalog.json` before committing. + * + * Source shape (see src/permissions/README.md): + * POST /index.php?q=churchauth/ajax body: func=getMasterData + * → { data: { auth_table: { : { : { id, datenfeld, bezeichnung, isRevocable, … } } } } } + * + * Mapping to a catalog entry `":"`: + * authId ← id + * scopeField ← datenfeld (empty/falsy → null) + * revocable ← !!isRevocable + * desc ← bezeichnung ?? "" + */ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +interface RawRight { + id: number; + datenfeld?: string | null; + bezeichnung?: string | null; + isRevocable?: boolean | number; + [k: string]: unknown; +} +interface MasterData { + data?: { auth_table?: Record> }; + auth_table?: Record>; +} +interface CatalogEntry { + authId: number; + scopeField: string | null; + revocable: boolean; + desc: string; +} + +function requireEnv(name: string): string { + const v = process.env[name]?.trim(); + if (!v) throw new Error(`Missing required env var ${name}. See the header of this script for usage.`); + return v; +} + +/** Parse a `Set-Cookie` header list into a single `Cookie` request-header value. */ +function cookieHeader(res: Response): string { + // Node's fetch exposes multiple Set-Cookie via getSetCookie(); fall back to the folded header. + const raw = + (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? + (res.headers.get("set-cookie") ? [res.headers.get("set-cookie") as string] : []); + return raw.map((c) => c.split(";")[0]).filter(Boolean).join("; "); +} + +async function main(): Promise { + const host = requireEnv("CT_HOST").replace(/\/+$/, ""); + const token = requireEnv("CT_LOGINTOKEN"); + + // 1. Log in (login-token handshake) to obtain the session cookie the legacy endpoint needs. + const whoami = await fetch(`${host}/api/whoami?login_token=${encodeURIComponent(token)}`, { + headers: { Accept: "application/json" }, + }); + if (!whoami.ok) throw new Error(`Login failed (whoami): HTTP ${whoami.status}`); + const cookie = cookieHeader(whoami); + if (!cookie) throw new Error("Login succeeded but no session cookie was returned."); + + // 2. Read the instance CT version (for the catalog's provenance stamp). + const infoRes = await fetch(`${host}/api/info`, { headers: { Accept: "application/json", Cookie: cookie } }); + const infoBody = (await infoRes.json().catch(() => ({}))) as { data?: { version?: string }; version?: string }; + const ctVersion = infoBody.data?.version ?? infoBody.version ?? "unknown"; + + // 3. Fetch the permission master data from the legacy AJAX endpoint. + const res = await fetch(`${host}/index.php?q=churchauth/ajax`, { + method: "POST", + headers: { + Accept: "application/json", + Cookie: cookie, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "func=getMasterData", + }); + if (!res.ok) throw new Error(`getMasterData failed: HTTP ${res.status}`); + const master = (await res.json()) as MasterData; + const authTable = master.data?.auth_table ?? master.auth_table; + if (!authTable || typeof authTable !== "object") { + throw new Error("Unexpected response: no data.auth_table in getMasterData. Is the endpoint/shape unchanged?"); + } + + // 4. Flatten `auth_table[module][right]` → `"module:right" → CatalogEntry`, preserving iteration order. + const rights: Record = {}; + for (const [moduleName, moduleRights] of Object.entries(authTable)) { + for (const [rightName, raw] of Object.entries(moduleRights)) { + const field = raw.datenfeld; + rights[`${moduleName}:${rightName}`] = { + authId: raw.id, + scopeField: field && String(field).length > 0 ? String(field) : null, + revocable: Boolean(raw.isRevocable), + desc: raw.bezeichnung ? String(raw.bezeichnung) : "", + }; + } + } + + const rightCount = Object.keys(rights).length; + const host_ = host.replace(/^https?:\/\//, ""); + const catalog = { + // Reserved provenance key (split off by src/permissions/catalog.ts — never seen as a right). + $meta: { + capturedFrom: host_, + ctVersion, + capturedAt: new Date().toISOString().slice(0, 10), + rightCount, + source: "POST /index.php?q=churchauth/ajax func=getMasterData", + regenerate: "npm run regenerate:permission-catalog (see src/permissions/README.md)", + }, + ...rights, + }; + + const outPath = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "permissions", "catalog.json"); + writeFileSync(outPath, `${JSON.stringify(catalog, null, 1)}\n`, "utf8"); + process.stdout.write( + `Wrote ${outPath}\n ${rightCount} rights · CT ${ctVersion} · ${host_}\n` + + `Review the diff (git diff src/permissions/catalog.json) before committing.\n`, + ); +} + +main().catch((err: unknown) => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; +}); diff --git a/src/api/ctClient.ts b/src/api/ctClient.ts index 8a5b940..f3da426 100644 --- a/src/api/ctClient.ts +++ b/src/api/ctClient.ts @@ -77,8 +77,11 @@ export class CtClient { /** * The ChurchTools release this client is talking to, once known (populated by - * {@link assertMinVersion} / any `/info` read). `null` until then. Surfaced in - * the `--env` plan header so a per-env version gate is visible (#22). + * {@link assertMinVersion} / any `/info` read, which every command runs via + * `authedSession`). `null` until then. Surfaced in the `--env` plan header so a + * per-env version gate is visible (#22), and used for the permission-catalog + * staleness warning (#25) — no extra `/info` fetch, since the version is + * already cached. */ get version(): string | null { return this.ctVersion; diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 91be137..de79272 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -67,12 +67,15 @@ export function applyCommand(): Command { const resolver = new Resolver({ client, state, desired, host: config.host }); // Independent fetches: the resource plan and the permission plan (whose instance-wide // /permissions/ reads are slow) run concurrently rather than back-to-back. - const [{ plan, actual, fetchErrors }, { items: permItems, fetchErrors: permFetchErrors }] = + const [{ plan, actual, fetchErrors }, { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }] = await Promise.all([ buildPlan(client, state, desired, { configDir, resolver }), - buildPermissionPlan(client, state, permissions, desired, resolver), + buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), ]); + // Permission catalog warnings (#25): stale-version / unknown-authId (untouched, never revoked). + for (const w of permWarnings) warn(w); + const allFetchErrors = [...fetchErrors, ...permFetchErrors]; if (allFetchErrors.length > 0) { error( diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 8e54212..a2d8f1f 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -39,10 +39,10 @@ export function planCommand(): Command { // instance means each master-data catalog is fetched at most once (cache is Promise-keyed). const resolver = new Resolver({ client, state, desired, host: config.host }); // Independent fetches run concurrently (see commands/apply.ts). - const [{ plan, fetchErrors }, { items: permItems, fetchErrors: permFetchErrors }] = + const [{ plan, fetchErrors }, { items: permItems, fetchErrors: permFetchErrors, warnings: permWarnings }] = await Promise.all([ buildPlan(client, state, desired, { configDir, resolver }), - buildPermissionPlan(client, state, permissions, desired, resolver), + buildPermissionPlan(client, state, permissions, desired, resolver, client.version ?? undefined), ]); if (opts.json) { out({ plan, permissions: permItems }); @@ -63,6 +63,10 @@ export function planCommand(): Command { } } + // Permission catalog warnings (#25): stale-version / unknown-authId. Informational — they do + // not make the plan incomplete (unlike fetchErrors), so they never set a failing exit code. + for (const w of permWarnings) warn(w); + const allFetchErrors = [...fetchErrors, ...permFetchErrors]; if (allFetchErrors.length > 0) { warn( diff --git a/src/config/context.ts b/src/config/context.ts index b6c5592..9934685 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -51,9 +51,9 @@ export interface PermissionInput { id?: number; /** `group_type_role`: the group type by name/key — sugars into a Ref-valued domainId (#20). */ groupType?: string; - /** `group_role`: the group by key (paired with `role`) — GATED, see `ref.groupRole`/#25. */ + /** `group_role`: the group by key (paired with `role`) — resolves to the pairing domainId (#25). */ group?: string; - /** `group_role`: the role name (paired with `group`) — GATED, see `ref.groupRole`/#25. */ + /** `group_role`: the role name (paired with `group`) — resolves to the pairing domainId (#25). */ role?: string; grants: Grant[]; } @@ -76,8 +76,8 @@ function domainKeyPart(domainId: number | Ref): string { /** * Resolve a permission declaration's domain to a numeric id (escape hatch) or a {@link Ref} (#20): * - `group_type_role`: numeric `id`, or logical `groupType: ""` → `ref.groupType(...)`. - * - `group_role`: numeric `id`, or logical `group` + `role` → `ref.groupRole(...)` (GATED — the - * resolver rejects it at plan time with a "pass a numeric id" error; see #25). + * - `group_role`: numeric `id`, or logical `group` + `role` → `ref.groupRole(...)` (the resolver + * maps the pair to its pairing domainId at plan time; see #25). * Declaring both a numeric `id` and a logical form is a conflict. */ function resolveDomainInput(domainType: DomainType, input: PermissionInput): number | Ref { diff --git a/src/permissions/README.md b/src/permissions/README.md index 465eac6..f9d7c56 100644 --- a/src/permissions/README.md +++ b/src/permissions/README.md @@ -21,11 +21,18 @@ changes materially. ```json { + "$meta": { "capturedFrom": "eqrm.church.tools", "ctVersion": "3.134.0", "capturedAt": "2026-07-08", "rightCount": 187 }, "churchgroup:view group": { "authId": 1104, "scopeField": "cdb_gruppe", "revocable": false, "desc": "View group incl. its group members" }, "churchcore:administer settings": { "authId": 1, "scopeField": null, "revocable": false, "desc": "Edit system settings" } } ``` +- **`$meta`** (#25) — a reserved top-level provenance key (NOT a right): + `capturedFrom`, `ctVersion`, `capturedAt`, `rightCount`. `catalog.ts` splits + it off at load so no consumer (`resolveAuthId`, `ct get permissions-catalog`, + grant adoption) ever sees it as a right. `ctVersion` drives `ct plan`'s + staleness warning — see `docs/permissions.md` "Catalog lifecycle & staleness". + - **key** — `":"`, the DSL vocabulary (e.g. `churchgroup:view group`). - **authId** — numeric id sent in the `PermissionRequest` write body. - **scopeField** — the ChurchTools data-field a scoped right applies to (`datenfeld`), or `null` @@ -43,6 +50,19 @@ churchresource, churchcheckin, churchwiki, churchreport, finance, churchsync, ## Regeneration -Open the permission editor in the CT admin with devtools recording, export the HAR, and extract -`log.entries[].response` for the `churchauth/ajax` `func=getMasterData` POST; flatten -`data.auth_table[module][right]` to `"module:right" → { authId: id, scopeField: datenfeld, revocable: !!isRevocable, desc: bezeichnung }`. +**One command (#25):** + +```bash +CT_HOST=https://your.church.tools CT_LOGINTOKEN= npm run regenerate:permission-catalog +``` + +`scripts/regenerate-permission-catalog.ts` logs in with the login token, calls +`POST /index.php?q=churchauth/ajax` `func=getMasterData`, flattens +`data.auth_table[module][right]` to +`"module:right" → { authId: id, scopeField: datenfeld, revocable: !!isRevocable, desc: bezeichnung }`, +stamps the instance's CT version into `$meta`, and rewrites this file. It only +**reads** from the instance. Review the `git diff` before committing. + +**Manual fallback (HAR):** open the permission editor in the CT admin with +devtools recording, export the HAR, extract `log.entries[].response` for the +`churchauth/ajax` `func=getMasterData` POST, and apply the same flattening. diff --git a/src/permissions/catalog.json b/src/permissions/catalog.json index 7ec1788..e14884f 100644 --- a/src/permissions/catalog.json +++ b/src/permissions/catalog.json @@ -1,4 +1,12 @@ { + "$meta": { + "capturedFrom": "eqrm.church.tools", + "ctVersion": "3.134.0", + "capturedAt": "2026-07-08", + "rightCount": 187, + "source": "POST /index.php?q=churchauth/ajax func=getMasterData", + "regenerate": "npm run regenerate:permission-catalog (see src/permissions/README.md)" + }, "churchcore:administer settings": { "authId": 1, "scopeField": null, diff --git a/src/permissions/catalog.ts b/src/permissions/catalog.ts index ccfd14a..91ef92b 100644 --- a/src/permissions/catalog.ts +++ b/src/permissions/catalog.ts @@ -11,12 +11,47 @@ import catalogData from "./catalog.json" with { type: "json" }; export interface CatalogEntry { authId: number; scopeField: string | null; revocable: boolean; desc: string } +/** + * Provenance for the catalog (#25). Recorded as a reserved top-level `$meta` key in catalog.json so + * `ct plan` can warn when the live instance's CT version drifts from the version this catalog was + * captured against (authIds/scopeFields may then be stale). The key is split out below so it is + * NEVER seen as a right by any catalog consumer (`resolveAuthId`, `ct get permissions-catalog`, + * grant adoption) — they all iterate {@link CATALOG}, which excludes it. + */ +export interface CatalogMeta { + /** Host the catalog was captured from (informational). */ + capturedFrom: string; + /** ChurchTools version at capture time, e.g. "3.134.0" — compared to the live instance at plan time. */ + ctVersion: string; + /** ISO date of capture. */ + capturedAt: string; + /** Number of rights captured (sanity check). */ + rightCount: number; + [k: string]: unknown; +} + +// Split the reserved `$meta` provenance key from the rights. Done once at module load so every +// consumer of CATALOG sees rights only, and metadata is available without a second parse. +const { $meta, ...rights } = catalogData as unknown as { $meta?: CatalogMeta } & Record; + /** * The permission catalog: name → authId bridge, inlined at build time (see the module header). * It is a constant, not something "loaded" — callers that need a snapshot already spread it, so it - * is exported directly rather than behind a `loadCatalog()` wrapper. + * is exported directly rather than behind a `loadCatalog()` wrapper. Excludes the `$meta` key. + */ +export const CATALOG = rights as Record; + +/** The catalog's recorded provenance (#25), or `null` on a legacy catalog with no `$meta` key. */ +export const CATALOG_META: CatalogMeta | null = $meta ?? null; + +/** + * Every authId the catalog knows a name for. `ct plan` uses this to detect a live grant carrying an + * authId the catalog cannot name (a stale/foreign right) — such a grant is warned about and left + * untouched, never revoked, because we cannot even describe what we would be deleting (#25). */ -export const CATALOG = catalogData as Record; +export const KNOWN_AUTH_IDS: ReadonlySet = new Set( + Object.values(CATALOG).map((e) => e.authId), +); export function resolveAuthId(name: string): CatalogEntry { const entry = CATALOG[name]; diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts index 344dbb0..38e0983 100644 --- a/src/permissions/plan.ts +++ b/src/permissions/plan.ts @@ -8,7 +8,8 @@ import type { CtClient } from "../api/ctClient.js"; import { CtApiError } from "../api/ctClient.js"; import type { State } from "../state/state.js"; import type { DesiredResource } from "../engine/types.js"; -import { resolveAuthId } from "./catalog.js"; +import { resolveAuthId, CATALOG_META, KNOWN_AUTH_IDS } from "./catalog.js"; +import { compareVersions } from "../api/version.js"; import { resolveScope } from "./scope.js"; import { normalizeActual, diffGrants, type GrantTuple, type GrantDiff, type DomainType, type RawPermission } from "./grants.js"; import type { DesiredPermission } from "./types.js"; @@ -71,7 +72,7 @@ type ResolvedPermission = DesiredPermission & { domainId: number }; * a Ref (e.g. `groupType: "…"`) resolves against the live catalog. A domainId that resolves to a * same-run-created resource (PendingRef) is rejected — the permission plan needs a concrete id to * fetch actuals and build the write path, and the permission subsystem does not defer that. A - * group_role ref throws its own gated "pass a numeric id" error from the resolver. + * group_role ref resolves to its concrete (group, role) pairing id in the resolver (#25). * * After resolution, the authoritative duplicate-target guard runs on the CONCRETE ids: two different * refs (or a ref and a number) that collide on one (domainType, domainId) would otherwise each diff @@ -112,10 +113,21 @@ async function resolveDomainIds( export async function buildPermissionPlan( client: Pick, state: State, permissions: DesiredPermission[], desired: DesiredResource[] = [], - resolver?: Resolver, -): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[] }> { + resolver?: Resolver, instanceVersion?: string, +): Promise<{ items: PermissionPlanItem[]; fetchErrors: string[]; warnings: string[] }> { const items: PermissionPlanItem[] = []; const fetchErrors: string[] = []; + const warnings: string[] = []; + // Catalog staleness (#25): the catalog is a snapshot captured against one CT version. If the live + // instance reports a different version, right names/authIds/scopeFields may have drifted — warn + // (never fail) so the diff is trusted-but-verified and the fix (regenerate) is one command away. + if (permissions.length > 0 && instanceVersion && CATALOG_META && compareVersions(instanceVersion, CATALOG_META.ctVersion) !== 0) { + warnings.push( + `Permission catalog was captured from ChurchTools ${CATALOG_META.ctVersion} but this instance ` + + `runs ${instanceVersion}. Right names/authIds may be stale — regenerate it with ` + + `\`npm run regenerate:permission-catalog\` (see docs/permissions.md).`, + ); + } // Resolve logical domainIds (#20) up front. Shares the command layer's resolver so master-data // catalogs are fetched once across buildPlan + buildPermissionPlan; falls back to a private one. const resolved = await resolveDomainIds(permissions, resolver ?? new Resolver({ client, state, desired })); @@ -135,8 +147,30 @@ export async function buildPermissionPlan( for (const p of resolved) { const all = byType.get(p.domainType); if (all == null) continue; // fetch failed for this domainType — recorded above - const actual = normalizeActual(all.filter((r) => r.domainId === p.domainId)); - items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state, declaredGroupKeys), actual) }); + const normalized = normalizeActual(all.filter((r) => r.domainId === p.domainId)); + // Unknown-authId guard (#25): a live GRANT whose authId is absent from the catalog cannot be + // named or described. Keep it OUT of the diff — otherwise, having no desired counterpart, it + // would land in `toDelete` and `ct apply` would silently revoke a right we cannot even name. + // Instead, warn (naming authId + domain) and leave it untouched. Idempotent: excluded every run. + // (Revoke/deny rows with an unknown authId are already `preserved` by diffGrants, so ignore them + // here — only unknown grant rows are the churn/silent-revoke hazard.) + const knownActual: GrantTuple[] = []; + const unknownAuthIds = new Set(); + for (const t of normalized) { + if (t.type === "grant" && !KNOWN_AUTH_IDS.has(t.authId)) { + unknownAuthIds.add(t.authId); + continue; + } + knownActual.push(t); + } + for (const authId of [...unknownAuthIds].sort((a, b) => a - b)) { + warnings.push( + `${p.domainType} #${p.domainId} ("${p.key}"): a live grant carries authId ${authId}, which is ` + + `not in the permission catalog — left untouched (never revoked). Regenerate the catalog ` + + `(\`npm run regenerate:permission-catalog\`) if this right should be manageable.`, + ); + } + items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state, declaredGroupKeys), knownActual) }); } - return { items, fetchErrors }; + return { items, fetchErrors, warnings }; } diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index 3ccecb2..083d401 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -55,8 +55,10 @@ export const ref = { 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) }), /** - * GATED (#20/#25): the (group, role) pairing id has no confirmed API source, so the resolver - * throws a clear "pass a numeric id" error at plan time. The Ref itself is inert until then. + * A `group_role` permission domain, by its (group, role) pair (#25). The resolver maps it to the + * numeric pairing domainId at plan time by matching the role name against the group's role list + * (see the ASSUMPTION block in src/resolve/resolver.ts — the exact source is unverified; the + * numeric `id:` escape hatch remains the fallback). The Ref itself is an inert sentinel until then. */ groupRole: (group: string, role: string): GroupRoleRef => ({ __ctRef: true, @@ -140,7 +142,8 @@ export function collectRefs(value: unknown): Ref[] { /** * Collect the managed logical keys named by every {@link PendingRef} in a value. Pending markers * always point at a same-run declared resource, so these keys are exactly the apply-order - * dependencies the referencing resource needs (group-role refs are gated and never go pending). + * dependencies the referencing resource needs (group-role refs resolve to a concrete id at plan + * time and never go pending). */ export function collectPendingRefKeys(value: unknown): string[] { const out: string[] = []; diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index d501413..079aea4 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -73,31 +73,31 @@ export interface ResolverDeps { } /** - * GATED (#20/#25): resolve a (group, role) pair to CT's internal group_role pairing domainId. - * TODO(#25): the candidate source is `GET /groups/{groupId}/roles` (per-group role assignments), - * but the pairing id is NOT confirmed to be exposed there — verify live on eqrm-dev before wiring - * this up. Until then the resolver rejects group_role refs with a clear "pass a numeric id" error; - * this seam exists so the lookup can be dropped in without touching call sites. + * ASSUMPTION (verify on eqrm-dev) — the pinned model for a `group_role` permission domain (#25): + * + * 1. A `group_role` domain is keyed by CT's internal (group, role) PAIRING id — one id per + * (this specific group, this specific role). It is NEITHER the group's id NOR the shared + * role-definition id (this matches the long-standing code/docs comment; docs/permissions.md + * "domainId semantics"). + * 2. That pairing id is exposed on the group's OWN role list, `GET /groups/{groupId}/roles`, as + * each row's {@link GROUP_ROLE_PAIRING_FIELD} (`id`), and rows carry a `name` we match the + * declared role against (slug-primary, exact-name secondary — same as every master-data catalog). + * + * Neither the endpoint nor the field is confirmed against a live instance. If a live check shows the + * pairing id lives in a different field (e.g. `groupRoleId`, `permissionId`) or a different endpoint, + * change the two constants below — call sites do not change. Until confirmed, the numeric `id:` escape + * hatch on `ct.groupRole` remains the safe path (and is unit-tested to still work). */ -export async function lookupGroupRolePairing( - groupId: number, - roleId: number, - client: Pick, -): Promise { - // Reference the seam's inputs so the intended call shape is documented in one place: - // const roles = await client.get(`/groups/${groupId}/roles`); find the row for `roleId`; its - // pairing id is the group_role domainId — IF the endpoint exposes it (unconfirmed). - void client; - throw new Error( - `group_role (group ${groupId}, role ${roleId}) → domainId lookup is not implemented (#25).`, - ); -} +const GROUP_ROLE_ENDPOINT = (groupId: number): string => `/groups/${groupId}/roles`; +const GROUP_ROLE_PAIRING_FIELD = "id"; export class Resolver { private readonly client: Pick; private readonly state: State; private readonly host: string; private readonly catalogs = new Map>(); + /** Per-group role list cache (group_role domain resolution), keyed by group id, fetched at most once. */ + private readonly groupRoleLists = new Map>(); /** Declared logical keys indexed by resource type — a same-run target that resolves to pending. */ private readonly declaredByType = new Map>(); @@ -172,14 +172,76 @@ export class Resolver { throw this.notFound(r, site); } - private resolveGroupRole(r: GroupRoleRef, site: string): never { + /** + * Resolve a `group_role` domain by its (group, role) pair to the numeric pairing domainId (#25). + * See the ASSUMPTION block above the {@link GROUP_ROLE_ENDPOINT} constant for the (unverified) + * model this implements. Returns a number — never a {@link PendingRef}: the pairing id only exists + * once the group does, so a same-run-declared (not-yet-created) group is a hard error here (its id + * cannot be known at plan time), telling the author to apply the group first or pass a numeric id. + */ + private async resolveGroupRole(r: GroupRoleRef, site: string): Promise { + const groupId = this.groupIdForRole(r, site); + const rows = await this.groupRoleList(groupId); + // slug-primary, exact-name secondary — identical matching to resolveFromCatalog, so a role named + // e.g. "Leiter" resolves whether the author writes "leiter" (slug) or "Leiter" (exact). + const bySlug = rows.filter((row) => typeof row.name === "string" && slug(row.name) === slug(r.role)); + const matches = bySlug.length >= 1 ? bySlug : rows.filter((row) => row.name === r.role); + if (matches.length === 0) { + const available = rows + .map((row) => (typeof row.name === "string" ? JSON.stringify(row.name) : `#${row.id}`)) + .join(", "); + throw new Error( + `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: group #${groupId} has ` + + `no role named "${r.role}"${available ? ` (available: ${available})` : ""}. Fix the role name, ` + + `or pass a numeric id.`, + ); + } + if (matches.length > 1) { + const list = matches.map((c) => `${JSON.stringify(c.name)} (#${c.id})`).join(", "); + throw new Error( + `Ambiguous ${refLabel(r)} referenced at ${site} on ${this.host}: ${matches.length} roles on ` + + `group #${groupId} match — ${list}. Rename to disambiguate, or pass a numeric id.`, + ); + } + const domainId = matches[0]![GROUP_ROLE_PAIRING_FIELD]; + if (typeof domainId !== "number") { + throw new Error( + `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: the matched role row ` + + `carries no numeric "${GROUP_ROLE_PAIRING_FIELD}" (the assumed pairing domainId — see #25). ` + + `Pass a numeric id.`, + ); + } + return domainId; + } + + /** Resolve the group half of a group_role ref to a managed group id (state ∪ declared). */ + private groupIdForRole(r: GroupRoleRef, site: string): number { + const managed = this.state.resources[r.group]; + if (managed && managed.type === "group") return managed.id; + if (this.declaredByType.get("group")?.has(r.group)) { + throw new Error( + `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: group "${r.group}" is ` + + `declared in this config but not yet created — its (group, role) pairing id only exists once ` + + `the group does. Apply the group first, then re-run, or pass a numeric id.`, + ); + } throw new Error( - `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: resolving a ` + - `(group, role) pair to its permission domainId is not yet supported — pass a numeric id ` + - `instead (see #25).`, + `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: no managed group named ` + + `"${r.group}" is declared or adopted. Declare/adopt it, fix the key, or pass a numeric id.`, ); } + private groupRoleList(groupId: number): Promise { + let p = this.groupRoleLists.get(groupId); + if (!p) { + p = this.client + .get(GROUP_ROLE_ENDPOINT(groupId)) + .then((rows) => (Array.isArray(rows) ? rows : [])); + this.groupRoleLists.set(groupId, p); + } + return p; + } + private notFound(r: SimpleRef, site: string): Error { const catalog = CATALOG_PATH[r.kind]; const where = catalog @@ -219,8 +281,9 @@ export function reresolvePendingValue(value: unknown, state: State): unknown { function pendingIdFromState(r: Ref, state: State): number { if (r.kind === "group-role") { - // group_role refs are gated at plan time, so a pending one should never reach apply. - throw new Error(`Pending ${refLabel(r)} reached apply — group_role refs are unsupported (#25).`); + // A group_role ref resolves to a concrete pairing id at plan time (never a PendingRef — a + // same-run group is rejected up front), so a pending one should never reach apply. + throw new Error(`Pending ${refLabel(r)} reached apply — group_role refs never go pending (#25).`); } const managed = state.resources[r.key]; if (!managed) { diff --git a/tests/apply-env-command.test.ts b/tests/apply-env-command.test.ts index c584e7c..70a44c5 100644 --- a/tests/apply-env-command.test.ts +++ b/tests/apply-env-command.test.ts @@ -33,7 +33,7 @@ vi.mock("../src/engine/build.js", () => ({ })); vi.mock("../src/engine/execute.js", () => ({ executePlan })); vi.mock("../src/permissions/plan.js", () => ({ - buildPermissionPlan: vi.fn(async () => ({ items: [], fetchErrors: [] })), + buildPermissionPlan: vi.fn(async () => ({ items: [], fetchErrors: [], warnings: [] })), })); vi.mock("../src/permissions/apply.js", () => ({ applyPermissionPlan: vi.fn(async () => ({ granted: 0, deleted: 0, failed: [] })), diff --git a/tests/permission-catalog.test.ts b/tests/permission-catalog.test.ts index 9daba04..15a54a4 100644 --- a/tests/permission-catalog.test.ts +++ b/tests/permission-catalog.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { resolveAuthId, CATALOG } from "../src/permissions/catalog.js"; +import { resolveAuthId, CATALOG, CATALOG_META, KNOWN_AUTH_IDS } from "../src/permissions/catalog.js"; describe("permission catalog", () => { it("resolves a known global right to its authId", () => { @@ -18,4 +18,19 @@ describe("permission catalog", () => { it("exposes the whole catalog (187 rights)", () => { expect(Object.keys(CATALOG).length).toBeGreaterThanOrEqual(180); }); + it("does NOT expose the reserved $meta key as a right", () => { + expect(CATALOG).not.toHaveProperty("$meta"); + expect(resolveAuthId("churchgroup:view")).toBeTruthy(); // sanity: rights still resolve + expect(() => resolveAuthId("$meta")).toThrow(/unknown permission/i); + }); + it("records provenance (CT version) for staleness detection (#25)", () => { + expect(CATALOG_META).not.toBeNull(); + expect(CATALOG_META?.ctVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(CATALOG_META?.rightCount).toBe(Object.keys(CATALOG).length); + }); + it("exposes the set of known authIds (for the unknown-authId plan warning)", () => { + expect(KNOWN_AUTH_IDS.has(1104)).toBe(true); // churchgroup:view group + expect(KNOWN_AUTH_IDS.has(999999)).toBe(false); + expect(KNOWN_AUTH_IDS.size).toBe(new Set(Object.values(CATALOG).map((e) => e.authId)).size); + }); }); diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index d5d0b54..22447d7 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { desiredTuples, buildPermissionPlan } from "../src/permissions/plan.js"; +import { CATALOG_META } from "../src/permissions/catalog.js"; +import { ref } from "../src/resolve/refs.js"; import type { State } from "../src/state/state.js"; const state: State = { version: 1, host: "h", resources: { @@ -108,4 +110,52 @@ describe("buildPermissionPlan", () => { expect(items[0]?.diff.toPut).toEqual([]); expect(items[0]?.diff.toDelete).toEqual([]); }); + + it("resolves a group_role domain by (group, role) reference and reconciles idempotently (#25)", async () => { + const client = { get: vi.fn(async (path: string) => { + if (path === "/groups/42/roles") return [{ id: 2882, name: "Leiter" }]; + if (path === "/permissions/group_role") return [ + { domainType: "group_role", domainId: 2882, authId: 1104, dataId: 42, type: "grant", meta: { modifiedPid: 1 } }, + ]; + throw new Error(`unexpected path ${path}`); + }) }; + // Declared with ZERO numeric ids: right name + group key + role name only. + const { items, warnings, fetchErrors } = await buildPermissionPlan(client as never, state, [ + { key: "kids_lead", domainType: "group_role", domainId: ref.groupRole("kids_area", "Leiter"), + grants: [{ right: "churchgroup:view group", scope: ["kids_area"] }] }, + ]); + expect(fetchErrors).toEqual([]); + expect(warnings).toEqual([]); + expect(items[0]?.domainId).toBe(2882); // resolved from the (group, role) pair + expect(items[0]?.diff.toPut).toEqual([]); // adopted live row already matches → no-op + expect(items[0]?.diff.toDelete).toEqual([]); + }); + + it("warns and never revokes a live grant whose authId is unknown to the catalog (#25)", async () => { + const client = { get: vi.fn(async () => [ + { domainType: "group_type_role", domainId: 8, authId: 1113, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // known + desired + { domainType: "group_type_role", domainId: 8, authId: 987654, dataId: null, type: "grant", meta: { modifiedPid: 1 } }, // unknown authId + ]) }; + const { items, warnings } = await buildPermissionPlan(client as never, state, + [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }]); + expect(items[0]?.diff.toDelete).toEqual([]); // the unnameable grant is NOT proposed for revocation + expect(items[0]?.diff.toPut).toEqual([]); + expect(warnings.some((w) => w.includes("987654") && w.includes("group_type_role #8"))).toBe(true); + }); + + it("warns when the instance CT version differs from the catalog's recorded version (#25)", async () => { + const client = { get: vi.fn(async () => []) }; + const { warnings } = await buildPermissionPlan(client as never, state, + [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], + [], undefined, "9.99.0"); + expect(warnings.some((w) => /catalog was captured from ChurchTools .* but this instance\s+runs 9\.99\.0/is.test(w))).toBe(true); + }); + + it("does NOT warn about staleness when the instance version matches the catalog version (#25)", async () => { + const client = { get: vi.fn(async () => []) }; + const { warnings } = await buildPermissionPlan(client as never, state, + [{ key: "t", domainType: "group_type_role", domainId: 8, grants: ["churchgroup:administer groups"] }], + [], undefined, CATALOG_META!.ctVersion); + expect(warnings).toEqual([]); + }); }); diff --git a/tests/plan-env-command.test.ts b/tests/plan-env-command.test.ts index c8fc9cb..a16ce9e 100644 --- a/tests/plan-env-command.test.ts +++ b/tests/plan-env-command.test.ts @@ -40,7 +40,7 @@ vi.mock("../src/engine/build.js", () => ({ })); vi.mock("../src/permissions/plan.js", () => ({ - buildPermissionPlan: vi.fn(async () => ({ items: [], fetchErrors: [] })), + buildPermissionPlan: vi.fn(async () => ({ items: [], fetchErrors: [], warnings: [] })), })); const { planCommand } = await import("../src/commands/plan.js"); diff --git a/tests/portable-refs.test.ts b/tests/portable-refs.test.ts index 8b01fcb..9c97ec7 100644 --- a/tests/portable-refs.test.ts +++ b/tests/portable-refs.test.ts @@ -184,13 +184,36 @@ describe("permission domainId resolution", () => { ); }); - it("rejects a gated group_role reference at plan time", async () => { + it("resolves a group_role (group, role) reference to the pairing domainId at plan time (#25)", async () => { const { permissions } = await evaluateConfig((ct) => { ct.groupRole({ key: "p", group: "kids", role: "Leiter", grants: ["churchgroup:administer groups"] }); }); + const state: State = { ...emptyState("h"), resources: { + kids: { type: "group", id: 42, key: "kids", fields: {}, adoptedAt: "t", updatedAt: "t" }, + } }; + const client = { + get: async (path: string): Promise => { + if (path === "/groups/42/roles") return [{ id: 7001, name: "Leiter" }] as T; + if (path === "/permissions/group_role") return [] as T; + throw new CtApiError(`not found: ${path}`, 404, null); + }, + }; + const { items } = await buildPermissionPlan(client, state, permissions); + expect(items[0]?.domainId).toBe(7001); + // No live grant yet → the one declared grant is proposed to add (churchgroup:administer groups, unscoped). + expect(items[0]?.diff.toPut).toEqual([{ authId: 1113, dataId: [], type: "grant" }]); + }); + + it("still rejects a group_role reference to a not-yet-created group (pass a numeric id) (#25)", async () => { + const { permissions } = await evaluateConfig((ct) => { + ct.group({ key: "kids", name: "Kids", groupTypeId: 2 }); + ct.groupRole({ key: "p", group: "kids", role: "Leiter", grants: ["churchgroup:administer groups"] }); + }); const client = { get: async (): Promise => [] as T }; - await expect(buildPermissionPlan(client, emptyState("h"), permissions)).rejects.toThrow( - /not yet supported.*pass a numeric id.*#25/, + // `desired` includes the same-run group, so the resolver knows it is declared-but-pending. + const desired = [{ type: "group", key: "kids", fields: {}, dependsOn: [] }]; + await expect(buildPermissionPlan(client, emptyState("h"), permissions, desired)).rejects.toThrow( + /declared in this config but not yet created.*pass a numeric id/is, ); }); }); diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index 02535c2..f6195c6 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -78,11 +78,45 @@ describe("Resolver.resolve", () => { ); }); - it("throws the gated error for a group_role reference", async () => { + it("resolves a group_role (group, role) pair to the pairing domainId via the group's role list (#25)", async () => { + const state = stateWith({ + kids: { type: "group", id: 42, key: "kids", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }); + const client = fakeClient({ + "/groups/42/roles": [{ id: 2882, name: "Leiter" }, { id: 2883, name: "Mitglied" }], + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); + // slug("Leiter") === "leiter", so either the slug key or the exact name resolves. + expect(await r.resolve(ref.groupRole("kids", "leiter"), "perm \"p\"")).toBe(2882); + expect(await r.resolve(ref.groupRole("kids", "Mitglied"), "perm \"p\"")).toBe(2883); + expect(client.calls["/groups/42/roles"]).toBe(1); // fetched once, cached across both refs + }); + + it("errors clearly when the role name is not on the group's role list", async () => { + const state = stateWith({ + kids: { type: "group", id: 42, key: "kids", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }); + const client = fakeClient({ "/groups/42/roles": [{ id: 2882, name: "Leiter" }] }); + const r = new Resolver({ client, state, desired: NO_DESIRED, host: "hostA" }); + await expect(r.resolve(ref.groupRole("kids", "Ghost"), "perm \"p\"")).rejects.toThrow( + /group #42 has no role named "Ghost".*available: "Leiter".*pass a numeric id/is, + ); + }); + + it("errors when a group_role names a group that isn't managed", async () => { const client = fakeClient({}); const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); - await expect(r.resolve(ref.groupRole("g", "Leiter"), "perm \"p\"")).rejects.toThrow( - /not yet supported.*pass a numeric id.*#25/, + await expect(r.resolve(ref.groupRole("ghost", "Leiter"), "perm \"p\"")).rejects.toThrow( + /no managed group named "ghost".*pass a numeric id/is, + ); + }); + + it("errors when a group_role names a same-run-declared (not-yet-created) group", async () => { + const desired: DesiredResource[] = [{ type: "group", key: "kids", fields: {}, dependsOn: [] }]; + const client = fakeClient({}); + const r = new Resolver({ client, state: emptyState("h"), desired }); + await expect(r.resolve(ref.groupRole("kids", "Leiter"), "perm \"p\"")).rejects.toThrow( + /declared in this config but not yet created.*Apply the group first/is, ); });