diff --git a/README.md b/README.md index 9c6d1b8..cf01ede 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ confirmation — and a declaration marked `preventDestroy: true` blocks even tha | --- | --- | --- | | Campuses | `ct.campus` | [config guide](docs/configuration.md) | | Groups (fields, campus, multi-parent hierarchy) | `ct.group` | [config guide](docs/configuration.md) | -| Group types, roles, age/target groups, relationship types | `ct.groupType`, `ct.roleDefinition`, `ct.ageGroup`, `ct.targetGroup`, `ct.relationshipType` | reusable building blocks | +| Group types, roles, age/target groups, relationship types, person statuses | `ct.groupType`, `ct.roleDefinition`, `ct.ageGroup`, `ct.targetGroup`, `ct.relationshipType`, `ct.personStatus` | reusable building blocks | | Permissions (group-role, group-type-role, person-status) | `ct.groupRole`, `ct.groupTypeRole`, `ct.status` | [permissions](docs/handbuch/permissions.md) | | Auto-groups (dynamic groups) | the `dynamic` block on a group | [dynamic groups](docs/handbuch/dynamic-groups.md) | | Repeated structure, parametrized | a plain function over the DSL | [blueprints](docs/handbuch/blueprints.md) | diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 66169db..1da5b59 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -29,11 +29,13 @@ Methods marked only if they actually exist on the matched path. "Update" = PUT o | 10 | `ct_target_group` | `/group/targetgroups`, `/group/targetgroups/{targetGroupId}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | | 11 | `ct_meeting_point` | — none — | ❌ | ❌ | ❌ | ❌ | **Not in API → fully manual.** Zero matches for `treffpunkt`/`meetingpoint`/`meeting point` anywhere in the spec. Closest neighbours are _meeting templates_ (`/group/meetingtemplates`, full CRUD) and _group meetings_ (`/groups/{groupId}/meetings`, CRUD) — different concepts; confirm with product whether "meeting point" was meant to be one of those. | | 12 | `ct_relationship_type` | `/person/relationshiptypes`, `/person/relationshiptypes/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD** | +| 13 | `ct_person_status` | `/statuses`, `/statuses/{id}` | list ✅ / by-id ✅ | ✅ | ✅ PUT | ✅ | **Full CRUD, live-verified 2026-08-13** (eqrm prod, CT 3.135.2, read from the instance OpenAPI spec): `/statuses` → GET, POST; `/statuses/{id}` → GET, PUT, DELETE. `POST` requires `name`, `shorty`, `isMember`; `PUT` requires ALL of `name`, `shorty`, `isMember`, `isSearchable`, `sortKey`, `securityLevelId` — uniquely strict among managed types (every other managed PUT declares no required fields), and since PUT is a full replace the registry manages all six rather than a subset. The person/community **Status** master data (tag `Status`): "0 - First", "3 - Group Active", …, the domain a `ct.status` permission grant hangs off. Adoptable since #96, which is what makes a config using that domain self-sufficient across hosts. Do NOT conflate with `ct_group_status` (row 8, no endpoint at all) or `/group/memberstatus` (member statuses, string ids). Master data — never a person record; the people guard is unaffected. | +| 14 | `ct_department` | `/departments` | list ✅ / by-id ❌ | ❌ | ❌ | ❌ | **Read-only — a ref catalog, not a managed resource.** Live-probed 2026-08-13 (eqrm prod, CT 3.135.2): `GET /departments` returns `[{id, name, nameTranslated, sortKey, shorty}]`; no `POST`/`PUT`/`DELETE` on `/departments` exists in the spec, and there is no `/departments/{id}` path at all. Bereiche are the `cdb_bereich` permission scope dimension (`churchdb:view alldata`), so `ct` resolves them BY NAME for a `scope: [{ department: "…" }]` reference (#98) and surfaces them via `ct get departments` — but they can never be declared, adopted or created. | > **Create-time required fields (#73).** The "POST ✅" marks above were spec-derived, not live-exercised for > create. CT's POST validators require fields the tool does not manage for diffing: `group-type` needs > `namePlural`/`shorty`/`color`/`permissionDepth`/`isLeaderNecessary`/`availableForNewPerson` (+ `sortKey`/`postsEnabled`), -> and `group-role` needs `shorty`. These are supplied as deterministic create-only defaults +> and `group-role` + `person-status` need `shorty`. These are supplied as deterministic create-only defaults > (`AdoptableResource.createDefaults`, derived from the declared `name`) — merged into the POST body only, never > recorded in state, so they stay unmanaged. `campus` (name+shorty, live-verified), `group`, `age-group`, and > `target-group` need only `name` from their managed set and require no defaults. @@ -51,6 +53,7 @@ Standard collection-POST + item-GET/PUT(-or-PATCH)/DELETE shape, safe to drive f - `ct_age_group` - `ct_target_group` - `ct_relationship_type` +- `ct_person_status` ### Writable via non-standard verbs (support, but special-case the client) — 2 resources diff --git a/docs/handbuch/blueprints.md b/docs/handbuch/blueprints.md index 4f459be..c72766f 100644 --- a/docs/handbuch/blueprints.md +++ b/docs/handbuch/blueprints.md @@ -4,8 +4,8 @@ sources: - src/config/context.ts - src/engine/graph.ts - src/engine/hierarchy.ts -sources_hash: d69dbf11ba71e8eb -reviewed: 2026-08-10 +sources_hash: e1bc9429a635e819 +reviewed: 2026-08-13 --- # Blueprints (parametrized, reusable config) diff --git a/docs/handbuch/field-definitions.md b/docs/handbuch/field-definitions.md index 83afb31..623def0 100644 --- a/docs/handbuch/field-definitions.md +++ b/docs/handbuch/field-definitions.md @@ -3,8 +3,8 @@ title: Field definitions & security levels sources: - src/commands/get.ts - src/api/ctClient.ts -sources_hash: 580645532edbdf9a -reviewed: 2026-08-10 +sources_hash: 24ea82f0326ed7d5 +reviewed: 2026-08-13 --- # Field definitions & security levels (#47, #48) @@ -21,6 +21,17 @@ the data-field definitions ("Datenfelder") for both persons and groups. | The security-level model (levels + what visibility they gate) | Which persons exist, their memberships, their master-data values | | The person master-data model (sexes, titles, statuses, campuses, …) | Assigning/reading a person's status, sex, campus, etc. | +> **Exception — person statuses are managed, not just read (#96).** The status +> *enumeration* ("0 - First", "3 - Group Active", …) is a declarable resource: +> `ct.personStatus({ key, name, shorty })`, adoptable with +> `ct adopt person-status ` and listable with `ct get statuses` +> (`/statuses`, full CRUD). That is what lets a config declare permission grants +> on a status (`ct.status`) and still stand up on a fresh host. Which *person* +> carries which status remains permanently out of scope, like every other +> per-record value. Campuses are likewise managed (`ct.campus`); the rest of the +> master-data model — sexes, titles, the security-level enumeration — stays +> read-only. + This mirrors the tool's permanent people boundary (README "People are never managed"; `assertNotPeople` in `src/engine/guard.ts`). The commands below read *definitions only*; none of them read or write a per-record field value. diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index d10b596..c9493eb 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -5,7 +5,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: ffcfe8dc0516ccf3 +sources_hash: 03c3ae24524afcc9 reviewed: 2026-08-13 --- @@ -60,18 +60,17 @@ reference or a numeric `id`: 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. + See "domainId semantics" for how the pairing id is resolved. - `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 - **scoped** grant, where each `scope` entry is either a logical key of a - managed group, or a raw numeric `dataId` (the escape hatch, #49 — see - "Scope resolution" below). + - an object `{ right: "module:right", scope: [...] }` — a **scoped** grant, + where each `scope` entry is a logical key of a managed group, a typed + logical reference such as `{ campus: "koblenz" }` (#98), or a raw numeric + `dataId` (the escape hatch, #49). See "Scope resolution" below. ## Discovering right names — `ct get permissions-catalog` @@ -158,18 +157,54 @@ The two DSL functions manage two different ChurchTools "domain types," and > 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 - > (`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. + Since #96 the status itself is also **declarable**, via `ct.personStatus`: + + ```ts + ct.personStatus({ key: "3_group_active", name: "3 - Group Active", shorty: "GA" }); + ct.status({ key: "3_group_active_login", personStatus: "3_group_active", grants: [...] }); + ``` + + **Key it as `slug(name)`.** A `personStatus:` reference resolves against + managed state first and the live `/statuses` catalog second, and the catalog + matches by `slug(name)` — so a key that does not slug from the name (`"core"` + for `"5 - Core"`) can only ever match the declaration. On a host that already + has that status but has not adopted it, the plan then **creates a second, + identically-named status** and grants on the new one, leaving the real one + untouched. `ct adopt person-status ` emits `slug(name)` for this reason; + match it. + + > **Teardown caveat.** A person status is the one managed type whose deletion + > reaches person *records*: dropping the declaration and running `ct destroy` + > deletes the status, and ChurchTools re-stamps every person carrying it. + > `assertNotPeople` cannot catch this (`/statuses/{id}` is not a people path), + > so `ct destroy` warns explicitly for this type and `--force` does **not** + > skip its confirmation. Set `preventDestroy: true` on the declaration if the + > status is load-bearing. + + That is what makes a config using the `status` domain self-sufficient across + hosts. Before it, `personStatus: "…"` could only resolve against statuses that + already existed on the target instance, so a config that planned to a clean + no-op on prod died on dev with *"no managed resource and no live person-status + at /statuses matches key …"* — whose own advice ("Declare/adopt it") was not + actually possible. A status declared in the same config resolves to a pending + domain and converges in one `ct apply`, exactly like a same-run group type. + + > **VERIFIED LIVE (2026-08-13, CT 3.135.2).** 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. Confirmed against two + > anchors on **different group types**, each chosen so the per-group `id` and + > the type-level `groupTypeRoleId` necessarily differ: + > + > - both role rows' `id`s appear in `GET /permissions/group_role` as live + > `domainId`s carrying that role's authored grants; + > - neither row's `groupTypeRoleId` appears anywhere in the domainId set — + > decisive, since a type-scoped key would have to; + > - roles with no authored rights have no domain at all, exactly as a + > per-(group, role) pairing predicts. + > + > The numeric `id:` escape hatch remains fully supported: find the id via the + > ChurchTools permission editor or a `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 @@ -207,11 +242,16 @@ scenario). ## Scope resolution -A scoped grant's `scope: [...]` is a list where each entry is either a -**logical key of a group managed by this tool** (declared via `ct.group` or -adopted into state), or a **raw numeric `dataId`** (the escape hatch — see -below). String entries are resolved against **desired ∪ state** -(`src/permissions/scope.ts`): +A scoped grant's `scope: [...]` is a list where each entry is one of three +forms (`src/permissions/scope.ts`): + +| Form | Example | Dimension | +| --- | --- | --- | +| **Logical group key** (string) | `scope: ["kids_area"]` | groups (`cdb_gruppe`) only | +| **Typed logical reference** (#98) | `scope: [{ campus: "koblenz" }]` | campuses, group types — see below | +| **Raw numeric `dataId`** (escape hatch, #49) | `scope: [1, 2, 3]` | any dimension | + +String entries are resolved against **desired ∪ state**: - A key already in state resolves to that group's `dataId`. - A key **declared in this config but not yet created** resolves to a *pending* @@ -237,6 +277,84 @@ post-execute state. This means a group *created* or *recreated* in the same apply always gets its grant written with its fresh `dataId`, never a pending placeholder or a stale, dangling id. +### Typed logical scope references (#98) + +Not every scoped right scopes **by group**, and some of the other dimensions are +resources this tool *can* name portably. Those take a typed reference: + +```ts +ct.groupRole({ + key: "campus_lead_grants", + group: "campus_lead", + role: "Leiter", + grants: [ + { right: "churchdb:view station", scope: [{ campus: "koblenz" }] }, + { right: "churchgroup:view groups of grouptype", scope: [{ groupType: "struktur" }] }, + ], +}); +``` + +`{ campus: "koblenz" }` is sugar for `ref.campus("koblenz")` — the same `Ref` +the rest of the DSL uses — so both spellings are interchangeable. + +| `scopeField` | Reference form | Resolved against | +| --- | --- | --- | +| `cdb_gruppe` | `{ group: "" }` (or the bare string) | managed groups | +| `cdb_station` | `{ campus: "" }` | managed campuses, then `GET /campuses` | +| `cdb_gruppentyp` | `{ groupType: "" }` | managed group types, then `GET /group/grouptypes` | +| `cdb_bereich` | `{ department: "" }` | `GET /departments` **only** — read-only, see below | + +**Why this matters:** campus ids are host-specific — Mainz is `0` on eqrm prod +and `6` on eqrm dev. A campus-scoped grant written as a numeric literal is +therefore a cross-environment misgrant, and because declaring a domain makes +`ct` *own* it, the wrong-scope grant also revokes whatever is really there on +the other host. The typed reference makes one config plan clean on both. + +Resolution mirrors the domain-reference rules: managed state first, the live +master-data catalog second, and a target **declared in this same config** +resolves to a *pending* scope re-resolved at apply time. A reference resolved +through the catalog (not under management) carries an already-final id and is +not re-resolved. Catalogs are read **paginated** — ChurchTools returns only a +first page (10 rows) for a plain list read, so an instance with more campuses, +group types or departments than that would otherwise report a perfectly real +name as unresolvable. + +Three things are hard errors at **plan** time, never a guessed `dataId`: + +- a reference whose dimension does not match the right's `scopeField` — + e.g. `{ groupType: … }` on `churchdb:view station` (a `cdb_station` right); +- a reference on a dimension that has **no** logical form (`cc_securitylevel`, + `ccm_data_category`, `oauth_client`, … — values that are not resources at + all) — the message points at the numeric escape hatch; +- a **bare string** on a non-group dimension. A string always means "managed + group", so on e.g. a `cdb_station` right it would either fail confusingly or — + worse — match an unrelated group that happens to carry that key. + +### Departments are referenceable, never declarable + +`cdb_bereich` (Bereiche) is the one dimension with a **read-only** catalog. +Live-probed against the instance's own OpenAPI spec (eqrm prod, CT 3.135.2, +2026-08-13): `GET /departments` exists, and **no** `POST`/`PUT`/`DELETE` on +`/departments` does. + +So a department reference resolves by name on every host — which is what makes +`churchdb:view alldata` ("Personen eines Bereiches sehen") declarable at all — +but there is no `ct.department` resource, no `ct adopt department`, and a name +that matches nothing is a **hard error** rather than a create: + +``` +Cannot resolve department:nope referenced at … : no live department at +/departments matches key "nope". departments are a read-only catalog in +ChurchTools — they cannot be declared or adopted, so fix the key/name +(list them with `ct get departments`) or use a numeric id. +``` + +Discover the names with `ct get departments`. Because the id always comes from +the live catalog, a department scope is never "pending" and is never re-resolved +at apply time — it is host-correct the moment it resolves. A managed resource +that happens to share the key does **not** shadow it (that would be exactly the +misgrant this feature exists to prevent). + ### Numeric scope escape hatch (#49) Not every scoped right's `scope` dimension is a **group**. The catalog's @@ -276,8 +394,11 @@ 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` array. `ct adopt grants` emits this form automatically for any scoped right -whose `scopeField` is not the group dimension (see below) — never a `ct adopt -group ` hint for a dataId that was never a group. +whose `scopeField` has no logical form (see below) — never a `ct adopt group +` hint for a dataId that was never a group. For `cdb_station` / +`cdb_gruppentyp` it emits a typed reference instead when the dataId names a +managed campus / group type, and otherwise leaves the number with a `NOTE` +naming the one `ct adopt …` command that makes it portable. ## Adopting existing grants — `ct adopt grants ` diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index 56def4a..2a3b556 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -21,6 +21,7 @@ Every item below falls into exactly one of three buckets: | ------------------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Group member-statuses | The set of "member status" values a group can assign to its members (e.g. active/candidate) — NOT the same dimension as a group's own `groupStatusId`, which IS fully managed (`ct.group({ groupStatusId })`, [`docs/group-field-decisions.md`](group-field-decisions.md)); do not conflate the two (#67) | Only `GET /group/memberstatus` exists; no create/update/delete endpoint ([`docs/api-coverage.md`](api-coverage.md) #8) | Group settings → member status admin (org-wide master data, not per-group) | `ct get raw /group/memberstatus` (no dedicated `ct get member-statuses` subcommand yet — the generic `raw` path covers it) and diff by eye against the expected list below | | Meeting points (Treffpunkte) | A group's meeting-location master data | No endpoint at all — zero matches for `treffpunkt`/`meetingpoint` anywhere in the OpenAPI spec ([`docs/api-coverage.md`](api-coverage.md) #11) | Group admin → meeting point field on a group | No API verification possible; visually confirm in the UI. (Do not confuse with _meeting templates_ `/group/meetingtemplates` or _group meetings_ `/groups/{id}/meetings`, both full CRUD but different concepts — confirm with product if "meeting point" was meant to be one of those instead) | +| Bereiche (departments) — creating/renaming one | The Bereich master data (`/departments`) — the `cdb_bereich` permission scope dimension | `GET /departments` exists but no write verb does — no `POST`/`PUT`/`DELETE`, and no `/departments/{id}` path at all (live-probed against the instance OpenAPI spec, eqrm prod CT 3.135.2, 2026-08-13; [`docs/api-coverage.md`](api-coverage.md) #14) | Master data admin → Bereiche | `ct get departments` lists them. **Grants scoped to one ARE declarable** — `scope: [{ department: "" }]` resolves by name per host (#98) — so only CREATING or RENAMING a Bereich is manual. Renaming one in CT breaks every config reference to its old name, by design: the reference then hard-errors instead of silently regranting elsewhere | | Permission name↔authId catalog | The mapping from human-readable `module:right` names to the numeric `authId` the API actually writes | Not exposed by the REST API at all; only servable via the legacy `POST /index.php?q=churchauth/ajax&func=getMasterData` call ([`src/permissions/README.md`](../src/permissions/README.md)) | Permission editor (any role's right-picker enumerates the live set) | Regeneration procedure below (**Permission catalog lifecycle**) | **Expected values for a given instance:** left blank here deliberately — this @@ -33,9 +34,9 @@ in that instance's own config repo, in a runbook following 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, #25).** Configs reference master data by name/key — `campus`/`groupType` 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. **`status` (group status) is NOT part of this** (#67) — group statuses have no REST catalog, so `status:` fails fast at eval time; declare the numeric `groupStatusId` directly | [#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) | +| Portable/logical references | **Shipped (#20, #25).** Configs reference master data by name/key — `campus`/`groupType` 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. **`status` (group status) is NOT part of this** (#67) — group statuses have no REST catalog, so `status:` fails fast at eval time; declare the numeric `groupStatusId` directly | [#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`. The `group_role` pairing-id resolution is verified live (row below) | | 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 **(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/handbuch/permissions.md`](handbuch/permissions.md) "domainId semantics") | +| Permission `group_role` domain by reference **(shipped, verified live)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **Confirmed live 2026-08-13 (CT 3.135.2):** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId. Two anchors on different group types: each row's `id` is a live `group_role` domainId carrying that role's grants, while its type-level `groupTypeRoleId` appears nowhere in the domainId set | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verified) | None needed. Works by reference for managed, already-created groups; numeric `id:` remains a supported escape hatch ([`docs/handbuch/permissions.md`](handbuch/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/handbuch/permissions.md`](handbuch/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~~ **(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/handbuch/permissions.md`](handbuch/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 | | Field definitions & security levels (person + group custom fields) **(read-only, shipped #47/#48)** | The person master-data model, the security-level enumeration, and the data-field DEFINITIONS ("Datenfelder") for persons and groups — structural schema, not per-record values | [#47](https://github.com/eqrm/ct-cli/issues/47), [#48](https://github.com/eqrm/ct-cli/issues/48) (read shipped; write is an API gap — see note) | Read with `ct get person-masterdata` (model + security levels) and `ct get data-fields` (all field definitions, person + group, discriminated by `fieldCategory`). **Mutation stays manual:** field definitions have no REST write endpoint — only the legacy churchdb admin AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) — so create/edit/delete them by hand in the master-data admin UI. Decision + evidence: [`docs/handbuch/field-definitions.md`](handbuch/field-definitions.md) | diff --git a/examples/permissions.config.ts b/examples/permissions.config.ts index d4f1a56..569d9c0 100644 --- a/examples/permissions.config.ts +++ b/examples/permissions.config.ts @@ -39,8 +39,8 @@ export default (ct: ConfigContext): void => { // 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/handbuch/permissions.md "domainId semantics" for the - // resolution assumption still to be confirmed live.) + // of `group`/`role`. (See docs/handbuch/permissions.md "domainId semantics" — + // the pairing-id resolution is verified live as of CT 3.135.2.) ct.groupRole({ key: "kids_leiter_grant", group: "kids_area", @@ -48,17 +48,65 @@ export default (ct: ConfigContext): void => { grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }], }); - // 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 - // domain (people are never managed by this tool). + // Typed logical scope refs (#98): a scoped right whose dimension is NOT a + // group, but IS something this tool can name portably. Campus ids differ per + // host (Mainz is 0 on one instance, 6 on another), so the numeric literal that + // used to be the only option here was a cross-environment misgrant waiting to + // happen. `{ campus: "mainz" }` is sugar for `ref.campus("mainz")`. + // + // The reference's dimension is checked against the right's catalog `scopeField` + // at plan time, so a mismatched ref fails with both named — never a silent + // grant on the wrong dimension. Dimensions with no logical form (security + // levels, flow-manager categories, …) still take numeric dataIds. + ct.campus({ key: "mainz", name: "Mainz", shorty: "MZ" }); + ct.groupRole({ + key: "mainz_lead_grant", + group: "kids_area", + role: "Mitarbeiter", + grants: [ + { right: "churchdb:view station", scope: [{ campus: "mainz" }] }, + { right: "churchgroup:view groups of grouptype", scope: [{ groupType: "kids" }] }, + // Bereiche/departments are a READ-ONLY catalog (`GET /departments`, no write verb), so a + // department is referenceable by name on every host but never declarable — an unknown name + // is a hard error, not a create. Discover them with `ct get departments`. + { right: "churchdb:view alldata", scope: [{ department: "outreach" }] }, + ], + }); + + // status (#90): the domain is a PERSON status. Declared by name and resolved + // against managed state first, then 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). + // + // The status itself is declarable too (#96), which is what makes this config + // self-sufficient on a fresh instance: without it, every target host would + // need a byte-identically-named status created by hand first. // // `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. + // All six fields are declared because CT's `PUT /statuses/{id}` requires every one of them and is + // a full replace — unlike every other managed type, a partial declaration here would 400 (and, if + // it didn't, blank the omitted fields). `ct adopt person-status ` emits exactly this shape. + // + // The key is `slug(name)` on purpose. A `personStatus:` reference resolves against managed state + // FIRST and the live `/statuses` catalog second (which matches by `slug(name)`), so a key that + // does not slug from the name — `"core"` for "5 - Core" — can only ever match this declaration: + // on a host that already HAS a "5 - Core" status but has not adopted it, the plan would create a + // second, identically-named status and hang the grants off that one, leaving the real status + // ungranted. `ct adopt person-status ` emits `slug(name)` for the same reason. + ct.personStatus({ + key: "5_core", + name: "5 - Core", + shorty: "Core", + isMember: true, + isSearchable: true, + sortKey: 50, + securityLevelId: 1, + }); ct.status({ key: "core_external_login", - personStatus: "5 - Core", + personStatus: "5_core", grants: [{ right: "churchcore:login to external system", scope: [-1] }], }); }; diff --git a/src/commands/destroy.ts b/src/commands/destroy.ts index 22c6384..2ef905b 100644 --- a/src/commands/destroy.ts +++ b/src/commands/destroy.ts @@ -96,6 +96,23 @@ async function fetchParentEdges( } } +/** + * Type-level teardown warnings for the targets of this run (#99 review) — one line per target whose + * resource type declares a `destroyWarning`. A non-empty result also means `--force` must NOT skip + * the typed confirmation: these are the deletes whose blast radius leaves the managed surface (today: + * `person-status`, whose deletion mutates every person carrying it), so an unattended `--force` + * teardown is exactly the run that should stop and ask. + */ +export function destroyWarnings(state: State, keys: string[]): string[] { + const out: string[] = []; + for (const key of keys) { + const type = state.resources[key]?.type; + const warning = type ? RESOURCES[type]?.destroyWarning : undefined; + if (warning) out.push(`${type}.${key}: ${warning}`); + } + return out; +} + export function destroyCommand(): Command { return new Command("destroy") .description("Explicitly delete managed resources (protected; never implicit)") @@ -107,7 +124,10 @@ export function destroyCommand(): Command { "confirm a protected env non-interactively (must match --env exactly)", ) .option("--backup-dir ", "directory for the pre-destroy backup (or set CT_BACKUP_DIR)") - .option("--force", "skip the typed confirmation (preventDestroy is still enforced)") + .option( + "--force", + "skip the typed confirmation (preventDestroy — and a type-level destroy warning, e.g. person-status — is still enforced)", + ) .action(async (opts: DestroyOptions) => { const targets = parseTargets(opts.target ?? []); if (targets.length === 0) { @@ -159,12 +179,21 @@ export function destroyCommand(): Command { info(`Backup written: ${backupPath}`); warn(`About to DELETE: ${ordered.join(", ")}`); + // Type-level risk (see `destroyWarnings`): surfaced before the prompt, and it takes `--force` + // away for this run so the delete cannot go through unattended. + const risky = destroyWarnings(state, ordered); + for (const line of risky) { + warn(`RISK — ${line}`); + } + if (risky.length > 0 && opts.force) { + warn("--force does NOT skip confirmation for the target(s) above. Confirm interactively."); + } // Protected env (#22): typed confirmation of the env NAME is mandatory and --force does NOT bypass // it (--confirm-env substitutes in CI). Otherwise the usual per-target typed confirmation. const expected = targets.length === 1 ? targets[0]! : "destroy"; const ok = cmdEnv.protected ? await confirmEnv(cmdEnv.name!, { confirmFlag: opts.confirmEnv }) - : await confirmTyped(expected, { force: opts.force }); + : await confirmTyped(expected, { force: opts.force && risky.length === 0 }); if (!ok) { warn( cmdEnv.protected diff --git a/src/commands/get.ts b/src/commands/get.ts index 2c7519f..ab9041c 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -35,6 +35,13 @@ const RESOURCE_PATHS: Record = { "target-groups": { path: "/group/targetgroups" }, "dynamic-groups": { path: "/dynamicgroups" }, "relationship-types": { path: "/person/relationshiptypes" }, + // Bereiche/departments — the `cdb_bereich` permission scope dimension. READ-ONLY in ChurchTools + // (GET only; no write verb exists), so this is the way to discover the names a + // `scope: [{ department: "…" }]` reference resolves against. Never adoptable. + departments: { path: "/departments" }, + // PERSON statuses — master data (the enumeration), never person records. The domain a `ct.status` + // permission declaration hangs off, and an adoptable resource since #96. + statuses: { path: "/statuses" }, // Schema/DEFINITIONS only — never person records or field VALUES (#47/#48; see docs/handbuch/field-definitions.md). // The person master-data MODEL: sexes/titles/statuses/campuses plus the security-level enumeration // that churchdb permission scopes (cc_securitylevel) reference. Single object → unpaginated. diff --git a/src/config/context.ts b/src/config/context.ts index 6b64316..b4f9733 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -17,6 +17,7 @@ import type { DesiredResource, DynamicSpec, DynamicStatus } from "../engine/type import type { DomainType } from "../permissions/grants.js"; import type { DesiredPermission, Grant } from "../permissions/types.js"; import { GROUP_STATUS_NO_CATALOG, isRef, ref, refKey, type Ref } from "../resolve/refs.js"; +import { normalizeScopeEntry } from "../permissions/scope.js"; import { conventionalRulesetRef, knownFields } from "../resources/registry.js"; import { warn } from "../ui.js"; // Re-exported so a config file can pull the query DSL from the same module as @@ -212,6 +213,13 @@ export interface ConfigContext { ageGroup(input: ResourceInput): void; targetGroup(input: ResourceInput): void; relationshipType(input: ResourceInput): void; + /** + * A PERSON status (`/statuses` — "0 - First", "3 - Group Active", …), #96. Declaring one makes a + * config that grants ON a status self-sufficient across hosts: without it the status had to be + * created by hand on every target instance before `ct.status` could resolve `personStatus: "…"`. + * Master data, not people — a status is the enumeration, never a person or a membership. + */ + personStatus(input: ResourceInput): void; /** Master-data group role (`/group/roles`). Named `roleDefinition` to avoid colliding * with the `groupRole` permission function below. */ roleDefinition(input: ResourceInput): void; @@ -427,25 +435,23 @@ export function createContext(): { const domainId = resolveDomainInput(domainType, input); if (!Array.isArray(input.grants)) throw new Error(`${domainType} "${input.key}": "grants" must be an array.`); - for (const g of input.grants) { + // Normalise each grant's scope at eval time: object sugar (`{ campus: "koblenz" }`) becomes the + // equivalent Ref, bare strings/numbers pass through (#98). Same sugar→Ref move `ID_SUGAR` makes + // for declaration id fields, and it produces a NEW array — the author's input is never mutated. + const grants: Grant[] = input.grants.map((g) => { const right = typeof g === "string" ? g : g?.right; if (typeof right !== "string" || !right.includes(":")) throw new Error( `${domainType} "${input.key}": each grant must be a "module:right" string or { right, scope }.`, ); - if (typeof g === "object") { - if (!Array.isArray(g.scope)) - throw new Error(`${domainType} "${input.key}": scoped grant needs "scope": (string | number)[].`); - // Each entry is a logical group key, or a raw numeric dataId (#49 escape hatch — for scope - // dimensions that aren't groups, e.g. security levels, which have no logical/managed form). - for (const s of g.scope) { - if (typeof s === "string" ? s.length === 0 : typeof s !== "number") - throw new Error( - `${domainType} "${input.key}": scope entries must be a non-empty string (logical group key) or a number (raw dataId), got ${JSON.stringify(s)}.`, - ); - } - } - } + if (typeof g === "string") return g; + if (!Array.isArray(g.scope)) + throw new Error( + `${domainType} "${input.key}": scoped grant needs "scope": (string | number | { campus | groupType | group })[].`, + ); + const where = `${domainType} "${input.key}" grant "${right}"`; + return { right, scope: g.scope.map((s) => normalizeScopeEntry(s, where)) }; + }); if (seen.has(input.key)) throw new Error(`Duplicate logical key "${input.key}" in config.`); seen.add(input.key); // Duplicate-target guard, keyed by the canonical domain string (numeric id or Ref key). This @@ -460,7 +466,7 @@ export function createContext(): { ); } seenDomains.set(domainKey, input.key); - permissions.push({ key: input.key, domainType, domainId, grants: input.grants }); + permissions.push({ key: input.key, domainType, domainId, grants }); }; const definePermission = (domainType: DomainType) => @@ -481,6 +487,7 @@ export function createContext(): { ageGroup: define("age-group"), targetGroup: define("target-group"), relationshipType: define("relationship-type"), + personStatus: define("person-status"), roleDefinition: define("group-role"), groupRole: definePermission("group_role"), groupTypeRole: definePermission("group_type_role"), diff --git a/src/permissions/adopt.ts b/src/permissions/adopt.ts index 1f447b0..84dd025 100644 --- a/src/permissions/adopt.ts +++ b/src/permissions/adopt.ts @@ -18,6 +18,7 @@ import type { State } from "../state/state.js"; import { findByTypeId } from "../state/state.js"; import { CATALOG } from "./catalog.js"; import { normalizeActual, type DomainType, type GrantTuple, type RawPermission } from "./grants.js"; +import { GROUP_SCOPE_FIELD, SCOPE_REF_KIND } from "./scope.js"; /** DSL function name for each domain type — the call the emitted block should be pasted as. */ const DSL_FN: Record = { @@ -26,14 +27,12 @@ const DSL_FN: Record = { status: "ct.status", }; -/** - * The catalog `scopeField` value for rights that scope by GROUP. Only rights carrying this exact - * scopeField have a logical/managed representation (`ct.group` / state) to resolve their dataIds - * against — every other non-null scopeField (`cc_securitylevel`, `cdb_comment_viewer`, `cdb_station`, - * …) names a different ChurchTools dimension with no group under management to look up, so their - * dataIds pass through as the numeric scope escape hatch (#49) instead. - */ -const GROUP_SCOPE_FIELD = "cdb_gruppe"; +/** DSL sugar field name per managed scope type, for emitting `{ campus: "koblenz" }`-style refs. */ +const SCOPE_SUGAR_FIELD: Readonly> = { + campus: "campus", + "group-type": "groupType", + department: "department", +}; interface ReverseEntry { name: string; @@ -186,12 +185,16 @@ function grantLines( // left is admin-authored DIRECT grants, INCLUDING the writable `authId >= 10000` group-member rights // Equippers curates on group_type_role. Those ARE declarable and are emitted as active grants. if (entry.scopeField != null && entry.scopeField !== GROUP_SCOPE_FIELD) { - // Scoped right whose scope dimension is NOT a group (e.g. "cc_securitylevel", "cdb_comment_viewer") - // — its dataIds name something this tool has no managed/logical representation for (a security - // level, a comment-viewer bucket, …), so `findByTypeId(state, "group", id)` would never find them - // (and could even collide with an unrelated group's id in a different namespace). There is nothing - // to "adopt" here: the numeric scope escape hatch (#49, src/permissions/scope.ts) declares these - // dataIds directly, so the grant is always emitted as an ACTIVE line, never a WARNING placeholder. + // Scoped right whose scope dimension is NOT a group. Two cases: + // + // a) The dimension HAS a logical form (#98: `cdb_station` → campus, `cdb_gruppentyp` → group + // type). Emit each dataId that names a MANAGED resource as a typed ref (`{ campus: "koblenz" }`) + // — the whole point of #98, since these ids are host-specific (dev Mainz = 6, prod Mainz = 0), + // so a numeric literal adopted from prod is a misgrant when replayed on dev. An unmanaged + // dataId keeps its number and earns a NOTE pointing at the one command that fixes it. + // b) The dimension has none (`cc_securitylevel`, `cdb_bereich`, `oauth_client`, …) — its dataIds + // name something this tool has no managed representation for, so the numeric escape hatch + // (#49) is the only honest output and the grant is always emitted as an ACTIVE line. const out: string[] = []; let omitted = false; if (g.hasUnscoped) { @@ -201,9 +204,53 @@ function grantLines( omitted = true; } if (g.dataIds.length > 0) { - const scope = [...g.dataIds].sort((a, b) => a - b).join(", "); - out.push(` // "${entry.name}" scopes by "${entry.scopeField}", not a group — using its numeric dataId(s) directly.`); - out.push(` { right: ${JSON.stringify(entry.name)}, scope: [${scope}] },`); + const dimension = SCOPE_REF_KIND[entry.scopeField]; + // A catalog-only dimension (departments) has no managed resource to look an id up in, and this + // emitter is deliberately pure — no client, no fetch — so it cannot turn the id into a name. + // Emit the number and point at the portable form the author can write by hand. + const sugar = dimension?.managed ? SCOPE_SUGAR_FIELD[dimension.type] : undefined; + if (dimension && !dimension.managed) { + const field = SCOPE_SUGAR_FIELD[dimension.type] ?? dimension.type; + out.push( + ` // NOTE: "${entry.name}" scopes by "${entry.scopeField}" (${dimension.type}), a read-only catalog —`, + ); + out.push( + ` // the id below is host-specific. Portable form: { ${field}: "" } (\`ct get ${dimension.type}s\`).`, + ); + } + const entries: string[] = []; + const unmanaged: number[] = []; + for (const id of [...g.dataIds].sort((a, b) => a - b)) { + const managed = dimension && sugar ? findByTypeId(state, dimension.type, id) : undefined; + if (managed && sugar) { + entries.push(`{ ${sugar}: ${JSON.stringify(managed.key)} }`); + } else { + entries.push(String(id)); + unmanaged.push(id); + } + } + if (sugar && unmanaged.length > 0) { + // Not a WARNING: the numeric line below IS valid, applicable config — it is just not portable + // to another host. So this never counts into `omitted` (nothing would be revoked). + const one = unmanaged.length === 1; + // `ct adopt ` takes exactly ONE id, so name every unmanaged id: telling the author + // to adopt `unmanaged[0]` alone would leave the rest numeric and the block still unportable. + const commands = unmanaged.map((id) => `\`ct adopt ${dimension!.type} ${id}\``).join(", "); + out.push( + ` // NOTE: "${entry.name}" scopes by "${entry.scopeField}" (${dimension!.type}). ${unmanaged.join(", ")} ` + + `${one ? "is" : "are"} not managed, so`, + ); + out.push( + ` // ${one ? "it stays a" : "they stay"} host-specific number(s) — run ${commands} ` + + `and re-adopt to make ${one ? "it" : "them all"} portable.`, + ); + } else if (!dimension) { + // Reached only for a dimension with NO logical form at all (`cc_securitylevel`, `oauth_client`, + // …). A catalog-only dimension (`cdb_bereich`) already got its own NOTE above — emitting this + // line there too would contradict it ("not a group, use numbers" vs "portable form exists"). + out.push(` // "${entry.name}" scopes by "${entry.scopeField}", not a group — using its numeric dataId(s) directly.`); + } + out.push(` { right: ${JSON.stringify(entry.name)}, scope: [${entries.join(", ")}] },`); } return { lines: out, omitted }; } diff --git a/src/permissions/grants.ts b/src/permissions/grants.ts index 518b865..23833e0 100644 --- a/src/permissions/grants.ts +++ b/src/permissions/grants.ts @@ -34,6 +34,13 @@ export interface GrantTuple { * actual tuples read back from ChurchTools. */ scopeKey?: string; + /** + * The MANAGED RESOURCE TYPE behind {@link scopeKey} — "group" for the historical group dimension, + * "campus" / "group-type" for a typed logical scope ref (#98). Read by `reresolveTuple` so the + * post-apply state lookup checks the right type; absent means "group" for backward compatibility + * with tuples built before typed scope refs existed. + */ + scopeType?: string; /** * True when `scopeKey` names a group DECLARED in this config but not yet created (absent from * state at plan time). Its real dataId is unknown until `executePlan` runs, so the plan renders diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts index e9b5e58..c49c015 100644 --- a/src/permissions/plan.ts +++ b/src/permissions/plan.ts @@ -10,7 +10,7 @@ import type { State } from "../state/state.js"; import type { DesiredResource } from "../engine/types.js"; import { resolveAuthId, CATALOG_META, KNOWN_AUTH_IDS } from "./catalog.js"; import { compareVersions } from "../api/version.js"; -import { resolveScope } from "./scope.js"; +import { resolveScope, resolveScopeRefs, type ScopeRefMap } from "./scope.js"; import { normalizeActual, diffGrants, type GrantTuple, type GrantDiff, type DomainType, type RawPermission } from "./grants.js"; import type { DesiredPermission } from "./types.js"; import { Resolver } from "../resolve/resolver.js"; @@ -39,6 +39,7 @@ export function desiredTuples( p: DesiredPermission, state: State, declaredGroupKeys: ReadonlySet = new Set(), + scopeRefs: ScopeRefMap = new Map(), ): GrantTuple[] { return p.grants.flatMap((g): GrantTuple[] => { const name = typeof g === "string" ? g : g.right; @@ -56,17 +57,23 @@ export function desiredTuples( if (entry.scopeField == null) { throw new Error(`${p.domainType} "${p.key}": "${name}" is not a scoped right (no scopeField) — remove "scope" or use a scoped right.`); } - // Retain the symbolic scopeKey on every scoped tuple so its dataId is re-resolved against - // post-execute state at apply time. `id === null` means the group is declared but not yet - // created (pending); it renders in the plan and always diffs into toPut (#29, #33.3). A `numeric` - // resolution (#49 escape hatch) carries no state-backed key to re-resolve — its dataId is already - // final, so no scopeKey is retained (apply.ts's `reresolveTuple` passes such a tuple through as-is). - return resolveScope(g.scope, state, declaredGroupKeys).map(({ key, id, numeric }) => + // Retain the symbolic scopeKey (and its managed resource type, #98) on every scoped tuple so its + // dataId is re-resolved against post-execute state at apply time. `id === null` means the target is + // declared but not yet created (pending); it renders in the plan and always diffs into toPut (#29, + // #33.3). A `numeric` resolution — the #49 escape hatch, or a typed ref that resolved through a live + // master-data catalog rather than managed state — carries no state-backed key to re-resolve: its + // dataId is already final, so no scopeKey is retained (apply.ts's `reresolveTuple` passes it as-is). + const scoped = resolveScope(g.scope, state, declaredGroupKeys, { + refs: scopeRefs, + scopeField: entry.scopeField, + where: `${p.domainType} "${p.key}" grant "${name}"`, + }); + return scoped.map(({ key, id, numeric, type }) => id === null - ? { authId: entry.authId, dataId: [], type: "grant" as const, scopeKey: key, pending: true } + ? { authId: entry.authId, dataId: [], type: "grant" as const, scopeKey: key, scopeType: type, pending: true } : numeric ? { authId: entry.authId, dataId: [id], type: "grant" as const } - : { authId: entry.authId, dataId: [id], type: "grant" as const, scopeKey: key }, + : { authId: entry.authId, dataId: [id], type: "grant" as const, scopeKey: key, scopeType: type }, ); }); } @@ -148,7 +155,11 @@ export async function buildPermissionPlan( } // 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 })); + const refResolver = resolver ?? new Resolver({ client, state, desired }); + const resolved = await resolveDomainIds(permissions, refResolver); + // Typed logical scope refs (#98) resolve — and are dimension-checked against each right's + // scopeField — in one async pass here, so the per-grant `resolveScope` below stays synchronous. + const scopeRefs = await resolveScopeRefs(permissions, refResolver, state); // Keys declared as groups in the config — valid scope targets even before they are created. const declaredGroupKeys = new Set(desired.filter((r) => r.type === "group").map((r) => r.key)); // one bulk fetch per distinct domainType — but only for CONCRETE domains. A pending domain (#69) @@ -178,7 +189,7 @@ export async function buildPermissionPlan( pendingDomain: p.pendingDomain, // domainId is irrelevant to desiredTuples (it only reads key/domainType/grants); pass the // pending Ref through so the shape stays a valid DesiredPermission. - diff: diffGrants(desiredTuples({ ...p, domainId: p.pendingDomain }, state, declaredGroupKeys), []), + diff: diffGrants(desiredTuples({ ...p, domainId: p.pendingDomain }, state, declaredGroupKeys, scopeRefs), []), }); continue; } @@ -213,7 +224,7 @@ export async function buildPermissionPlan( `(\`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) }); + items.push({ key: p.key, domainType: p.domainType, domainId: p.domainId, diff: diffGrants(desiredTuples(p, state, declaredGroupKeys, scopeRefs), knownActual) }); } return { items, fetchErrors, warnings }; } diff --git a/src/permissions/scope.ts b/src/permissions/scope.ts index 09f2a1b..18073d1 100644 --- a/src/permissions/scope.ts +++ b/src/permissions/scope.ts @@ -1,14 +1,20 @@ import type { State } from "../state/state.js"; import type { GrantTuple } from "./grants.js"; +import type { DesiredPermission, ScopeEntry } from "./types.js"; +import type { Resolver } from "../resolve/resolver.js"; +import { isPendingRef, isRef, ref, refKey, refLabel, type Ref, type RefKind, type SimpleRef } from "../resolve/refs.js"; +import { resolveAuthId } from "./catalog.js"; /** * One resolved scope entry: `id` is the concrete ChurchTools dataId, or `null` when it names a - * group declared in the config but not yet created (pending). `numeric` marks an entry that came - * from a raw numeric scope literal (the #49 escape hatch) rather than a logical group key — such an - * entry carries no state-backed identity to re-resolve, only its already-known id (see - * {@link reresolveTuple}, which passes a tuple through unchanged when it has no `scopeKey`). + * resource declared in the config but not yet created (pending). `numeric` marks an entry that has + * no state-backed identity to re-resolve — either a raw numeric scope literal (the #49 escape hatch) + * or a logical ref that resolved through a live master-data catalog rather than managed state — so + * it carries only its already-known id (see {@link reresolveTuple}, which passes a tuple through + * unchanged when it has no `scopeKey`). `type` is the MANAGED RESOURCE TYPE behind `key` (#98): + * "group" for the historical group dimension, "campus"/"group-type" for a typed logical scope ref. */ -export interface ScopeResolution { key: string; id: number | null; numeric?: boolean } +export interface ScopeResolution { key: string; id: number | null; numeric?: boolean; type?: string } /** * ChurchTools' "every value of this dimension" dataId. CT both accepts it on write and reads it back @@ -18,34 +24,243 @@ export interface ScopeResolution { key: string; id: number | null; numeric?: boo export const ALL_SCOPE_SENTINEL = -1; /** - * Resolve a scope array against DESIRED ∪ STATE. Each entry is either: + * The catalog `scopeField` naming the GROUP dimension. The only dimension a bare string scope entry + * may address — every other one needs a typed ref (see {@link SCOPE_REF_KIND}) or a numeric dataId. + */ +export const GROUP_SCOPE_FIELD = "cdb_gruppe"; + +/** + * Catalog `scopeField` → the {@link RefKind} a typed logical scope ref must carry for that dimension, + * and the managed resource type it resolves against (#98). + * + * Only dimensions whose values this tool can address by a HOST-INDEPENDENT name are listed: + * + * - `cdb_gruppe` → groups (managed-only; the historical string-key form is the same dimension) + * - `cdb_station` → campuses (`/campuses`) — the motivating case: campus ids are host-specific + * (dev Mainz = 6, prod Mainz = 0), so a numeric literal is a cross-environment + * misgrant waiting to happen + * - `cdb_gruppentyp` → group types (`/group/grouptypes`) + * - `cdb_bereich` → departments (`/departments`) — Bereiche, e.g. `churchdb:view alldata` + * ("Personen eines Bereiches sehen") + * + * `managed` is what separates the last one from the rest. Groups, campuses and group types are + * MANAGED resource kinds: a scope target can be declared in the same config (resolving pending, then + * re-resolved at apply time) and a state-backed id is worth re-resolving. Departments are a READ-ONLY + * ref catalog — `GET /departments` exists, but no POST/PUT/DELETE does (live-probed against the + * instance's own OpenAPI spec, eqrm prod CT 3.135.2, 2026-08-13) — so a department reference always + * resolves by NAME against the live catalog and hard-errors when the name is absent, which is + * strictly better than a silent numeric misgrant but is never a create. + * + * Every other scoped dimension (`cc_securitylevel`, `ccm_data_category`, `oauth_client`, …) stays + * numeric-only by nature rather than by omission: its values are not resources at all. + */ +export const SCOPE_REF_KIND: Readonly> = { + [GROUP_SCOPE_FIELD]: { kind: "group", type: "group", managed: true }, + cdb_station: { kind: "campus", type: "campus", managed: true }, + cdb_gruppentyp: { kind: "group-type", type: "group-type", managed: true }, + cdb_bereich: { kind: "department", type: "department", managed: false }, +}; + +/** The DSL's object sugar for a typed scope ref: exactly one dimension field, e.g. `{ campus: "koblenz" }`. */ +const SCOPE_SUGAR: Readonly Ref>> = { + group: ref.group, + campus: ref.campus, + groupType: ref.groupType, + department: ref.department, +}; + +/** + * Normalise one authored scope entry into the three forms the resolver/planner speak: a bare string + * (logical group key), a number (raw dataId), or a {@link Ref}. The object sugar `{ campus: "koblenz" }` + * compiles to `ref.campus("koblenz")` here — the same eval-time sugar→Ref move `ID_SUGAR` makes for + * declaration id fields (src/config/context.ts), so both authoring forms converge before any host is + * involved. Shared by the config DSL and the planner so a programmatically built `DesiredPermission` + * behaves identically to an authored one. + */ +export function normalizeScopeEntry(entry: unknown, where: string): string | number | Ref { + if (typeof entry === "number") return entry; + if (typeof entry === "string") { + if (entry.length === 0) { + throw new Error(`${where}: a string scope entry must be a non-empty logical group key.`); + } + return entry; + } + if (isRef(entry)) return entry; + if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { + const fields = Object.entries(entry as Record); + const dims = Object.keys(SCOPE_SUGAR).join(", "); + if (fields.length !== 1) { + throw new Error( + `${where}: a scope reference must name exactly one dimension (${dims}), got ${JSON.stringify(entry)}.`, + ); + } + const [dim, value] = fields[0]!; + const make = SCOPE_SUGAR[dim]; + if (!make) { + throw new Error(`${where}: unknown scope dimension "${dim}" — expected one of: ${dims}.`); + } + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${where}: "${dim}" must be a non-empty logical key, got ${JSON.stringify(value)}.`); + } + return make(value); + } + throw new Error( + `${where}: scope entries must be a non-empty string (logical group key), a number (raw dataId), ` + + `or a logical reference such as { campus: "koblenz" }, got ${JSON.stringify(entry)}.`, + ); +} + +/** + * A typed scope ref resolved for THIS host. `id` is the concrete dataId, or `null` when the ref names + * a resource declared in this config but not yet created. `managedKey`/`managedType` are set only when + * the ref resolved against MANAGED state (or a same-run declaration) — i.e. when there is a + * state-backed identity to re-resolve at apply time. A ref that resolved through a live master-data + * catalog carries neither: its id is already final and host-correct. + */ +export interface ScopeRefResolution { id: number | null; managedKey?: string; managedType?: string } + +/** Typed scope refs resolved for this host, keyed by {@link refKey}. */ +export type ScopeRefMap = ReadonlyMap; + +/** + * Pre-resolve every typed logical scope ref in `permissions` against this host (#98), and validate + * each one's DIMENSION against the right it scopes. + * + * Scope resolution itself ({@link resolveScope}) is synchronous — it is a pure state lookup, called + * per grant deep inside `desiredTuples`. Typed refs may need a live catalog fetch, so their + * resolution is hoisted here into one async pass over the whole permission set, and the sync path + * consumes the resulting map. Same shape as `resolveDomainIds` (src/permissions/plan.ts). + * + * Validation (all HARD errors at plan time, never a silently-guessed dataId): + * - a ref on a right that takes no scope, or on a dimension with no logical form → rejected with + * the dimension named, so the author reaches for the numeric escape hatch deliberately; + * - a ref whose kind does not match the right's `scopeField` dimension → rejected naming BOTH + * (a `{campus:…}` ref on a `cc_securitylevel` right is a config bug, not a runtime surprise); + * - a ref that cannot resolve at all → the resolver's own hard error propagates. + * + * A right the catalog does not know is skipped here so `desiredTuples` raises its own (better) + * unknown-right error rather than being pre-empted by a less specific one. + */ +export async function resolveScopeRefs( + permissions: readonly DesiredPermission[], + resolver: Resolver, + state: State, +): Promise { + const out = new Map(); + for (const p of permissions) { + for (const g of p.grants) { + if (typeof g === "string") continue; + const where = `${p.domainType} "${p.key}" grant "${g.right}"`; + let scopeField: string | null; + try { + scopeField = resolveAuthId(g.right).scopeField; + } catch { + continue; // unknown right — desiredTuples reports it with the catalog's own hint + } + for (const raw of g.scope) { + const entry = normalizeScopeEntry(raw, where); + if (!isRef(entry)) continue; + const dimension = expectedDimension(entry, scopeField, where); + const k = refKey(entry); + if (out.has(k)) continue; + const resolved = await resolver.resolve(entry, `${where} scope`); + if (isPendingRef(resolved)) { + out.set(k, { id: null, managedKey: dimension.ref.key, managedType: dimension.type }); + continue; + } + // Mirror the resolver's own precedence: it consults managed state BEFORE the live catalog, so + // a key that names a managed resource of this type is exactly the case where the id came from + // state — and only then is there a state-backed identity worth re-resolving at apply time. + // A catalog-only dimension (departments) has no managed kind at all, so it never takes this path. + const managed = dimension.managed ? state.resources[dimension.ref.key] : undefined; + out.set( + k, + managed && managed.type === dimension.type + ? { id: resolved, managedKey: dimension.ref.key, managedType: dimension.type } + : { id: resolved }, + ); + } + } + } + return out; +} + +/** + * The dimension a typed scope ref must match, or a hard error naming both sides. Also narrows the ref + * to the simple (single-`key`) kinds — the compound `group-role` / `group-type-role` refs address + * permission DOMAINS, never scope values, so they can only ever be a dimension mismatch here — and + * hands the narrowed ref back so callers can read its `key` without a cast. + */ +function expectedDimension( + entry: Ref, + scopeField: string | null, + where: string, +): { kind: RefKind; type: string; managed: boolean; ref: SimpleRef } { + if (scopeField == null) { + throw new Error( + `${where}: "${refLabel(entry)}" is a logical scope reference, but this right takes no scope ` + + `(no scopeField in the permission catalog) — remove the scope, or use a scoped right.`, + ); + } + const dimension = SCOPE_REF_KIND[scopeField]; + if (!dimension) { + const supported = Object.entries(SCOPE_REF_KIND) + .map(([field, d]) => `${field} (${d.kind})`) + .join(", "); + throw new Error( + `${where}: scope reference ${refLabel(entry)} cannot be used here — this right scopes by ` + + `"${scopeField}", which has no logical reference form. Declare its scope as a numeric dataId. ` + + `Dimensions with a logical form: ${supported}.`, + ); + } + if (entry.kind !== dimension.kind) { + throw new Error( + `${where}: scope reference ${refLabel(entry)} does not match this right's scope dimension ` + + `"${scopeField}", which takes a ${dimension.kind} reference. Use a ${dimension.kind} reference, ` + + `or a numeric dataId.`, + ); + } + // Every kind in SCOPE_REF_KIND is a simple, key-addressed one, so matching `dimension.kind` above + // has already excluded the compound refs (which carry no `key`). + return { ...dimension, ref: entry as SimpleRef }; +} + +/** + * Resolve a scope array against DESIRED ∪ STATE. Each entry is one of: * - * - a **logical group key** (`string`) — resolved against managed groups, exactly as before, or + * - a **logical group key** (`string`) — resolved against managed groups. Valid only on the group + * dimension (`cdb_gruppe`): a bare string on any other dimension is rejected, because it would + * otherwise be looked up among GROUPS and either hard-error confusingly or — worse — match an + * unrelated group that happens to carry that key and write a silent misgrant (#98). + * - a **typed logical ref** ({@link Ref}, #98) — `{ campus: "koblenz" }` / `ref.campus("koblenz")`, + * pre-resolved for this host by {@link resolveScopeRefs} and read out of `refs` here. This is what + * makes a campus-scoped grant portable: campus ids differ per host (dev Mainz = 6, prod Mainz = 0). * - a **raw numeric dataId** (`number`, #49) — an escape hatch that passes straight through with no - * state lookup at all. This is required for scoped rights whose `scopeField` is NOT the group - * dimension (`cdb_gruppe`) — e.g. `cc_securitylevel`, `cdb_comment_viewer` — where the dataId - * names something this tool has no managed representation for (a security level, not a group), - * so a logical key can never be offered for it. + * lookup at all. Still the only form for dimensions whose values are not resources this tool can + * name (`cc_securitylevel`, `cdb_bereich`, `oauth_client`, …). * - * A logical key that names a managed group in state resolves to its id. A logical key that names a - * group DECLARED in this config but not yet in state (`declaredGroupKeys`) resolves to `null` - * (pending) — its id is only known after the resource tier applies, so it is re-resolved at apply - * time (see {@link reresolveTuple}). This is what lets a config declare a group AND a grant scoped - * to it and still plan/apply in one run (#29). A logical key that is neither in state nor declared - * stays a hard error. + * A logical key that names a managed resource in state resolves to its id. A logical key that names a + * resource DECLARED in this config but not yet in state (`declaredGroupKeys` for the string form; the + * resolver's pending marker for a typed ref) resolves to `null` (pending) — its id is only known after + * the resource tier applies, so it is re-resolved at apply time (see {@link reresolveTuple}). This is + * what lets a config declare a group AND a grant scoped to it and still plan/apply in one run (#29). A + * logical key that is neither in state nor declared stays a hard error. * - * Resolved (in-state or numeric) entries sort ascending by id — ChurchTools reads scoped grants - * back one row per dataId, so a stable order keeps multi-scope grants idempotent. Pending keys + * Resolved (in-state, catalog or numeric) entries sort ascending by id — ChurchTools reads scoped + * grants back one row per dataId, so a stable order keeps multi-scope grants idempotent. Pending keys * follow, sorted by key. */ export function resolveScope( - scopeKeys: (string | number)[], + scopeKeys: readonly ScopeEntry[], state: State, declaredGroupKeys: ReadonlySet = new Set(), + opts: { refs?: ScopeRefMap; scopeField?: string | null; where?: string } = {}, ): ScopeResolution[] { + const { refs, scopeField, where = "scope" } = opts; const resolved: ScopeResolution[] = []; const pending: ScopeResolution[] = []; - for (const key of scopeKeys) { + for (const raw of scopeKeys) { + const key = normalizeScopeEntry(raw, where); if (typeof key === "number") { // `-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 @@ -59,11 +274,43 @@ export function resolveScope( resolved.push({ key: String(key), id: key, numeric: true }); continue; } + if (isRef(key)) { + const hit = refs?.get(refKey(key)); + if (!hit) { + // resolveScopeRefs pre-resolves every ref in the permission set before any diffing, so a miss + // here means a caller built tuples without that pass — an internal wiring bug, not a config error. + throw new Error( + `Scope reference ${refLabel(key)} was not pre-resolved for this host — build the plan through ` + + `buildPermissionPlan (or pass the map from resolveScopeRefs) so logical scopes can resolve.`, + ); + } + if (hit.id === null) { + pending.push({ key: hit.managedKey ?? refKey(key), id: null, type: hit.managedType }); + } else if (hit.managedKey !== undefined) { + resolved.push({ key: hit.managedKey, id: hit.id, type: hit.managedType }); + } else { + // Catalog-resolved: host-correct already and with no managed identity to re-resolve against, + // so it behaves exactly like the numeric escape hatch from here on. + resolved.push({ key: String(hit.id), id: hit.id, numeric: true }); + } + continue; + } + if (scopeField != null && scopeField !== GROUP_SCOPE_FIELD) { + const dimension = SCOPE_REF_KIND[scopeField]; + const hint = dimension + ? `use a ${dimension.kind} reference (e.g. { ${sugarFieldFor(dimension.kind)}: "${key}" })` + : "declare its scope as a numeric dataId"; + throw new Error( + `${where}: scope entry "${key}" is a bare string, which names a managed GROUP — but this right ` + + `scopes by "${scopeField}", not by group. A bare string here would silently grant on the wrong ` + + `dimension, so ${hint}.`, + ); + } const m = state.resources[key]; if (m && m.type === "group") { - resolved.push({ key, id: m.id }); + resolved.push({ key, id: m.id, type: "group" }); } else if (declaredGroupKeys.has(key)) { - pending.push({ key, id: null }); + pending.push({ key, id: null, type: "group" }); } else { throw new Error( `Scope key "${key}" does not resolve to a managed group. Declare/adopt it, use a group already under management, or pass a raw numeric dataId if this right's scope is not a group (see the catalog's scopeField).`, @@ -75,19 +322,29 @@ export function resolveScope( return [...resolved, ...pending]; } +/** The DSL sugar field name for a scope {@link RefKind} — for error hints only. */ +function sugarFieldFor(kind: RefKind): string { + const hit = Object.entries(SCOPE_SUGAR).find(([, make]) => make("x").kind === kind); + return hit?.[0] ?? kind; +} + /** * Re-resolve a scoped grant tuple's dataId against the current (post-execute) state, using the * symbolic scopeKey retained on the tuple. Fixes stale ids after a recreate and fills in the id of - * a group created in the same apply. Unscoped tuples (no scopeKey) pass through unchanged. Throws - * if the scope key no longer resolves to a managed group — which should be impossible once the - * resource tier has applied, so it signals a real inconsistency rather than being silently skipped. + * a resource created in the same apply. Unscoped tuples, numeric scopes and catalog-resolved refs + * (no `scopeKey`) pass through unchanged. `scopeType` names the managed resource type behind the key + * — "group" for the historical group dimension, "campus"/"group-type" for a typed scope ref (#98). + * Throws if the scope key no longer resolves to a managed resource of that type — which should be + * impossible once the resource tier has applied, so it signals a real inconsistency rather than + * being silently skipped. */ export function reresolveTuple(t: GrantTuple, state: State): GrantTuple { if (t.scopeKey == null) return t; + const type = t.scopeType ?? "group"; const m = state.resources[t.scopeKey]; - if (!m || m.type !== "group") { + if (!m || m.type !== type) { throw new Error( - `Scope key "${t.scopeKey}" did not resolve to a managed group after apply — cannot write its grant with a valid dataId.`, + `Scope key "${t.scopeKey}" did not resolve to a managed ${type} after apply — cannot write its grant with a valid dataId.`, ); } return { ...t, dataId: [m.id], pending: false }; diff --git a/src/permissions/types.ts b/src/permissions/types.ts index 787b8bc..f2a1115 100644 --- a/src/permissions/types.ts +++ b/src/permissions/types.ts @@ -8,11 +8,30 @@ import type { DomainType } from "./grants.js"; import type { Ref } from "../resolve/refs.js"; /** - * A scope entry is either a logical key of a group managed by this tool, or a raw numeric dataId - * (#49 escape hatch) — required for scoped rights whose scope dimension (catalog `scopeField`) is - * not a group, since there is no logical/managed representation to reference by key there. + * A typed logical scope reference in its authoring (object-sugar) form (#98) — exactly one dimension + * field. `{ campus: "koblenz" }` is the portable way to scope a grant by campus, whose ids are + * host-specific (dev Mainz = 6, prod Mainz = 0). Compiles to the equivalent {@link Ref} at eval time + * (see `normalizeScopeEntry` in src/permissions/scope.ts), so `ref.campus("koblenz")` is identical. */ -export type Grant = string | { right: string; scope: (string | number)[] }; +export type ScopeSugar = + | { group: string } + | { campus: string } + | { groupType: string } + | { department: string }; + +/** + * One entry of a scoped grant's `scope`: + * + * - a **logical group key** (`string`) — a group managed by this tool. Group dimension only. + * - a **typed logical ref** ({@link ScopeSugar} / {@link Ref}, #98) — the portable form for the other + * dimensions this tool can name by key: campuses (`cdb_station`), group types (`cdb_gruppentyp`) + * and departments (`cdb_bereich`, a read-only catalog — referenceable but never declarable). + * - a **raw numeric dataId** (`number`, #49 escape hatch) — the only form for dimensions whose values + * are not resources at all (`cc_securitylevel`, `ccm_data_category`, `oauth_client`, …). + */ +export type ScopeEntry = string | number | Ref | ScopeSugar; + +export type Grant = string | { right: string; scope: ScopeEntry[] }; export interface DesiredPermission { key: string; diff --git a/src/resolve/refs.ts b/src/resolve/refs.ts index 61afcf4..43e716e 100644 --- a/src/resolve/refs.ts +++ b/src/resolve/refs.ts @@ -20,6 +20,7 @@ export type RefKind = | "campus" + | "department" | "group-type" | "group-status" | "person-status" @@ -43,10 +44,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 / person status / role definition / group. */ +/** Simple key-addressed reference: campus / department / group type / group status / person status / role definition / group. */ export interface SimpleRef { __ctRef: true; - kind: "campus" | "group-type" | "group-status" | "person-status" | "role-def" | "group"; + kind: "campus" | "department" | "group-type" | "group-status" | "person-status" | "role-def" | "group"; key: string; } @@ -98,6 +99,15 @@ function requireKey(kind: RefKind, value: unknown): string { */ export const ref = { campus: (key: string): SimpleRef => ({ __ctRef: true, kind: "campus", key: requireKey("campus", key) }), + /** + * A Bereich/DEPARTMENT (`/departments`), the scope dimension of `cdb_bereich` rights such as + * `churchdb:view alldata` ("Personen eines Bereiches sehen") — #98. Unlike campuses and group + * types, departments are a READ-ONLY ref catalog: `GET /departments` exists but there is no POST / + * PUT / DELETE (live-probed against the instance OpenAPI spec, eqrm prod CT 3.135.2, 2026-08-13). + * So a department can be REFERENCED by name on any host, but never declared, adopted or created — + * an unresolvable name is a hard error, not a create. + */ + department: (key: string): SimpleRef => ({ __ctRef: true, kind: "department", key: requireKey("department", 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) }), /** diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 522a0c8..2aea3b4 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -50,6 +50,12 @@ import { const REF_KIND_TYPE: Partial> = { campus: "campus", "group-type": "group-type", + // Person statuses became an adoptable resource in #96, so a `personStatus: "…"` domain now + // resolves from managed state / this run's declarations FIRST and only falls through to the + // `/statuses` catalog for a status this config does not own. That ordering is what makes a + // status declared in the same config usable as a permission domain (it resolves to a PendingRef, + // which buildPermissionPlan carries as a pending domain, #69). + "person-status": "person-status", "role-def": "group-role", group: "group", }; @@ -70,6 +76,11 @@ const REF_KIND_TYPE: Partial> = { */ const CATALOG_PATH: Partial> = { campus: "/campuses", + // Bereiche/departments — the `cdb_bereich` permission scope dimension (#98). Catalog-ONLY, with no + // REF_KIND_TYPE entry above: `GET /departments` exists but no POST/PUT/DELETE does (live-probed on + // eqrm prod, CT 3.135.2, 2026-08-13), so a department is resolvable by name on every host yet can + // never be declared, adopted or created. Rows carry {id, name, nameTranslated, sortKey, shorty}. + department: "/departments", "group-type": "/group/grouptypes", // 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 @@ -86,7 +97,14 @@ interface CatalogRecord { } export interface ResolverDeps { - client: Pick; + /** + * `getAll` is what the catalogs below are actually read with (#99 review): CT's list endpoints + * return only their default first page (10 rows) for a plain `get`, which would make a campus, + * group type or department past that page unresolvable by name on any real instance — exactly the + * portability #98 exists to give. It is optional so the many `{ get }` test doubles keep working; + * the real {@link CtClient} always provides it. + */ + client: Pick & Partial>; state: State; desired: DesiredResource[]; /** Host label for error messages. Defaults to `state.host`. */ @@ -94,26 +112,35 @@ export interface ResolverDeps { } /** - * ASSUMPTION (verify on eqrm-dev) — the pinned model for a `group_role` permission domain (#25): + * VERIFIED LIVE (2026-08-13, eqrm prod, CT 3.135.2) — the model for a `group_role` 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/handbuch/permissions.md - * "domainId semantics"). + * role-definition id (docs/handbuch/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). + * Evidence (two anchors on DIFFERENT group types, each a group whose type has many members, so the + * per-group `id` and the type-level `groupTypeRoleId` are guaranteed to differ): + * - For both, the role row's `id` appears in `GET /permissions/group_role` as a live `domainId` + * carrying that role's authored grants (1 row on one anchor, 35 on the other). + * - For both, the row's `groupTypeRoleId` appears NOWHERE in the whole domainId set — decisive, + * because a type-level key would have to appear if the domain were type-scoped. + * - On one anchor, the two roles with no authored rights have no domain at all, which is exactly + * what a per-(group, role) pairing predicts (a domain exists only where rights were written). + * + * A prior, cruder probe saw `groupTypeRoleId` values that also occur in the domainId set; those are + * numeric COLLISIONS with the low `id`s of long-existing role rows, not evidence of a type-level key. + * + * If a future CT release moves the pairing id to another field or endpoint, change the two constants + * below — call sites do not change. The numeric `id:` escape hatch on `ct.groupRole` remains supported. */ 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 client: Pick & Partial>; private readonly state: State; private readonly host: string; private readonly catalogs = new Map>(); @@ -169,11 +196,23 @@ export class Resolver { return deepMapRefs(value, (r) => byKey.get(refKey(r))); } + /** + * Fetch one master-data catalog, ONCE per run, paginated. + * + * Paginated deliberately (#99 review): a plain `GET /campuses` returns CT's default first page — + * 10 rows — so on an instance with more campuses/group types/departments than that, every ref + * naming a row on page 2+ would hard-error with "no live … matches key", and a `ct get campuses` + * (which pages) would list the very row this resolver claims does not exist. `getAll` is optional + * on the deps only so `{ get }` test doubles stay usable; the real client always has it. + */ private catalog(kind: RefKind): Promise { let p = this.catalogs.get(kind); if (!p) { const path = CATALOG_PATH[kind]!; - p = this.client.get(path).then((rows) => (Array.isArray(rows) ? rows : [])); + const rows = this.client.getAll + ? this.client.getAll(path).then((page) => page.data) + : this.client.get(path); + p = rows.then((r) => (Array.isArray(r) ? r : [])); this.catalogs.set(kind, p); } return p; @@ -196,8 +235,8 @@ export class Resolver { /** * 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 + * See the VERIFIED LIVE block above the {@link GROUP_ROLE_ENDPOINT} constant for the model this + * implements and the evidence for it. 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. */ @@ -313,9 +352,13 @@ export class Resolver { 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 : [])); + // Paginated for the same reason as `catalog()` above: a group with more than CT's default page + // of roles would otherwise hide its later ones behind "group #N has no role named …". + const path = GROUP_ROLE_ENDPOINT(groupId); + const rows = this.client.getAll + ? this.client.getAll(path).then((page) => page.data) + : this.client.get(path); + p = rows.then((r) => (Array.isArray(r) ? r : [])); this.groupRoleLists.set(groupId, p); } return p; @@ -334,6 +377,18 @@ export class Resolver { ); } const catalog = CATALOG_PATH[r.kind]; + // A catalog-only kind has no managed resource type, so "Declare/adopt it" is advice the tool + // cannot honour (#96's exact complaint about the old person-status message). Departments are + // read-only in ChurchTools — GET /departments exists, no write verb does — so the only real + // fixes are correcting the name or falling back to a numeric id. + if (catalog && REF_KIND_TYPE[r.kind] === undefined) { + return new Error( + `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: no live ${r.kind} at ` + + `${catalog} matches key "${r.key}". ${r.kind}s are a read-only catalog in ChurchTools — they ` + + `cannot be declared or adopted, so fix the key/name (list them with \`ct get ${r.kind}s\`) or ` + + `use a numeric id.`, + ); + } const where = catalog ? `no managed resource and no live ${r.kind} at ${catalog} matches key "${r.key}"` : `no managed ${r.kind} named "${r.key}" is declared or adopted`; diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 405265f..b91a8cf 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -34,6 +34,16 @@ export interface AdoptableResource { * Omit the hook entirely for types whose managed fields already satisfy the create contract. */ createDefaults?: (body: Record) => Record; + /** + * Extra teardown risk for this type, shown before `ct destroy` confirms — and, when set, `--force` + * does NOT skip the typed confirmation for a run touching it (#99 review). + * + * Set it only where the DELETE reaches past master data into records this tool otherwise never + * touches. `assertNotPeople` guards PATHS, and a type like `person-status` has a perfectly + * innocent one (`/statuses/{id}`) while its deletion still mutates every person carrying that + * status — a gap no path denylist can close. + */ + destroyWarning?: string; /** * DSL function name `configSnippet` emits for this type. Defaults to the camelCase of * the type name. Set it when the natural camelCase collides with another DSL surface @@ -186,6 +196,53 @@ export const RESOURCES: Record = { degreeNameB: r.degreeNameB, }), }), + /** + * PERSON statuses (`/statuses` — "0 - First", "3 - Group Active", …), the domain of a `ct.status` + * permission declaration (#90). Adoptable so a config that grants ON a status is self-sufficient + * across hosts (#96): before this entry the status itself could only be created by hand on every + * target instance, which made the whole `status` domain only half-portable — the grant was + * declarable, the thing it hangs off was not. + * + * Not a people surface: a status is master data (the enumeration), never a person or a membership. + * `assertNotPeople` still guards every write; `/statuses` does not match its denylist by design. + * + * The managed set is the WHOLE required PUT contract, deliberately — and this is the one managed + * type where a minimal field set would be actively wrong. Live-probed against the instance's own + * OpenAPI spec (eqrm prod, CT 3.135.2, 2026-08-13): + * + * POST /statuses required: name, shorty, isMember + * PUT /statuses/{id} required: name, shorty, isMember, isSearchable, sortKey, securityLevelId + * + * That PUT is a FULL REPLACE, and the executor sends the declared field bag as the update body — + * so managing a subset would both 400 (missing required fields) and, if it didn't, blank the + * fields left out. Every other managed type's PUT has no required fields at all (campus, + * age-group, target-group, group-role, group-type — same probe), which is why they can manage a + * narrow set; `/statuses` cannot. Hence: no `createDefaults` here. All six are declared, adopted + * and diffed, so `ct adopt person-status ` emits a config that is valid for both verbs. + * A hand-authored declaration that omits one gets CT's own HTTP 400 (#71), never a silent blank. + */ + "person-status": define({ + collectionPath: "/statuses", + updateMethod: "PUT", + tier: 0, + // The one managed type whose DELETE reaches person RECORDS (#99 review). `/statuses/{id}` is not + // a people path, so `assertNotPeople` cannot see the risk: deleting a status makes ChurchTools + // re-stamp every person who carries it. Drop the declaration and `ct destroy` would otherwise + // take it out behind a plain typed confirmation — or none at all under `--force`. + destroyWarning: + "deleting a person status MUTATES every person carrying it (ChurchTools re-stamps their " + + "status). `ct` never manages people, but this delete reaches them. Verify the status is " + + "unused first (`ct get statuses`, then check the status in ChurchTools).", + deriveKey: (r) => slug(str(r, "name")), + managedFields: (r) => ({ + name: r.name, + shorty: r.shorty, + isMember: r.isMember, + isSearchable: r.isSearchable, + sortKey: r.sortKey, + securityLevelId: r.securityLevelId, + }), + }), "group-role": define({ collectionPath: "/group/roles", updateMethod: "PUT", diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index 8f04353..c46eaba 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -97,7 +97,10 @@ function makeClient() { const getAll = vi.fn(async (path: string) => { if (path === "/groups") return { data: Object.values(groups) }; - throw new CtApiError(`unmocked getAll ${path}`, 404, null); + // The Resolver reads master-data catalogs paginated (#99 review), so serve them here too — + // same rows as `get`, wrapped in the page envelope. + const single = await get(path); + return { data: Array.isArray(single) ? single : [single] }; }); return { get, getAll }; diff --git a/tests/destroy.test.ts b/tests/destroy.test.ts index fc110a4..6483ecb 100644 --- a/tests/destroy.test.ts +++ b/tests/destroy.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { parseTargets, orderDestroy, runDeleteLoop } from "../src/commands/destroy.js"; +import { parseTargets, orderDestroy, runDeleteLoop, destroyWarnings } from "../src/commands/destroy.js"; import { emptyState, type State } from "../src/state/state.js"; import { CtApiError, type CtClient } from "../src/api/ctClient.js"; @@ -120,3 +120,18 @@ describe("runDeleteLoop", () => { process.exitCode = prevExit; }); }); + +describe("destroyWarnings (#99 review)", () => { + it("flags a person-status target — the one delete that reaches person records", () => { + const state = stateWith({ key: "core", type: "person-status", id: 5 }); + const lines = destroyWarnings(state, ["core"]); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("person-status.core"); + expect(lines[0]).toMatch(/MUTATES every person carrying it/); + }); + + it("stays silent for ordinary master data (and for an unknown key)", () => { + const state = stateWith({ key: "mainz", type: "campus", id: 1 }); + expect(destroyWarnings(state, ["mainz", "ghost"])).toEqual([]); + }); +}); diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts index e9583dc..507fec6 100644 --- a/tests/permission-adopt.test.ts +++ b/tests/permission-adopt.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest"; import { emitAdoptedGrants } from "../src/permissions/adopt.js"; import { diffGrants, normalizeActual, type DomainType, type RawPermission } from "../src/permissions/grants.js"; import { desiredTuples } from "../src/permissions/plan.js"; -import type { Grant } from "../src/permissions/types.js"; +import { resolveScopeRefs } from "../src/permissions/scope.js"; +import { Resolver } from "../src/resolve/resolver.js"; +import type { Grant, ScopeEntry } from "../src/permissions/types.js"; import type { State } from "../src/state/state.js"; const HOST = "https://mychurch.church.tools"; @@ -22,6 +24,11 @@ function emptyState(): State { return { version: 1, host: HOST, resources: {} }; } +/** A client whose master-data catalogs are empty — every emitted ref must resolve from state. */ +function catalogClient() { + return { get: async (): Promise => [] as T }; +} + /** * Parse the ACTIVE (non-comment) grant entries back out of an emitted block, so they can be fed * through the real `desiredTuples` — the round-trip property the emitter guarantees. @@ -42,7 +49,10 @@ function parseEmittedGrants(block: string): Grant[] { } const m = /^\{ right: ("(?:[^"\\]|\\.)*"), scope: \[(.*)\] \}$/.exec(entry); if (!m?.[1] || m[2] == null) throw new Error(`Unparseable emitted grant line: ${line}`); - grants.push({ right: JSON.parse(m[1]) as string, scope: JSON.parse(`[${m[2]}]`) as (string | number)[] }); + // Scope entries are JSON except for a typed logical ref (#98), whose object key is an unquoted + // TS identifier (`{ campus: "koblenz" }`) — quote it so the whole array parses as JSON. + const scopeJson = m[2].replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3'); + grants.push({ right: JSON.parse(m[1]) as string, scope: JSON.parse(`[${scopeJson}]`) as ScopeEntry[] }); } return grants; } @@ -290,9 +300,91 @@ describe("emitAdoptedGrants", () => { expect(block).toContain("ct adopt group 777"); }); - it("round trip — every emitted grant passes the real desiredTuples, for any mix of rows", () => { + it("campus-dimension scope whose dataId is a MANAGED campus → emits a logical ref, not a number (#98)", () => { + // churchdb:view station (authId 124) scopes by cdb_station. Campus ids are host-specific, so an + // adopted numeric literal is a misgrant when the block is replayed on the other instance. + const state = stateWithKids(); + state.resources.koblenz = { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + const rows: RawPermission[] = [ + { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_role", domainId: 42, rows, state }); + + expect(block).toContain('{ right: "churchdb:view station", scope: [{ campus: "koblenz" }] }'); + expect(block).not.toContain("scope: [23]"); + expect(block).not.toContain("WARNING"); + }); + + it("campus-dimension scope on an UNMANAGED campus stays numeric with a NOTE, and is still applicable (#98)", () => { + const rows: RawPermission[] = [ + { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_role", domainId: 42, rows, state: emptyState() }); + + expect(block).toContain('{ right: "churchdb:view station", scope: [23] }'); + expect(block).toContain("ct adopt campus 23"); + // A NOTE, not a WARNING: the numeric line IS valid config, just not portable — nothing is at + // risk of being revoked, so the block-level "will REVOKE" header must stay off. + expect(block).not.toContain("WARNING"); + expect(block).not.toContain("REVOKE"); + }); + + it("mixes managed (logical) and unmanaged (numeric) campus scopes in one grant", () => { + const state = emptyState(); + state.resources.koblenz = { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }; + const rows: RawPermission[] = [ + { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + { authId: 124, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_role", domainId: 42, rows, state }); + expect(block).toContain('scope: [{ campus: "koblenz" }, 99]'); + }); + + it("a catalog-only dimension gets ONE note — the portable form, not the 'not a group' line", () => { + const rows: RawPermission[] = [ + { authId: 102, dataId: 4, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cdb_bereich + ]; + const block = emitAdoptedGrants({ domainType: "group_role", domainId: 42, rows, state: emptyState() }); + + expect(block).toContain('Portable form: { department: "" }'); + // The numeric-escape-hatch line is for dimensions with NO logical form; emitting it here too + // would contradict the portable-form note directly above it. + expect(block).not.toContain("not a group"); + expect(block).toContain('{ right: "churchdb:view alldata", scope: [4] }'); + }); + + it("a dimension with no logical form still gets the numeric-escape-hatch note", () => { + const rows: RawPermission[] = [ + { authId: 125, dataId: 2, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cc_securitylevel + ]; + const block = emitAdoptedGrants({ domainType: "group_role", domainId: 42, rows, state: emptyState() }); + expect(block).toContain("not a group"); + expect(block).not.toContain("Portable form"); + }); + + it("names EVERY unmanaged dataId in the adopt hint, not just the first", () => { + const rows: RawPermission[] = [ + { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + { authId: 124, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + { authId: 124, dataId: 101, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, + ]; + const block = emitAdoptedGrants({ domainType: "group_role", domainId: 42, rows, state: emptyState() }); + for (const id of [23, 99, 101]) { + expect(block).toContain(`ct adopt campus ${id}`); + } + }); + + it("round trip — every emitted grant passes the real desiredTuples, for any mix of rows", async () => { const state = stateWithKids(); + // A managed campus, so the fixture also covers the #98 typed-ref emission form (`{ campus: … }`) + // — without it the round-trip property was only ever exercised on group/numeric scopes, and the + // one shape that needs a pre-resolved ScopeRefMap went unchecked. + state.resources.koblenz = { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }; const rows: RawPermission[] = [ + { authId: 124, dataId: 23, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cdb_station, managed → { campus: … } + { authId: 124, dataId: 777, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cdb_station, unmanaged → numeric + { authId: 102, dataId: 4, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cdb_bereich (catalog-only) → numeric + { authId: 125, dataId: 2, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // cc_securitylevel (no logical form) → numeric { authId: 1, dataId: null, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // known unscoped { authId: 1, dataId: 55, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // unscoped w/ dataId (stale catalog) { authId: 1104, dataId: 99, type: "grant", domainId: 42, meta: { modifiedPid: 5 } }, // scoped, managed @@ -308,9 +400,18 @@ describe("emitAdoptedGrants", () => { const block = emitAdoptedGrants({ domainType, domainId: 42, rows, state }); const grants = parseEmittedGrants(block); expect(grants.length).toBeGreaterThan(0); // the property is not vacuous - expect(() => - desiredTuples({ key: "adopted", domainType, domainId: 42, grants }, state), - ).not.toThrow(); + const permission = { key: "adopted", domainType, domainId: 42, grants }; + // The invariant is "passes desiredTuples GIVEN the plan's pre-resolution pass" — a typed scope + // ref must be resolved first (buildPermissionPlan always does this), so run the real + // resolveScopeRefs here rather than asserting against an empty map. + const resolver = new Resolver({ client: catalogClient(), state, desired: [] }); + const refs = await resolveScopeRefs([permission], resolver, state); + expect(() => desiredTuples(permission, state, new Set(), refs)).not.toThrow(); + // …and the emitted campus ref really did resolve to this host's managed campus id. + const campusTuple = desiredTuples(permission, state, new Set(), refs).find( + (t) => t.authId === 124 && t.dataId.includes(23), + ); + expect(campusTuple).toBeDefined(); } }); }); diff --git a/tests/permission-bootstrap.test.ts b/tests/permission-bootstrap.test.ts index db6155a..480c71a 100644 --- a/tests/permission-bootstrap.test.ts +++ b/tests/permission-bootstrap.test.ts @@ -51,7 +51,7 @@ describe("scope bootstrap: declare group + grant scoped to it in one config (#29 const { items, fetchErrors } = await buildPermissionPlan(client, state, [scopedPerm], desiredKidsGroup); expect(fetchErrors).toEqual([]); // The scoped grant survives planning and lands in toPut as a pending tuple. - expect(items[0]?.diff.toPut).toEqual([{ authId: 1104, dataId: [], type: "grant", scopeKey: "kids", pending: true }]); + expect(items[0]?.diff.toPut).toEqual([{ authId: 1104, dataId: [], type: "grant", scopeKey: "kids", scopeType: "group", pending: true }]); // Read-only `ct plan` renders instead of aborting, and labels the pending scope. expect(renderPermissionPlan(items)).toContain("kids (created this apply)"); }); @@ -80,7 +80,7 @@ describe("stale id after recreate (#33 item 3)", () => { }}; const { items } = await buildPermissionPlan(client, state, [scopedPerm], desiredKidsGroup); // At plan time the tuple resolves to the OLD id 100… - expect(items[0]?.diff.toPut).toEqual([{ authId: 1104, dataId: [100], type: "grant", scopeKey: "kids" }]); + expect(items[0]?.diff.toPut).toEqual([{ authId: 1104, dataId: [100], type: "grant", scopeKey: "kids", scopeType: "group" }]); // …then a recreate mints id 777 in state. await executePlan(createKidsPlan, { client, state, statePath: "unused", save: async () => {} }); diff --git a/tests/permission-plan.test.ts b/tests/permission-plan.test.ts index 6423056..d628274 100644 --- a/tests/permission-plan.test.ts +++ b/tests/permission-plan.test.ts @@ -18,7 +18,7 @@ describe("desiredTuples", () => { ]}, state); expect(tuples).toEqual([ { authId: 1113, dataId: [], type: "grant" }, - { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area" }, // scoped tuples retain their symbolic key for re-resolution + { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area", scopeType: "group" }, // scoped tuples retain their symbolic key for re-resolution ]); }); @@ -43,8 +43,8 @@ describe("desiredTuples", () => { ]}, state); // resolveScope sorts resolved dataIds ascending (7 < 42), independent of scope-key order. expect(tuples).toEqual([ - { authId: 1104, dataId: [7], type: "grant", scopeKey: "other" }, - { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area" }, + { authId: 1104, dataId: [7], type: "grant", scopeKey: "other", scopeType: "group" }, + { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area", scopeType: "group" }, ]); expect(tuples.every((t) => t.dataId.length <= 1)).toBe(true); }); @@ -81,7 +81,7 @@ describe("desiredTuples", () => { ]}, state); expect(tuples).toEqual([ { authId: 1104, dataId: [3], type: "grant" }, - { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area" }, + { authId: 1104, dataId: [42], type: "grant", scopeKey: "kids_area", scopeType: "group" }, ]); }); }); diff --git a/tests/permission-scope-refs.test.ts b/tests/permission-scope-refs.test.ts new file mode 100644 index 0000000..d69c07b --- /dev/null +++ b/tests/permission-scope-refs.test.ts @@ -0,0 +1,287 @@ +/** + * Typed logical scope refs (#98) — a grant scoped by a NON-group dimension declared portably. + * + * The motivating failure: campus ids are host-specific (eqrm dev Koblenz ≠ prod Koblenz), so a + * campus-scoped grant written as a numeric literal is a cross-environment misgrant — and because + * declaring a domain makes `ct` OWN it, the wrong-scope grant also revokes whatever is really there + * on the other host. These tests pin the portable form, the dimension guard, and the failure modes. + */ +import { describe, it, expect, vi } from "vitest"; +import { buildPermissionPlan, desiredTuples } from "../src/permissions/plan.js"; +import { applyPermissionPlan } from "../src/permissions/apply.js"; +import { resolveScope, resolveScopeRefs, reresolveTuple } from "../src/permissions/scope.js"; +import { executePlan } from "../src/engine/execute.js"; +import { Resolver } from "../src/resolve/resolver.js"; +import { ref } from "../src/resolve/refs.js"; +import { createContext } from "../src/config/context.js"; +import { emptyState, type State } from "../src/state/state.js"; +import type { DesiredPermission } from "../src/permissions/types.js"; +import type { DesiredResource, Plan } from "../src/engine/types.js"; +import type { CtClient } from "../src/api/ctClient.js"; + +const HOST = "https://mychurch.church.tools"; + +/** `churchdb:view station` — authId 124, scoped by `cdb_station` (campuses). The #98 motivating right. */ +const VIEW_STATION = "churchdb:view station"; + +/** A campus-scoped grant, declared with a logical ref — the exact shape ct-structure needs. */ +const campusScoped: DesiredPermission = { + key: "koblenz_grants", + domainType: "group_role", + domainId: 900, + grants: [{ right: VIEW_STATION, scope: [ref.campus("koblenz")] }], +}; + +/** State with campus "koblenz" managed at `id` — the same logical config, a different host. */ +function stateWithKoblenz(id: number): State { + return { + version: 1, + host: HOST, + resources: { + koblenz: { type: "campus", id, key: "koblenz", fields: { name: "Koblenz" }, adoptedAt: "t", updatedAt: "t" }, + }, + }; +} + +/** A client whose `/permissions/*` reads are empty and whose `/campuses` catalog is host-specific. */ +function mockClient(campuses: { id: number; name: string }[] = [], newId = 555) { + const calls: { method: string; path: string; body?: unknown }[] = []; + const get = vi.fn(async (path: string) => (path === "/campuses" ? campuses : [])); + const request = vi.fn(async (method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }); + if (method === "POST" && path === "/campuses") return { id: newId }; + return {}; + }); + return { client: { get, request } as unknown as CtClient, calls, get }; +} + +async function tuplesFor(perm: DesiredPermission, state: State, desired: DesiredResource[] = [], client?: CtClient) { + const c = client ?? mockClient().client; + const resolver = new Resolver({ client: c, state, desired }); + const refs = await resolveScopeRefs([perm], resolver, state); + return desiredTuples(perm, state, new Set(desired.filter((r) => r.type === "group").map((r) => r.key)), refs); +} + +describe("campus-scoped grants are portable across hosts (#98)", () => { + it("resolves one config to each host's own campus id — no numeric literal anywhere", async () => { + // Same DesiredPermission object, two hosts whose Koblenz campus carries a different id. + const dev = await tuplesFor(campusScoped, stateWithKoblenz(6)); + const prod = await tuplesFor(campusScoped, stateWithKoblenz(23)); + + expect(dev).toEqual([{ authId: 124, dataId: [6], type: "grant", scopeKey: "koblenz", scopeType: "campus" }]); + expect(prod).toEqual([{ authId: 124, dataId: [23], type: "grant", scopeKey: "koblenz", scopeType: "campus" }]); + }); + + it("plans to a clean no-op against each host's live rows", async () => { + for (const id of [6, 23]) { + const state = stateWithKoblenz(id); + const live = [{ domainType: "group_role", domainId: 900, authId: 124, dataId: id, type: "grant", meta: { modifiedPid: 5 } }]; + const client = { get: vi.fn(async (path: string) => (path === "/campuses" ? [] : live)) }; + const { items, fetchErrors } = await buildPermissionPlan(client as never, state, [campusScoped]); + expect(fetchErrors).toEqual([]); + expect(items[0]?.diff.toPut).toEqual([]); + expect(items[0]?.diff.toDelete).toEqual([]); + } + }); + + it("falls back to the live /campuses catalog for a campus this config does not manage", async () => { + // Not in state → resolved by name against the host's catalog. The id is already host-correct, so + // there is no managed identity to re-resolve at apply time and the tuple keeps no scopeKey. + const { client } = mockClient([{ id: 23, name: "Koblenz" }]); + const tuples = await tuplesFor(campusScoped, emptyState(HOST), [], client); + expect(tuples).toEqual([{ authId: 124, dataId: [23], type: "grant" }]); + }); + + it("a campus created in the same run is pending at plan time and gets its real id at apply time", async () => { + const desired: DesiredResource[] = [{ type: "campus", key: "koblenz", fields: { name: "Koblenz", shorty: "KO" }, dependsOn: [] }]; + const { client, calls } = mockClient([], 555); + const state = emptyState(HOST); + const { items } = await buildPermissionPlan(client, state, [campusScoped], desired); + expect(items[0]?.diff.toPut).toEqual([ + { authId: 124, dataId: [], type: "grant", scopeKey: "koblenz", scopeType: "campus", pending: true }, + ]); + + const createCampus: Plan = { + items: [{ type: "campus", key: "koblenz", id: null, action: "create", changes: [{ field: "name", from: undefined, to: "Koblenz" }] }], + }; + await executePlan(createCampus, { client, state, statePath: "unused", save: async () => {} }); + expect(state.resources.koblenz?.id).toBe(555); + + const res = await applyPermissionPlan(items, client, state); + expect(res.granted).toBe(1); + const put = calls.find((c) => c.method === "PUT" && c.path === "/permissions/group_role/900"); + expect(put?.body).toEqual({ authId: 124, type: "grant", dataId: [555] }); + }); +}); + +describe("scope-dimension validation (#98)", () => { + const resolverFor = (state: State) => new Resolver({ client: mockClient().client, state, desired: [] }); + + it("rejects a ref whose dimension does not match the right's scopeField, naming both", async () => { + const perm: DesiredPermission = { + key: "p", domainType: "group_role", domainId: 1, + // churchdb:view station scopes by cdb_station (campus) — a group-type ref there is a config bug. + grants: [{ right: VIEW_STATION, scope: [ref.groupType("struktur")] }], + }; + await expect(resolveScopeRefs([perm], resolverFor(emptyState(HOST)), emptyState(HOST))).rejects.toThrow( + /group-type:struktur.*"cdb_station".*takes a campus reference/s, + ); + }); + + it("rejects a ref on a dimension with no logical form at all, naming the ref and the dimension", async () => { + const perm: DesiredPermission = { + key: "p", domainType: "group_role", domainId: 1, + // churchdb:security level person scopes by cc_securitylevel — security levels are not resources. + grants: [{ right: "churchdb:security level person", scope: [ref.campus("koblenz")] }], + }; + await expect(resolveScopeRefs([perm], resolverFor(emptyState(HOST)), emptyState(HOST))).rejects.toThrow( + /campus:koblenz.*cc_securitylevel.*no logical reference form/s, + ); + }); + + it("rejects a logical ref on a dimension that has no logical form, pointing at the numeric hatch", async () => { + const perm: DesiredPermission = { + key: "p", domainType: "group_role", domainId: 1, + // ccm_data_category: its values are not resources at all — numbers only, by nature. + grants: [{ right: "jpmFlowManagerPreview:view custom category", scope: [ref.campus("x")] }], + }; + await expect(resolveScopeRefs([perm], resolverFor(emptyState(HOST)), emptyState(HOST))).rejects.toThrow( + /ccm_data_category.*no logical reference form.*numeric dataId/s, + ); + }); + + it("rejects a logical ref on an unscoped right", async () => { + const perm: DesiredPermission = { + key: "p", domainType: "group_role", domainId: 1, + grants: [{ right: "churchcore:administer settings", scope: [ref.campus("koblenz")] }], + }; + await expect(resolveScopeRefs([perm], resolverFor(emptyState(HOST)), emptyState(HOST))).rejects.toThrow( + /takes no scope/, + ); + }); + + it("rejects a BARE STRING scope on a non-group dimension — it would silently grant on groups", async () => { + // Before #98 this was the trap: "koblenz" was looked up among managed GROUPS regardless of the + // right's dimension, so it either errored confusingly or matched an unrelated same-keyed group. + const perm: DesiredPermission = { + key: "p", domainType: "group_role", domainId: 1, + grants: [{ right: VIEW_STATION, scope: ["koblenz"] }], + }; + const state = stateWithKoblenz(6); + await expect(tuplesFor(perm, state)).rejects.toThrow(/bare string.*scopes by "cdb_station".*\{ campus: "koblenz" \}/s); + }); + + it("a ref that cannot resolve is a hard error, never a dropped or guessed dataId", async () => { + const perm: DesiredPermission = { + key: "p", domainType: "group_role", domainId: 1, + grants: [{ right: VIEW_STATION, scope: [ref.campus("nowhere")] }], + }; + await expect(tuplesFor(perm, emptyState(HOST))).rejects.toThrow(/Cannot resolve campus:nowhere/); + }); + + it("still accepts a numeric dataId on any dimension (the #49 escape hatch is untouched)", async () => { + const perm: DesiredPermission = { + key: "p", domainType: "group_role", domainId: 1, + grants: [{ right: "churchdb:view alldata", scope: [7] }], + }; + expect(await tuplesFor(perm, emptyState(HOST))).toEqual([{ authId: 102, dataId: [7], type: "grant" }]); + }); + + it("resolves a group-type scope ref against the managed group type", async () => { + const state: State = { version: 1, host: HOST, resources: { + struktur: { type: "group-type", id: 12, key: "struktur", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }}; + const perm: DesiredPermission = { + key: "p", domainType: "group_role", domainId: 1, + grants: [{ right: "churchgroup:view groups of grouptype", scope: [ref.groupType("struktur")] }], + }; + expect(await tuplesFor(perm, state)).toEqual([ + { authId: 1108, dataId: [12], type: "grant", scopeKey: "struktur", scopeType: "group-type" }, + ]); + }); +}); + +describe("department scopes are a READ-ONLY ref catalog (cdb_bereich, #98)", () => { + // `/departments` is GET-only — live-probed 2026-08-13 against the instance's own OpenAPI spec + // (eqrm prod, CT 3.135.2). So a department resolves by NAME on every host but is never declarable. + const departments = [{ id: 7, name: "Equippers Koblenz" }, { id: 1, name: "Equippers Rhein-Main" }]; + const client = { get: vi.fn(async (path: string) => (path === "/departments" ? departments : [])) } as unknown as CtClient; + + const perm = (key: string): DesiredPermission => ({ + key: "p", domainType: "group_role", domainId: 1, + grants: [{ right: "churchdb:view alldata", scope: [{ department: key }] }], + }); + + it("resolves a department by name against the live catalog", async () => { + // No scopeKey: a catalog-resolved id is already host-correct and has no managed identity to + // re-resolve at apply time — it behaves exactly like the numeric escape hatch from there on. + expect(await tuplesFor(perm("equippers_koblenz"), emptyState(HOST), [], client)).toEqual([ + { authId: 102, dataId: [7], type: "grant" }, + ]); + }); + + it("hard-errors on an unknown department, and does NOT advise declaring it", async () => { + await expect(tuplesFor(perm("nope"), emptyState(HOST), [], client)).rejects.toThrow( + /no live department at \/departments matches key "nope".*read-only catalog.*cannot be declared or adopted/s, + ); + }); + + it("is never treated as a managed resource, even if a same-keyed resource is in state", async () => { + const state: State = { version: 1, host: HOST, resources: { + equippers_koblenz: { type: "group", id: 999, key: "equippers_koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }}; + // The group must not shadow the department catalog — that would be the misgrant #98 is about. + expect(await tuplesFor(perm("equippers_koblenz"), state, [], client)).toEqual([ + { authId: 102, dataId: [7], type: "grant" }, + ]); + }); +}); + +describe("scope object sugar", () => { + it("compiles { campus: \"koblenz\" } to the same Ref as ref.campus(...)", () => { + const { ct, permissions } = createContext(); + ct.groupRole({ key: "p", id: 900, grants: [{ right: VIEW_STATION, scope: [{ campus: "koblenz" }] }] }); + expect(permissions[0]?.grants).toEqual([{ right: VIEW_STATION, scope: [ref.campus("koblenz")] }]); + }); + + it("rejects a scope object naming zero or several dimensions", () => { + const { ct } = createContext(); + expect(() => + ct.groupRole({ key: "a", id: 1, grants: [{ right: VIEW_STATION, scope: [{ campus: "a", groupType: "b" } as never] }] }), + ).toThrow(/exactly one dimension/); + expect(() => + ct.groupRole({ key: "b", id: 2, grants: [{ right: VIEW_STATION, scope: [{ bereich: "a" } as never] }] }), + ).toThrow(/unknown scope dimension "bereich"/); + }); + + it("rejects a non-string logical key", () => { + const { ct } = createContext(); + expect(() => + ct.groupRole({ key: "a", id: 1, grants: [{ right: VIEW_STATION, scope: [{ campus: 6 } as never] }] }), + ).toThrow(/"campus" must be a non-empty logical key/); + }); +}); + +describe("re-resolution is type-aware", () => { + it("re-resolves a campus scope against the campus in state, not a same-keyed group", () => { + const state: State = { version: 1, host: HOST, resources: { + koblenz: { type: "campus", id: 23, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }}; + const t = { authId: 124, dataId: [], type: "grant" as const, scopeKey: "koblenz", scopeType: "campus", pending: true }; + expect(reresolveTuple(t, state)).toEqual({ ...t, dataId: [23], pending: false }); + }); + + it("refuses to write a grant whose scope key now names a different resource type", () => { + const state: State = { version: 1, host: HOST, resources: { + koblenz: { type: "group", id: 5, key: "koblenz", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }}; + const t = { authId: 124, dataId: [], type: "grant" as const, scopeKey: "koblenz", scopeType: "campus", pending: true }; + expect(() => reresolveTuple(t, state)).toThrow(/did not resolve to a managed campus/); + }); +}); + +describe("resolveScope without a pre-resolution pass", () => { + it("refuses rather than silently dropping a logical scope ref", () => { + expect(() => resolveScope([ref.campus("koblenz")], emptyState(HOST))).toThrow(/was not pre-resolved/); + }); +}); diff --git a/tests/permission-scope.test.ts b/tests/permission-scope.test.ts index 279ceb8..65b0044 100644 --- a/tests/permission-scope.test.ts +++ b/tests/permission-scope.test.ts @@ -10,18 +10,18 @@ const state: State = { version: 1, host: "h", resources: { describe("resolveScope", () => { it("maps managed group keys to resolutions sorted by id", () => { expect(resolveScope(["other", "kids_area"], state)).toEqual([ - { key: "other", id: 7 }, - { key: "kids_area", id: 42 }, + { key: "other", id: 7, type: "group" }, + { key: "kids_area", id: 42, type: "group" }, ]); }); it("resolves a declared-but-not-yet-created group key to a pending (null id) resolution", () => { - expect(resolveScope(["kids"], state, new Set(["kids"]))).toEqual([{ key: "kids", id: null }]); + expect(resolveScope(["kids"], state, new Set(["kids"]))).toEqual([{ key: "kids", id: null, type: "group" }]); }); it("orders resolved (in-state, by id) before pending (by key)", () => { expect(resolveScope(["pending_b", "kids_area", "pending_a"], state, new Set(["pending_a", "pending_b"]))).toEqual([ - { key: "kids_area", id: 42 }, - { key: "pending_a", id: null }, - { key: "pending_b", id: null }, + { key: "kids_area", id: 42, type: "group" }, + { key: "pending_a", id: null, type: "group" }, + { key: "pending_b", id: null, type: "group" }, ]); }); it("throws for a key that is neither in state nor declared", () => { @@ -31,14 +31,14 @@ describe("resolveScope", () => { it("passes a raw numeric scope entry through directly, without a state lookup (escape hatch, #49)", () => { expect(resolveScope([5, "kids_area"], state)).toEqual([ { key: "5", id: 5, numeric: true }, - { key: "kids_area", id: 42 }, + { key: "kids_area", id: 42, type: "group" }, ]); }); it("sorts numeric and resolved group entries together, ascending by id", () => { expect(resolveScope(["kids_area", 3], state)).toEqual([ { key: "3", id: 3, numeric: true }, - { key: "kids_area", id: 42 }, + { key: "kids_area", id: 42, type: "group" }, ]); }); diff --git a/tests/person-status-resource.test.ts b/tests/person-status-resource.test.ts new file mode 100644 index 0000000..d28b716 --- /dev/null +++ b/tests/person-status-resource.test.ts @@ -0,0 +1,166 @@ +/** + * Person statuses as a declarable resource (#96). + * + * Before this, `ct.status` could declare grants ON a person status but the status itself could not + * be declared, adopted or created — so a config using the `status` domain was NOT self-sufficient + * across hosts: every target instance had to already carry a byte-identically-named status, created + * by hand. The resolver even advised "Declare/adopt it", which was impossible. These tests pin that + * the whole loop now closes: declare → resolve (state first, catalog second) → create → grant. + */ +import { describe, it, expect, vi } from "vitest"; +import { RESOURCES, resourceType, knownFields } from "../src/resources/registry.js"; +import { isKnownType } from "../src/engine/graph.js"; +import { createContext } from "../src/config/context.js"; +import { Resolver } from "../src/resolve/resolver.js"; +import { ref } from "../src/resolve/refs.js"; +import { buildPermissionPlan } from "../src/permissions/plan.js"; +import { applyPermissionPlan } from "../src/permissions/apply.js"; +import { executePlan } from "../src/engine/execute.js"; +import { assertNotPeople } from "../src/engine/guard.js"; +import { emptyState, type State } from "../src/state/state.js"; +import type { DesiredPermission } from "../src/permissions/types.js"; +import type { DesiredResource, Plan } from "../src/engine/types.js"; +import type { CtClient } from "../src/api/ctClient.js"; + +const HOST = "https://mychurch.church.tools"; + +describe("the person-status registry entry", () => { + it("is CRUD-wired against /statuses", () => { + const spec = resourceType("person-status"); + expect(spec.collectionPath).toBe("/statuses"); + expect(spec.itemPath(3)).toBe("/statuses/3"); + expect(spec.updateMethod).toBe("PUT"); + }); + + it("keys by the status NAME — the same slug the /statuses ref resolver matches on", () => { + expect(RESOURCES["person-status"]?.deriveKey({ id: 3, name: "3 - Group Active" })).toBe("3_group_active"); + }); + + it("manages the WHOLE required PUT contract — a subset would 400 and blank the rest", () => { + // Live-probed 2026-08-13 (eqrm prod, CT 3.135.2) against the instance's own OpenAPI spec: + // PUT /statuses/{id} required: name, shorty, isMember, isSearchable, sortKey, securityLevelId + // Uniquely strict among managed types (campus/age-group/target-group/group-role/group-type PUTs + // declare no required fields at all). The executor sends the declared bag as a full-replace PUT, + // so anything left unmanaged here would be dropped from the body — a 400, or a silent blanking. + expect([...knownFields("person-status")].sort()).toEqual([ + "isMember", "isSearchable", "name", "securityLevelId", "shorty", "sortKey", + ]); + expect( + RESOURCES["person-status"]?.managedFields({ + id: 3, name: "3 - Group Active", shorty: "GA", isMember: true, + isSearchable: false, sortKey: 30, securityLevelId: 1, extra: 1, + }), + ).toEqual({ + name: "3 - Group Active", shorty: "GA", isMember: true, + isSearchable: false, sortKey: 30, securityLevelId: 1, + }); + }); + + it("declares NO create defaults — every POST-required field is already managed", () => { + // POST /statuses requires name, shorty, isMember (same probe) — all three are in managedFields, + // so a create-only default would only diverge from what the update path sends. + expect(resourceType("person-status").createDefaults).toBeUndefined(); + }); + + it("has an apply tier, so the engine can order it", () => { + expect(isKnownType("person-status")).toBe(true); + expect(resourceType("person-status").tier).toBe(0); // master data — before groups + }); + + it("is master data, not a people surface — the people guard does not (and must not) block it", () => { + expect(() => assertNotPeople("/statuses")).not.toThrow(); + expect(() => assertNotPeople("/statuses/3")).not.toThrow(); + expect(() => assertNotPeople("/persons/3")).toThrow(/never managed/); + }); +}); + +describe("ct.personStatus in the config DSL", () => { + it("declares a person-status resource", () => { + const { ct, resources } = createContext(); + ct.personStatus({ key: "group_active", name: "3 - Group Active", shorty: "GA" }); + expect(resources[0]).toMatchObject({ + type: "person-status", + key: "group_active", + fields: { name: "3 - Group Active", shorty: "GA" }, + }); + }); + + it("warns on a field the registry does not manage, rather than silently ignoring it", () => { + const spy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const { ct, resources } = createContext(); + ct.personStatus({ key: "s", name: "3 - Group Active", nameTranslated: "Group Active" }); + expect(String(spy.mock.calls[0]![0])).toContain('person-status "s": unknown field "nameTranslated" (ignored)'); + expect(resources[0]?.fields).toHaveProperty("nameTranslated", "Group Active"); // still passed through + } finally { + spy.mockRestore(); + } + }); +}); + +describe("resolving a personStatus reference", () => { + const client = { get: vi.fn(async () => [{ id: 3, name: "3 - Group Active" }]) }; + + it("prefers a MANAGED status in state over the live /statuses catalog", async () => { + const state: State = { version: 1, host: HOST, resources: { + group_active: { type: "person-status", id: 8, key: "group_active", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }}; + const resolver = new Resolver({ client: client as never, state, desired: [] }); + expect(await resolver.resolve(ref.personStatus("group_active"), "site")).toBe(8); + }); + + it("still falls back to the /statuses catalog for a status this config does not own", async () => { + const resolver = new Resolver({ client: client as never, state: emptyState(HOST), desired: [] }); + expect(await resolver.resolve(ref.personStatus("3_group_active"), "site")).toBe(3); + }); +}); + +describe("declare a status AND grants on it, in one config (the #96 trap)", () => { + const desired: DesiredResource[] = [ + { type: "person-status", key: "group_active", fields: { name: "3 - Group Active" }, dependsOn: [] }, + ]; + const perm: DesiredPermission = { + key: "group_active_login", + domainType: "status", + domainId: ref.personStatus("group_active"), + grants: [{ right: "churchcore:login to external system", scope: [-1] }], + }; + + function mockClient(newId: number) { + const calls: { method: string; path: string; body?: unknown }[] = []; + const request = vi.fn(async (method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }); + if (method === "POST" && path === "/statuses") return { id: newId }; + return {}; + }); + // A FRESH instance: /statuses is empty, so the old catalog-only path would hard-error here. + const get = vi.fn(async () => [] as unknown[]); + return { client: { request, get } as unknown as CtClient, calls }; + } + + it("plans without the 'no managed resource and no live person-status' hard error", async () => { + const { client } = mockClient(9); + const { items, fetchErrors } = await buildPermissionPlan(client, emptyState(HOST), [perm], desired); + expect(fetchErrors).toEqual([]); + expect(items[0]?.domainId).toBeNull(); // pending: the status is created in this same run + expect(items[0]?.pendingDomain).toEqual(ref.personStatus("group_active")); + expect(items[0]?.diff.toPut).toHaveLength(1); + }); + + it("applies in one run — the grant is written against the id the status create returned", async () => { + const { client, calls } = mockClient(9); + const state = emptyState(HOST); + const { items } = await buildPermissionPlan(client, state, [perm], desired); + + const createStatus: Plan = { + items: [{ type: "person-status", key: "group_active", id: null, action: "create", + changes: [{ field: "name", from: undefined, to: "3 - Group Active" }] }], + }; + await executePlan(createStatus, { client, state, statePath: "unused", save: async () => {} }); + expect(state.resources.group_active?.id).toBe(9); + + const res = await applyPermissionPlan(items, client, state); + expect(res.granted).toBe(1); + expect(calls.some((c) => c.method === "PUT" && c.path === "/permissions/status/9")).toBe(true); + }); +}); diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts index a585964..e425c9b 100644 --- a/tests/resolver.test.ts +++ b/tests/resolver.test.ts @@ -279,6 +279,54 @@ describe("Resolver.resolveValue", () => { }); }); +describe("catalogs are read PAGINATED (#99 review)", () => { + /** + * A client that also has `getAll`, like the real one. `get` returns only the first page — which is + * exactly what CT does for a plain list read (10 rows by default) — so a resolver that used `get` + * would report the page-2 row as non-existent while `ct get campuses` happily lists it. + */ + function pagingClient(pages: Record) { + const calls: Record = {}; + return { + calls, + get: async (path: string): Promise => { + calls[path] = (calls[path] ?? 0) + 1; + return (pages[path]?.[0] ?? []) as T; + }, + getAll: async (path: string): Promise<{ data: T[] }> => { + calls[path] = (calls[path] ?? 0) + 1; + return { data: (pages[path] ?? []).flat() as T[] }; + }, + }; + } + + const campusPages = [ + Array.from({ length: 10 }, (_, i) => ({ id: i + 1, name: `Campus ${i + 1}` })), + [{ id: 42, name: "Koblenz" }], // page 2 — invisible to a single `get` + ]; + + it("resolves a campus that lives past CT's default first page", async () => { + const client = pagingClient({ "/campuses": campusPages }); + const r = new Resolver({ client, state: emptyState("h"), desired: NO_DESIRED }); + expect(await r.resolve(ref.campus("koblenz"), "site")).toBe(42); + expect(client.calls["/campuses"]).toBe(1); // still fetched once per run + }); + + it("pages the per-group role list too", async () => { + const state = stateWith({ + kids: { type: "group", id: 42, key: "kids", fields: {}, adoptedAt: "t", updatedAt: "t" }, + }); + const client = pagingClient({ + "/groups/42/roles": [ + Array.from({ length: 10 }, (_, i) => ({ id: i + 1, name: `Rolle ${i + 1}` })), + [{ id: 2882, name: "Leiter" }], + ], + }); + const r = new Resolver({ client, state, desired: NO_DESIRED }); + expect(await r.resolve(ref.groupRole("kids", "Leiter"), "perm \"p\"")).toBe(2882); + }); +}); + describe("reresolvePendingValue", () => { it("replaces a pending marker with the id from post-execute state", () => { const state = stateWith({