diff --git a/docs/ci.md b/docs/ci.md index 197d046..4133206 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -203,19 +203,21 @@ Per resource item: `diff.toPut`/`diff.toDelete` is honestly just desired-vs-actual — this is the one place the tool cannot make the distinction, so it doesn't pretend to. -- **A permission domain declared by reference to a same-run-created group - type** (e.g. `ct.groupTypeRole({ groupType: "struktur", ... })` against a - fresh instance where `struktur` is itself in the create-set) plans as a - **pending domain** instead of aborting. Its `domainId` is `null` and it - carries a `pendingDomain` object (the logical reference, e.g. - `{ kind: "group-type", key: "struktur", __ctRef: true }`); the human render - shows ``. Its grants land in - `diff.toPut` and count toward `summary.permissions.toPut`, `hasChanges`, - and exit code `2` — so a fresh-instance `ct plan` reports the create-set + - pending grants rather than failing. `ct apply` re-resolves the real domain - id after the group type is created and reconciles the grants in the same - run. The hard error is reserved for references that resolve to nothing at - all (a key absent from the config, state, and the live catalog — a typo). +- **A permission domain declared by reference to a same-run-created resource** + — a `ct.groupRole` on a group in the create-set (#106), or a `ct.status` on a + person status in the create-set (#90) — plans as a **pending domain** instead + of aborting. (`ct.groupTypeRole` never goes pending: its group type and role + must already exist, #182/#189.) Its `domainId` is `null` and it carries a + `pendingDomain` object (the logical reference, e.g. + `{ kind: "person-status", key: "group_active", __ctRef: true }`); the human + render shows ``. Its grants + land in `diff.toPut` and count toward `summary.permissions.toPut`, + `hasChanges`, and exit code `2` — so a fresh-instance `ct plan` reports the + create-set + pending grants rather than failing. `ct apply` re-resolves the + real domain id after the resource is created and reconciles the grants in + the same run. The hard error is reserved for references that resolve to + nothing at all (a key absent from the config, state, and the live catalog — + a typo). ## Posting a plan as a PR comment diff --git a/docs/handbuch/blueprints.md b/docs/handbuch/blueprints.md index adaaf49..c00d7b1 100644 --- a/docs/handbuch/blueprints.md +++ b/docs/handbuch/blueprints.md @@ -4,7 +4,7 @@ sources: - src/config/context.ts - src/engine/graph.ts - src/engine/hierarchy.ts -sources_hash: b183fe0075bd7ad3 +sources_hash: 636599577c600553 reviewed: 2026-08-28 --- @@ -158,8 +158,9 @@ ct.group({ And the outer default export layers a permission grant (#13 — see [`permissions.md`](permissions.md)) on a shared `groupTypeRole` -template, declared once and applying across every campus's groups of that -type: +template, declared once and applying to every holder of that role in every +campus's groups of that type. The domain is the role, named by its group type +and role name (#182): ```ts export default (ct: ConfigContext): void => { @@ -170,7 +171,8 @@ export default (ct: ConfigContext): void => { const kidsLeads = CAMPUSES.map((c) => `${c}_kids_lead`); ct.groupTypeRole({ key: "kids_lead_tpl", - id: 2, + groupType: "ministry_team", + role: "Leiter", grants: [ { right: "churchgroup:view group", scope: kidsLeads }, { right: "churchgroup:edit group memberships of group", scope: kidsLeads }, diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index 67ff8c2..6c0afb5 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: 83e743cd6cf4caf3 +sources_hash: ffdca84fefe83fde title: Group member fields sources: - src/engine/member-fields.ts diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 0a0e151..fb7c166 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -7,7 +7,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: 1ccb85af4d54a491 +sources_hash: ac44a97574ed9ba9 reviewed: 2026-08-28 --- @@ -80,7 +80,8 @@ person-status rights — as code, and reconcile them idempotently with the same export default (ct) => { ct.groupTypeRole({ key: "leiter_tpl", // logical key (unique across the whole config) - groupType: "ministry_team", // domain BY NAME — resolved to the domainId per host (#20) + groupType: "ministry_team", // domain BY (group type, role) — resolved to the ROLE id per host (#182) + role: "Leiter", grants: [ "churchgroup:view group", // unscoped { right: "churchgroup:view group", scope: ["kids_area"] }, // scoped @@ -111,8 +112,10 @@ reference or a numeric `id`: namespace with every other resource type). - **domain** — the permission domain object. Declare it **by reference** (the portable form, #20) or **by numeric `id`** (the escape hatch): - - `ct.groupTypeRole` — `groupType: ""` resolves against the live - group-type catalog per host, or `id: ` targets one directly. + - `ct.groupTypeRole` — `groupType: "", role: ""` resolves the + pair against the live role catalog (`GET /group/roles`) per host, or + `id: ` targets one directly. `groupType` without `role` is an error + (#182): the domain is a role, not a type. - `ct.groupRole` — `group: "", role: ""` resolves the (group, role) pair to its pairing domainId per host (#25), or `id: ` targets one directly. The group must be **managed** (declared via `ct.group` @@ -264,10 +267,20 @@ still reports 0 for a clean plan. The two DSL functions manage two different ChurchTools "domain types," and `id` means something different for each: -- **`group_type_role`** (`ct.groupTypeRole`) — the domain is the **group type's - own id** (the same id you'd pass as `groupTypeId` on `ct.group`). It scopes the - grant to "every role holder of this group type." Declare it portably as - `groupType: ""` (resolved per host, #20) or directly as `id: `. +- **`group_type_role`** (`ct.groupTypeRole`) — the domain is a **role's id** + (`GET /group/roles` → `id`), _not_ the group type's id. Every role of a group + type carries its own grant set, and the grant applies to "every holder of this + role in any group of this type." Declare it portably as + `groupType: "", role: ""` (resolved per host) or directly as + `id: `. + + > **Why this is stated so bluntly (#182).** Through ct-cli 3.x this domain was + > documented, and resolved, as the group type's id. The endpoint ignores that + > reading: read live, a group type's own id returned 0 grants while each of + > its roles returned its own set. Ids of types and roles overlap, so a type id + > silently addresses whatever role shares the number — on another group type, + > or on no role at all. A bare `groupType` is therefore rejected. + - **`group_role`** (`ct.groupRole`) — the domain is the **internal (group, role) pairing's own id** — a ChurchTools-internal id for one specific group's specific role, _not_ the group's id and _not_ the role's @@ -283,7 +296,8 @@ The two DSL functions manage two different ChurchTools "domain types," and check, not a truthiness one. > **Person status ≠ group status.** `groupStatusId` (`ct.group`) is a - > different dimension with **no** REST catalog at all (#67) and must always be + > different dimension: its catalog is read-only (`GET /person/masterdata` → + > `groupStatuses`), ct does not resolve it by name yet (#157), and it must be > written as a number. Person statuses do have one (`GET /statuses`, flat > array of `{id, name}` — live-verified 2026-08-10 on eqrm prod), so they > resolve by name like campuses and group types. @@ -318,7 +332,8 @@ The two DSL functions manage two different ChurchTools "domain types," and 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. + domain and converges in one `ct apply` (see + [Domains created in the same run](#domains-created-in-the-same-run-fresh-instance-rehearsal-69)). > **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 @@ -338,30 +353,40 @@ The two DSL functions manage two different ChurchTools "domain types," and > hardcode it like any other domainId. Resolution runs in `buildPermissionPlan` (`src/permissions/plan.ts`): a numeric -`id` passes straight through; a `groupType` reference resolves against the live -catalog, and a `group` + `role` pair against the group's role list. After +`id` passes straight through; a `groupType` + `role` pair resolves against the +live role catalog (filtered to that group type), and a `group` + `role` pair +against the group's role list. After resolution, two declarations that resolve to the **same** `(domainType, domainId)` are rejected (they would otherwise diff against each other's grants forever) — even if one used a name and the other a raw id. ### Domains created in the same run (fresh-instance rehearsal, #69) -When a `groupType` reference names a group type that is **created in this same -run** (empty/partial state — the type is part of the create-set), the domain is -handled as a **pending domain** rather than aborting the plan: +When a permission domain references a resource that is **created in this same +run** (empty/partial state — the resource is part of the create-set), the +domain is handled as a **pending domain** rather than aborting the plan. That +covers a `personStatus` declared in the same config (#90) and a `group_role` on +a same-run group (#106, below): -- `ct plan` renders the grant block with a - ` (created this apply)>` marker (consistent with resource - pending refs, #20/#46) and counts its grants in `--json` - (`domainId: null` + a `pendingDomain` reference) and toward exit code `2`. +- `ct plan` renders the grant block with a `<… (created this apply)>` marker + (consistent with resource pending refs, #20/#46) and counts its grants in + `--json` (`domainId: null` + a `pendingDomain` reference) and toward exit + code `2`. - `ct apply` runs permission reconciliation **after** the resources are - created, re-resolving the domain id from the fresh group type and granting in + created, re-resolving the domain id from the fresh resource and granting in the same run — so a single `ct apply` converges fully. This reuses the same re-resolution machinery as resource pending refs. - The hard error (`references a resource created in the same run` → now only a genuine unresolvable) is reserved for references that resolve to **nothing**: a key absent from the config, state, and the live catalog (a typo). +> **Not for `group_type_role` (#182).** A `groupType` + `role` domain whose +> group type — or role — is created in the same run is a plan-time error that +> asks you to apply the master data first. #69 introduced this section for a +> bare `groupType` domain, but that resolved to the type's id, which the +> endpoint reads as a role id: it never granted on the type it named. Letting +> the role-keyed form go pending is #189. + **`group_role` behaves the same way since #106.** A `group_role` domain id is the (group, role) **pairing** id, which only exists on `GET /groups/{groupId}/roles` — so completing it needs a _live fetch_ after the diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index 0f63797..f367b3c 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -40,16 +40,16 @@ in that instance's own config repo, in a runbook following this doc's structure. ## Not yet implemented — API supports it, `ct` doesn't drive it yet -| 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) — ct does not resolve group statuses by name yet, although `/person/masterdata` → `groupStatuses` is a read catalog (#157), 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, 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) | -| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a member-status write, or — separately — a first-ever group-status list/write endpoint, #67) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | +| 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: "", role: ""` for a `group_type_role` domain (the ROLE id, #182), 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) — ct does not resolve group statuses by name yet, although `/person/masterdata` → `groupStatuses` is a read catalog (#157), 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, 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) | +| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a member-status write, or — separately — a first-ever group-status list/write endpoint, #67) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) | ## Out of tool scope — deliberate, not a gap diff --git a/examples/campus-blueprint.config.ts b/examples/campus-blueprint.config.ts index 7dba279..a12efda 100644 --- a/examples/campus-blueprint.config.ts +++ b/examples/campus-blueprint.config.ts @@ -67,6 +67,7 @@ export default (ct: ConfigContext): void => { ct.groupTypeRole({ key: "kids_lead_tpl", groupType: "ministry_team", + role: "Leiter", grants: [ { right: "churchgroup:view group", scope: kidsLeads }, { right: "churchgroup:edit group memberships of group", scope: kidsLeads }, diff --git a/examples/permissions.config.ts b/examples/permissions.config.ts index 569d9c0..75d00bf 100644 --- a/examples/permissions.config.ts +++ b/examples/permissions.config.ts @@ -21,7 +21,10 @@ export default (ct: ConfigContext): void => { ct.groupTypeRole({ key: "leiter_tpl", - groupType: "kids", // logical domain — the resolver maps it to the group type's id per host + // Logical domain: the (group type, role) pair resolves to that ROLE's id per host. The endpoint is + // role-keyed — every role of a type has its own grant set, so the role is required (#182). + groupType: "kids", + role: "Leiter", grants: [ // Unscoped: applies everywhere this group type's role holds. authId 1101. "churchgroup:view", diff --git a/examples/portable.config.ts b/examples/portable.config.ts index 92553c3..5a758b9 100644 --- a/examples/portable.config.ts +++ b/examples/portable.config.ts @@ -66,6 +66,7 @@ export default (ct: ConfigContext): void => { ct.groupTypeRole({ key: "kids_lead_tpl", groupType: "ministry_team", + role: "Leiter", grants: [{ right: "churchgroup:view group", scope: ["mainz_kids_lead"] }], }); }; diff --git a/src/config/context.ts b/src/config/context.ts index 6177825..db69dd3 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -163,11 +163,18 @@ export interface PermissionInput { key: string; /** Numeric domainId (the escape hatch). Mutually exclusive with the logical forms below. */ id?: number; - /** `group_type_role`: the group type by name/key — sugars into a Ref-valued domainId (#20). */ + /** + * `group_type_role`: the group type by name/key, paired with `role` — the two resolve to that + * role's id (#182). `/permissions/group_type_role/` is keyed by the ROLE id, not the group-type + * id: every role of a type carries its own grant set, so a type alone does not name a domain. + */ groupType?: string; /** `group_role`: the group by key (paired with `role`) — resolves to the pairing domainId (#25). */ group?: string; - /** `group_role`: the role name (paired with `group`) — resolves to the pairing domainId (#25). */ + /** + * The role name. `group_role`: paired with `group` → the pairing domainId (#25). `group_type_role`: + * paired with `groupType` → the role id within that type (#182). + */ role?: string; /** `status`: the PERSON status by name/key (`/statuses`) — sugars into a Ref-valued domainId (#90). */ personStatus?: string; @@ -201,7 +208,8 @@ function domainKeyPart(domainId: number | Ref): string { /** * Resolve a permission declaration's domain to a numeric id (escape hatch) or a {@link Ref} (#20): - * - `group_type_role`: numeric `id`, or logical `groupType: ""` → `ref.groupType(...)`. + * - `group_type_role`: numeric `id` (a ROLE id), or logical `groupType` + `role` → + * `ref.groupTypeRole(...)`, resolved against `/group/roles` (#182). * - `group_role`: numeric `id`, or logical `group` + `role` → `ref.groupRole(...)` (the resolver * maps the pair to its pairing domainId at plan time; see #25). * - `status`: numeric `id`, or logical `personStatus: ""` → `ref.personStatus(...)`, resolved @@ -210,7 +218,7 @@ function domainKeyPart(domainId: number | Ref): string { */ /** The logical field name each domain type offers, for the "provide id or ..." error message. */ const LOGICAL_FIELD: Record = { - group_type_role: '"groupType"', + group_type_role: '"groupType" + "role"', group_role: '"group" + "role"', status: '"personStatus"', }; @@ -222,11 +230,24 @@ function resolveDomainInput(domainType: DomainType, input: PermissionInput): num `${domainType} "${input.key}": declare either "id" (numeric) or ${logical} (logical), not both.`, ); if (domainType === "group_type_role") { - if (input.groupType !== undefined) { - if (hasId) throw bothError('"groupType"'); + if (input.groupType !== undefined || input.role !== undefined) { + if (hasId) throw bothError('"groupType" + "role"'); if (typeof input.groupType !== "string" || !input.groupType) throw new Error(`${domainType} "${input.key}": "groupType" must be a non-empty group-type key.`); - return ref.groupType(input.groupType); + if (input.role === undefined) + // #182: this used to resolve to the group TYPE id and use it as the domainId. The endpoint is + // keyed by ROLE id, so it silently addressed whichever role happened to share that number — + // a role of another group type, or no role at all. + // There is no type-wide grant set in ChurchTools to fall back to, so this is an error, not a + // default: name the role. + throw new Error( + `${domainType} "${input.key}": "groupType" alone does not name a permission domain — ` + + `/permissions/group_type_role/ is keyed by ROLE id (eqrm/ct-cli#182). Add ` + + `"role": "" and declare one block per role of "${input.groupType}".`, + ); + if (typeof input.role !== "string" || !input.role) + throw new Error(`${domainType} "${input.key}": "role" must be a non-empty role name.`); + return ref.groupTypeRole(input.groupType, input.role); } } else if (domainType === "status") { if (input.personStatus !== undefined) { diff --git a/src/permissions/adopt.ts b/src/permissions/adopt.ts index 1b78bd6..90cc2cf 100644 --- a/src/permissions/adopt.ts +++ b/src/permissions/adopt.ts @@ -50,6 +50,14 @@ const DSL_FN: Record = { status: "ct.status", }; +/** The comment on an emitted numeric `id:`: it is host-specific, and this is the portable form. */ +const ID_COMMENT: Record = { + group_role: "host-specific — adopt the group to emit the portable group + role form", + group_type_role: + 'a ROLE id, host-specific (#182) — portable form: groupType: "", role: ""', + status: 'host-specific — portable form: personStatus: ""', +}; + /** DSL sugar field name per managed scope type, for emitting `{ campus: "koblenz" }`-style refs. */ const SCOPE_SUGAR_FIELD: Readonly> = { campus: "campus", @@ -190,7 +198,7 @@ export function buildAdoptedGrants(args: AdoptGrantsArgs): AdoptedGrantsBlock { body.push(` group: ${JSON.stringify(domain.group)},`); body.push(` role: ${JSON.stringify(domain.role)},`); } else { - body.push(` id: ${domainId}, // host-specific — adopt the group to emit the portable group + role form`); + body.push(` id: ${domainId}, // ${ID_COMMENT[domainType]}`); } if (grants.length === 0) { diff --git a/src/permissions/apply.ts b/src/permissions/apply.ts index 0017735..b368ad0 100644 --- a/src/permissions/apply.ts +++ b/src/permissions/apply.ts @@ -117,7 +117,7 @@ export async function applyPermissionPlan( roleLists, ); } else { - // Every other pending domain (group type, person status) IS its resource's own id, so the + // Every other pending domain (a person status) IS its resource's own id, so the // SAME machinery that re-resolves resource pending refs finishes it from state alone. domainId = reresolvePendingValue(pendingRef(item.pendingDomain), state) as number; } diff --git a/src/permissions/plan.ts b/src/permissions/plan.ts index 04bacb7..e2b005c 100644 --- a/src/permissions/plan.ts +++ b/src/permissions/plan.ts @@ -166,8 +166,8 @@ export function preservePredicateFor( /** * A permission whose domainId has been resolved. Either a concrete numeric domain, or — when the - * domain is a group type created in this same run (#69) — a `pendingDomain` Ref with `domainId: null`, - * re-resolved at apply time. + * domain is a resource created in this same run (a person status, #90; a group_role on a new group, + * #106) — a `pendingDomain` Ref with `domainId: null`, re-resolved at apply time. */ type ResolvedPermission = | (DesiredPermission & { domainId: number; pendingDomain?: undefined }) @@ -175,7 +175,7 @@ type ResolvedPermission = /** * Resolve every permission's domainId (#20). A numeric domainId passes straight through; a Ref - * (e.g. `groupType: "…"`) resolves against managed state ∪ the live catalog. A domainId that + * (e.g. `groupType` + `role`) resolves against managed state ∪ the live catalog. A domainId that * resolves to a same-run-created resource (PendingRef) is NOT rejected (#69): it is carried as a * `pendingDomain` and re-resolved against post-execute state at apply time — this is what lets a * fresh-instance plan render the create-set + pending grants instead of aborting. @@ -204,6 +204,14 @@ async function resolveDomainIds( continue; } const site = `${p.domainType} "${p.key}".domainId`; + // #182: `/permissions/group_type_role/` is keyed by ROLE id. A group-type Ref resolves to the + // TYPE id and would address whichever role shares that number, so it is refused here too — not + // only in the DSL — for any producer that builds a DesiredPermission directly. + if (p.domainType === "group_type_role" && p.domainId.kind === "group-type") + throw new Error( + `${site}: a group type (${refLabel(p.domainId)}) does not name a group_type_role domain — ` + + `the domain is keyed by ROLE id (#182). Reference the role: ref.groupTypeRole(, ).`, + ); // `pendingGroupRole` is opt-in per position (#106): this is the ONLY call site that can finish a // pending group_role, because `applyPermissionPlan` runs after `executePlan` and holds a client to // fetch the freshly created group's role list with. diff --git a/tests/context.test.ts b/tests/context.test.ts index 0bea769..5820391 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -485,11 +485,29 @@ describe("permission declarations", () => { expect(permissions).toHaveLength(2); }); - it("sugars a logical `groupType` into a Ref-valued domainId (#20)", async () => { + it("sugars `groupType` + `role` into a group-type-role Ref — the endpoint is role-keyed (#182)", async () => { const { permissions } = await evaluateConfig((ct: ConfigContext) => - ct.groupTypeRole({ key: "tpl", groupType: "ministry_team", grants: ["churchgroup:view group"] }), + ct.groupTypeRole({ + key: "tpl", + groupType: "ministry_team", + role: "Leiter", + grants: ["churchgroup:view group"], + }), ); - expect(permissions[0]?.domainId).toEqual({ __ctRef: true, kind: "group-type", key: "ministry_team" }); + expect(permissions[0]?.domainId).toEqual({ + __ctRef: true, + kind: "group-type-role", + groupType: "ministry_team", + role: "Leiter", + }); + }); + + it("rejects a bare `groupType` — a type alone names no permission domain (#182)", async () => { + await expect( + evaluateConfig((ct: ConfigContext) => + ct.groupTypeRole({ key: "tpl", groupType: "ministry_team", grants: ["churchgroup:view group"] }), + ), + ).rejects.toThrow(/keyed by ROLE id.*#182/s); }); it("sugars group_role `group` + `role` into a compound Ref (gated at plan time, #25)", async () => { @@ -507,7 +525,13 @@ describe("permission declarations", () => { it("rejects declaring both a numeric id and a logical domain form", async () => { await expect( evaluateConfig((ct: ConfigContext) => - ct.groupTypeRole({ key: "tpl", id: 8, groupType: "mt", grants: ["churchgroup:view group"] }), + ct.groupTypeRole({ + key: "tpl", + id: 8, + groupType: "mt", + role: "Leiter", + grants: ["churchgroup:view group"], + }), ), ).rejects.toThrow(/either "id".*or "groupType".*not both/); }); @@ -515,10 +539,15 @@ describe("permission declarations", () => { it("dedups two logical declarations targeting the same group-type ref", async () => { await expect( evaluateConfig((ct: ConfigContext) => { - ct.groupTypeRole({ key: "a", groupType: "mt", grants: ["churchgroup:view group"] }); - ct.groupTypeRole({ key: "b", groupType: "mt", grants: ["churchdb:view group members"] }); + ct.groupTypeRole({ key: "a", groupType: "mt", role: "Leiter", grants: ["churchgroup:view group"] }); + ct.groupTypeRole({ + key: "b", + groupType: "mt", + role: "Leiter", + grants: ["churchdb:view group members"], + }); }), - ).rejects.toThrow(/Duplicate permission target.*group-type:mt/s); + ).rejects.toThrow(/Duplicate permission target.*group-type-role:mt Leiter/s); }); // The PERSON-status permission domain (#90) — the instance-wide grant lever. diff --git a/tests/permission-adopt.test.ts b/tests/permission-adopt.test.ts index 08db9bb..ac26e22 100644 --- a/tests/permission-adopt.test.ts +++ b/tests/permission-adopt.test.ts @@ -82,6 +82,20 @@ describe("emitAdoptedGrants", () => { expect(block).not.toContain("authId"); }); + it("names each domain type's own portable form next to the numeric id (#182)", () => { + const rows: RawPermission[] = [ + { authId: 1, dataId: null, type: "grant", domainId: 3, meta: { modifiedPid: 5 } }, + ]; + const idLine = (domainType: "group_type_role" | "group_role" | "status") => + emitAdoptedGrants({ domainType, domainId: 3, rows, state: emptyState() }) + .split("\n") + .find((l) => l.includes("id: 3,")); + expect(idLine("group_type_role")).toContain('groupType: "", role: ""'); + expect(idLine("group_role")).toContain("adopt the group to emit the portable group + role form"); + expect(idLine("status")).toContain('personStatus: ""'); + expect(idLine("status")).not.toContain("adopt the group"); + }); + it("group_role emits ct.groupRole", () => { const rows: RawPermission[] = [ { authId: 1, dataId: null, type: "grant", domainId: 7, meta: { modifiedPid: 5 } }, diff --git a/tests/permission-pending-domain.test.ts b/tests/permission-pending-domain.test.ts index 0f6996c..cc37a36 100644 --- a/tests/permission-pending-domain.test.ts +++ b/tests/permission-pending-domain.test.ts @@ -1,11 +1,15 @@ /** - * Pending permission domains (#69): a permission domain declared BY REFERENCE to a group type that - * is created in the SAME run must NOT abort the plan. Instead it plans as a pending grant block and - * reconciles at apply time once the group type has a fresh id — mirroring resource pending refs - * (#20/#46) and the scope pending path (#29). This is the #23 fresh-instance rehearsal scenario. + * Pending permission domains: a permission domain declared BY REFERENCE to a resource created in the + * SAME run plans as a pending grant block and reconciles at apply time, mirroring resource pending + * refs (#20/#46) and the scope pending path (#29). * - * Exercises the REAL build → execute → apply sequence with a mock client, so convergence in ONE - * `ct apply` run is proven end-to-end. No live instance. + * #69 introduced this for a `group_type_role` domain named by its group type. #182 found that domain + * was never the group type: `/permissions/group_type_role/` is keyed by ROLE id, so the pending + * path wrote to whichever role shared the fresh type's number. A group-type Ref is now refused for + * that domain, and a group-type-role Ref on a same-run group type is a plan-time error until it can + * go pending (#189). The group_role pending path (#106) is unaffected. + * + * Exercises the REAL build → execute → apply sequence with a mock client. No live instance. */ import { describe, it, expect, vi } from "vitest"; import { buildPermissionPlan } from "../src/permissions/plan.js"; @@ -22,30 +26,6 @@ import type { CtClient } from "../src/api/ctClient.js"; const HOST = "https://mychurch.church.tools"; const STRUKTUR_TYPE_ID = 9; -// The #23 config, in miniature: declare a group type AND a group_type_role permission domain that -// references it by name — with ZERO numeric ids. "churchgroup:administer groups" is authId 1113, -// unscoped (global). -const strukturType: DesiredResource[] = [ - { type: "group-type", key: "struktur", fields: { name: "Struktur" }, dependsOn: [] }, -]; -const strukturPerm: DesiredPermission = { - key: "struktur_roles", - domainType: "group_type_role", - domainId: ref.groupType("struktur"), - grants: ["churchgroup:administer groups"], -}; -const createStrukturPlan: Plan = { - items: [ - { - type: "group-type", - key: "struktur", - id: null, - action: "create", - changes: [{ field: "name", from: undefined, to: "Struktur" }], - }, - ], -}; - /** A mock client: POST /group/grouptypes mints STRUKTUR_TYPE_ID; GETs return whatever `perms` maps. */ function mockClient(perms: Record = {}) { const calls: { method: string; path: string; body?: unknown }[] = []; @@ -58,118 +38,59 @@ function mockClient(perms: Record = {}) { return { client: { request, get } as unknown as CtClient, calls, get }; } -describe("pending domain: declare group type + grant by reference in one config (#69/#23)", () => { - it("plans from EMPTY state without aborting — a pending grant block, not the hard error", async () => { - const { client, get } = mockClient(); - const { items, fetchErrors } = await buildPermissionPlan( - client, - emptyState(HOST), - [strukturPerm], - strukturType, - ); +const strukturType: DesiredResource[] = [ + { type: "group-type", key: "struktur", fields: { name: "Struktur" }, dependsOn: [] }, +]; +const grants = ["churchgroup:administer groups"]; - expect(fetchErrors).toEqual([]); - // The domain is pending: no numeric id yet, the Ref is carried for apply-time re-resolution. - expect(items[0]?.domainId).toBeNull(); - expect(items[0]?.pendingDomain).toEqual(ref.groupType("struktur")); - // Every desired grant lands in toPut against an empty actual set (the type has no live grants). - expect(items[0]?.diff.toPut).toEqual([{ authId: 1113, dataId: [], type: "grant" }]); - expect(items[0]?.diff.toDelete).toEqual([]); - // No /permissions fetch for a pending-only plan — nothing to fetch on a not-yet-created type. +describe("group_type_role is keyed by role, not by group type (#182)", () => { + it("refuses a group-type Ref as the domain instead of writing to the type id", async () => { + // Before #182 this resolved to STRUKTUR_TYPE_ID and granted on whichever ROLE carries that id. + const { client, get } = mockClient({ "/group/grouptypes": [{ id: STRUKTUR_TYPE_ID, name: "Struktur" }] }); + const typePerm: DesiredPermission = { + key: "struktur_roles", + domainType: "group_type_role", + domainId: ref.groupType("struktur"), + grants, + }; + await expect(buildPermissionPlan(client, emptyState(HOST), [typePerm], [])).rejects.toThrow( + /group_type_role "struktur_roles".domainId: a group type \(group-type:struktur\) does not name a group_type_role domain/, + ); expect(get).not.toHaveBeenCalled(); - // Read-only render shows the pending marker, consistent with resource pending-ref rendering. - expect(renderPermissionPlan(items)).toContain(""); }); - it("counts as a change for --detailed-exitcode / --json (toPut > 0)", async () => { + it("hard-errors (not pending) on a role of a group type created this run, until #189", async () => { const { client } = mockClient(); - const { items } = await buildPermissionPlan(client, emptyState(HOST), [strukturPerm], strukturType); - const hasPermissionChanges = items.some((i) => i.diff.toPut.length > 0 || i.diff.toDelete.length > 0); - expect(hasPermissionChanges).toBe(true); - expect(items.reduce((n, i) => n + i.diff.toPut.length, 0)).toBe(1); - }); - - it("applies in ONE run — create then grant against the FRESH id — and a second plan is a no-op", async () => { - const { client, calls } = mockClient(); - const state = emptyState(HOST); - const { items } = await buildPermissionPlan(client, state, [strukturPerm], strukturType); - - // executePlan creates the group type and upserts its real id into state… - await executePlan(createStrukturPlan, { client, state, statePath: "unused", save: async () => {} }); - expect(state.resources.struktur?.id).toBe(STRUKTUR_TYPE_ID); - - // …then permission reconciliation runs against POST-execute state and writes to the fresh domain. - const res = await applyPermissionPlan(items, client, state); - expect(res.granted).toBe(1); - const put = calls.find( - (c) => c.method === "PUT" && c.path === `/permissions/group_type_role/${STRUKTUR_TYPE_ID}`, + const rolePerm: DesiredPermission = { + key: "struktur_leiter", + domainType: "group_type_role", + domainId: ref.groupTypeRole("struktur", "Leiter"), + grants, + }; + await expect(buildPermissionPlan(client, emptyState(HOST), [rolePerm], strukturType)).rejects.toThrow( + /group type "struktur" is declared in this config but not yet created/, ); - expect(put?.body).toEqual({ authId: 1113, type: "grant" }); // fresh domain id in the path, not a placeholder - - // Second plan (type now in state, grant now live) converges to a no-op — domain is concrete. - const { client: c2 } = mockClient({ - "/permissions/group_type_role": [ - { - domainType: "group_type_role", - domainId: STRUKTUR_TYPE_ID, - authId: 1113, - dataId: null, - type: "grant", - meta: { modifiedPid: 1 }, - }, - ], - }); - const { items: items2, fetchErrors } = await buildPermissionPlan(c2, state, [strukturPerm], strukturType); - expect(fetchErrors).toEqual([]); - expect(items2[0]?.domainId).toBe(STRUKTUR_TYPE_ID); // now concrete, not pending - expect(items2[0]?.pendingDomain).toBeUndefined(); - expect(items2[0]?.diff.toPut).toEqual([]); - expect(items2[0]?.diff.toDelete).toEqual([]); - expect(renderPermissionPlan(items2)).toContain("No permission changes"); }); -}); -describe("pending domain: prod-like scenario (type already in state) is unchanged (#69)", () => { - it("resolves to the concrete domain id and reconciles idempotently — no pending path", async () => { - const state: State = { - version: 1, - host: HOST, - resources: { - struktur: { - type: "group-type", - id: STRUKTUR_TYPE_ID, - key: "struktur", - fields: { name: "Struktur" }, - adoptedAt: "t", - updatedAt: "t", - }, - }, + it("still hard-errors on a TRUE typo — the resolver's notFound message", async () => { + // "strucktur" is neither declared, nor in state, nor a live catalog match → genuinely unresolvable. + const typoPerm: DesiredPermission = { + key: "struktur_leiter", + domainType: "group_type_role", + domainId: ref.groupTypeRole("strucktur", "Leiter"), + grants, }; - const { client } = mockClient({ - "/permissions/group_type_role": [ - { - domainType: "group_type_role", - domainId: STRUKTUR_TYPE_ID, - authId: 1113, - dataId: null, - type: "grant", - meta: { modifiedPid: 1 }, - }, - ], - }); - const { items, fetchErrors } = await buildPermissionPlan(client, state, [strukturPerm], strukturType); - expect(fetchErrors).toEqual([]); - expect(items[0]?.domainId).toBe(STRUKTUR_TYPE_ID); - expect(items[0]?.pendingDomain).toBeUndefined(); - expect(items[0]?.diff.toPut).toEqual([]); - expect(items[0]?.diff.toDelete).toEqual([]); + const { client } = mockClient({ "/group/grouptypes": [{ id: STRUKTUR_TYPE_ID, name: "Struktur" }] }); + await expect(buildPermissionPlan(client, emptyState(HOST), [typoPerm], strukturType)).rejects.toThrow( + /Cannot resolve group-type:strucktur referenced at group_type_role "struktur_leiter".domainId/, + ); }); }); describe("group_role symmetry: a same-run group DOES go pending and completes in one apply (#106)", () => { // The domain half of #29's deadlock. A group_role domainId is the (group, role) PAIRING id, exposed // only on GET /groups/{id}/roles — so it cannot be completed from post-execute state alone the way a - // group_type_role domain can. It is completed with a live fetch inside applyPermissionPlan instead. + // person-status domain can. It is completed with a live fetch inside applyPermissionPlan instead. // Before #106 this was a hard error, which made the very same config plan clean on prod (group // exists) and exit 1 on dev (group does not) — non-portable by construction. const GROUP_ID = 4711; @@ -333,14 +254,3 @@ describe("group_role symmetry: a same-run group DOES go pending and completes in ).rejects.toThrow(/group "kids_area" is declared in this config but not yet created/); }); }); - -describe("pending domain: a TRUE typo (key absent from config AND state AND catalog) still hard-errors (#69)", () => { - it("throws the resolver's unchanged notFound message — not a pending block", async () => { - // "strucktur" is neither declared, nor in state, nor a live catalog match → genuinely unresolvable. - const typoPerm: DesiredPermission = { ...strukturPerm, domainId: ref.groupType("strucktur") }; - const { client } = mockClient({ "/group/grouptypes": [{ id: STRUKTUR_TYPE_ID, name: "Struktur" }] }); - await expect(buildPermissionPlan(client, emptyState(HOST), [typoPerm], strukturType)).rejects.toThrow( - /Cannot resolve group-type:strucktur referenced at group_type_role "struktur_roles".domainId/, - ); - }); -}); diff --git a/tests/portable-refs.test.ts b/tests/portable-refs.test.ts index a38fb85..2cff2d3 100644 --- a/tests/portable-refs.test.ts +++ b/tests/portable-refs.test.ts @@ -169,36 +169,49 @@ describe("apply-time pending re-resolution (same-run campus + group)", () => { }); describe("permission domainId resolution", () => { - it("resolves a groupType ref to the domainId and diffs against it", async () => { + // #182: the domain is the ROLE id, never the group-type id. The fixture gives them different + // numbers on purpose — on a real instance they collided (type 9 = role 9 on another type), which is how + // the bug stayed invisible. + const roleCatalogClient = { + get: async (path: string): Promise => { + if (path === "/group/grouptypes") return [{ id: 9, name: "Ministry Team" }] as T; + if (path === "/group/roles") + return [ + { id: 9, groupTypeId: 1, name: "Leiter" }, // same number as the type — must NOT be picked + { id: 901, groupTypeId: 9, name: "Leiter" }, + { id: 902, groupTypeId: 9, name: "Mitglied" }, + ] as T; + if (path === "/permissions/group_type_role") return [] as T; + throw new CtApiError(`not found: ${path}`, 404, null); + }, + }; + + it("resolves a groupType + role ref to the ROLE id and diffs against it (#182)", async () => { const { permissions } = await evaluateConfig((ct) => { - ct.groupTypeRole({ key: "tpl", groupType: "ministry_team", grants: ["churchgroup:administer groups"] }); + ct.groupTypeRole({ + key: "tpl", + groupType: "ministry_team", + role: "Leiter", + grants: ["churchgroup:administer groups"], + }); }); - const client = { - get: async (path: string): Promise => { - if (path === "/group/grouptypes") return [{ id: 9, name: "Ministry Team" }] as T; - if (path === "/permissions/group_type_role") return [] as T; - throw new CtApiError(`not found: ${path}`, 404, null); - }, - }; - const { items } = await buildPermissionPlan(client, emptyState("h"), permissions); + const { items } = await buildPermissionPlan(roleCatalogClient, emptyState("h"), permissions); expect(items).toHaveLength(1); - expect(items[0]?.domainId).toBe(9); // resolved from the catalog, not a raw number + expect(items[0]?.domainId).toBe(901); // the role's id within the type — not the type id 9 }); it("rejects two permissions whose refs resolve to the same domainId (post-resolution guard)", async () => { const { permissions } = await evaluateConfig((ct) => { - ct.groupTypeRole({ key: "a", groupType: "ministry_team", grants: ["churchgroup:administer groups"] }); - ct.groupTypeRole({ key: "b", id: 9, grants: ["churchgroup:administer groups"] }); + ct.groupTypeRole({ + key: "a", + groupType: "ministry_team", + role: "Leiter", + grants: ["churchgroup:administer groups"], + }); + ct.groupTypeRole({ key: "b", id: 901, grants: ["churchgroup:administer groups"] }); }); - const client = { - get: async (path: string): Promise => { - if (path === "/group/grouptypes") return [{ id: 9, name: "Ministry Team" }] as T; - if (path === "/permissions/group_type_role") return [] as T; - throw new CtApiError(`not found: ${path}`, 404, null); - }, - }; - await expect(buildPermissionPlan(client, emptyState("h"), permissions)).rejects.toThrow( - /Duplicate permission target after resolution: group_type_role #9/, + await expect(buildPermissionPlan(roleCatalogClient, emptyState("h"), permissions)).rejects.toThrow( + /Duplicate permission target after resolution: group_type_role #901/, ); }); @@ -251,6 +264,7 @@ describe("acceptance: one config, two hosts", () => { ct.groupTypeRole({ key: "tpl", groupType: "ministry_team", + role: "Leiter", grants: [{ right: "churchgroup:view group", scope: ["kids"] }], }); }; @@ -259,6 +273,7 @@ describe("acceptance: one config, two hosts", () => { const { resources, permissions } = await evaluateConfig(config); const catalogs = { "/group/grouptypes": [{ id: groupTypeId, name: "Ministry Team" }], + "/group/roles": [{ id: groupTypeId * 10, groupTypeId, name: "Leiter" }], "/permissions/group_type_role": [], }; const client = fakeHost(catalogs); @@ -287,9 +302,9 @@ describe("acceptance: one config, two hosts", () => { source: "config", }); - // Permission domainId is resolved per host from the same logical ref. - expect(a.items[0]?.domainId).toBe(2); - expect(b.items[0]?.domainId).toBe(77); + // Permission domainId is resolved per host from the same logical ref — to the ROLE id (#182). + expect(a.items[0]?.domainId).toBe(20); + expect(b.items[0]?.domainId).toBe(770); // Both plans create the campus + group (2 creates each) — the config is valid against both hosts. expect(a.plan.items.filter((i) => i.action === "create")).toHaveLength(2);