Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 63 additions & 15 deletions docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export default (ct) => {

ct.groupRole({
key: "kids_lead_grant",
id: 2882, // the internal (group, role) domainId — see below (group_role has no ref yet)
group: "kids_area", // domain BY (group, role) — resolved to the pairing domainId per host (#25)
role: "Leiter", // (or keep the numeric escape hatch: `id: 2882`)
// "edit group memberships of group" is a scoped right, so it takes a `scope: [...]`.
grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }],
});
Expand All @@ -35,11 +36,14 @@ reference or a numeric `id`:
portable form, #20) or **by numeric `id`** (the escape hatch):
- `ct.groupTypeRole` — `groupType: "<name>"` resolves against the live
group-type catalog per host, or `id: <domainId>` targets one directly.
- `ct.groupRole` — **`id: <domainId>` only for now.** The logical
`group: "<key>", role: "<name>"` form is accepted by the DSL but the
resolver rejects it at plan time (the (group, role) pairing id has no
confirmed API source — see "domainId semantics" and #25). Declaring both a
logical form and a numeric `id` is a conflict and throws.
- `ct.groupRole` — `group: "<key>", role: "<name>"` resolves the (group,
role) pair to its pairing domainId per host (#25), or `id: <domainId>`
targets one directly. The group must be **managed** (declared via `ct.group`
or adopted into state) and already created — a same-run group is rejected
(its pairing id is only known once it exists; pass a numeric `id` there).
Declaring both a logical form and a numeric `id` is a conflict and throws.
See "domainId semantics" for the resolution assumption still to be
confirmed live.
- **`grants`** — an array of `Grant`s, each either:
- a bare string, `"module:right"` — an **unscoped** grant, or
- an object `{ right: "module:right", scope: (string | number)[] }` — a
Expand Down Expand Up @@ -69,6 +73,40 @@ fetch — with a "did you mean" hint drawn from same-module names. (Config
evaluation only checks a grant's *shape*: `module:right` string or
`{ right, scope }`; it does not resolve the name against the catalog.)

## Catalog lifecycle & staleness (#25)

The catalog (`src/permissions/catalog.json`) is a snapshot of one instance's
permission master data, captured at a specific ChurchTools version. Two things
keep it honest:

**Regeneration — one command.** Point it at a live instance and it rewrites
`catalog.json` (rights + a fresh `$meta` provenance stamp):

```bash
CT_HOST=https://your.church.tools CT_LOGINTOKEN=<token> npm run regenerate:permission-catalog
```

It logs in, calls the legacy `POST /index.php?q=churchauth/ajax` `func=getMasterData`
endpoint (the only source of the name↔authId map — see
`src/permissions/README.md`), records the instance's CT version, and writes the
file. It performs a single **read**; it never writes to the instance. Review
the `git diff` before committing.

**Staleness & unknown rights — `ct plan` warns (never fails).** `$meta.ctVersion`
records the version the catalog was captured from. On every `plan`/`apply`:

- If the live instance's CT version differs from `$meta.ctVersion`, `ct plan`
prints a warning — right names/authIds/scopeFields may have drifted;
regenerate to be sure.
- If a **live grant carries an `authId` the catalog cannot name** (a stale or
foreign right), `ct plan` names the `authId` + domain and **leaves the grant
untouched** — it is deliberately kept *out* of the diff so `ct apply` never
revokes a right it cannot even describe. This is idempotent: the unknown row
is excluded every run, so it neither churns nor silently disappears.

Both are warnings, not errors: the plan still runs and the exit code stays
success. Regenerating the catalog (above) is the fix for both.

## `domainId` semantics

The two DSL functions manage two different ChurchTools "domain types," and
Expand All @@ -81,18 +119,28 @@ The two DSL functions manage two different ChurchTools "domain types," and
- **`group_role`** (`ct.groupRole`) — the domain is the **internal
(group, role) pairing's own id** — a ChurchTools-internal id for one
specific group's specific role, *not* the group's id and *not* the role's
id. **This is `id: <domainId>` only.** The logical `group` + `role` form is
reserved (and accepted by the DSL) but **not yet resolvable**: the pairing id
has no confirmed API source, so the resolver throws a clear "pass a numeric id
(see #25)" error at plan time. Find the id via the ChurchTools permission
editor / an existing `GET /permissions/group_role` response for a group+role
you already have, and hardcode it like any other domainId.
id. Declare it portably as `group: "<key>", role: "<name>"` (resolved per
host, #25) or directly as `id: <domainId>`.

> **ASSUMPTION — verify once on a live instance (`eqrm-dev`).** The reference
> form resolves by reading the group's own role list
> (`GET /groups/{groupId}/roles`) and taking the matched role row's `id` as
> the pairing domainId. Neither the endpoint nor the field is confirmed
> against a live instance (the assumption is pinned in a unit test and in a
> prominent comment in `src/resolve/resolver.ts`). If a live check shows the
> pairing id lives in a different field or endpoint, change the two
> constants at the top of `resolver.ts` — call sites don't change. Until
> confirmed, the numeric `id:` escape hatch is the guaranteed-correct path:
> find the id via the ChurchTools permission editor / an existing
> `GET /permissions/group_role` response, and hardcode it like any other
> domainId.

Resolution runs in `buildPermissionPlan` (`src/permissions/plan.ts`): a numeric
`id` passes straight through; a `groupType` reference resolves against the live
catalog. After resolution, two declarations that resolve to the **same**
`(domainType, domainId)` are rejected (they would otherwise diff against each
other's grants forever) — even if one used a name and the other a raw id.
catalog, and a `group` + `role` pair against the group's role list. After
resolution, two declarations that resolve to the **same** `(domainType,
domainId)` are rejected (they would otherwise diff against each other's grants
forever) — even if one used a name and the other a raw id.

## Scope resolution

Expand Down
39 changes: 27 additions & 12 deletions docs/runbook-manual-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ this doc's structure.
| Item | What it is | Tracking issue | Manual workaround today |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Group/group-type field decision table | Fields deliberately left unmanaged (decided out of scope): visibility, note, `autoAccept`/open-for-members, chat status, sort key. The triage **shipped** as a committed decision table ([`docs/group-field-decisions.md`](group-field-decisions.md)) | [#21](https://github.com/eqrm/ct-cli/issues/21) (decided) | Set by hand; these fields are intentionally not diffed — `ct` will neither preserve nor revert them. Promote one later only with its own registry entry + tests |
| Portable/logical references | **Shipped (#20).** Configs reference master data by name/key — `campus`/`groupType`/`status` on a group, `ref.campus(...)` in ruleset `var` values, `groupType: "<name>"` for a `group_type_role` domain — and the per-host resolver maps each to that instance's id at plan time (managed resources ∪ live catalogs). A same-run campus resolves at apply time. Numeric ids still work as an escape hatch. **Residual gap:** the `group_role` domain by (group, role) reference is still gated — see the row below | [#20](https://github.com/eqrm/ct-cli/issues/20) (done) | None needed for the shipped surface. Write logical names; run `ct plan`. For the gated `group_role` case, use a numeric `id` (next row) |
| Portable/logical references | **Shipped (#20, #25).** Configs reference master data by name/key — `campus`/`groupType`/`status` on a group, `ref.campus(...)` in ruleset `var` values, `groupType: "<name>"` for a `group_type_role` domain, and now `group: "<key>", role: "<name>"` for a `group_role` domain (#25) — and the per-host resolver maps each to that instance's id at plan time (managed resources ∪ live catalogs). A same-run campus resolves at apply time. Numeric ids still work as an escape hatch | [#20](https://github.com/eqrm/ct-cli/issues/20) (done), [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | None needed for the shipped surface. Write logical names; run `ct plan`. See the `group_role` resolution assumption below (one-time live check) |
| Environments (dev → prod promotion) | Named `(host, token, state file)` profiles and a `--env` flag; today one config + one state file = one host | [#22](https://github.com/eqrm/ct-cli/issues/22) | Point `CT_HOST`/state file manually at each target and re-run; keep dev and prod state files apart yourself, and be careful — nothing stops you from applying a dev-shaped config against prod today |
| Permission `group_role` domain by reference | `group_type_role` domains now resolve by name (`groupType: "<name>"`, #20). But `group_role`'s domain is CT's internal (group, role) _pairing_ id, with **no confirmed API source** — the DSL accepts `group: "<key>", role: "<name>"` but the resolver rejects it at plan time with a "pass a numeric id" error | [#25](https://github.com/eqrm/ct-cli/issues/25) | Find the pairing id via the CT permission editor, or an existing `GET /permissions/group_role` response for a group+role you already have, and pass it as numeric `id` ([`docs/permissions.md`](permissions.md) "domainId semantics") |
| Permission `group_role` domain by reference **(shipped, assumption unverified)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **ASSUMPTION not yet confirmed live:** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId — endpoint/field unverified (pinned in a unit test + a comment in `src/resolve/resolver.ts`) | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verify live) | Works today by reference for managed, already-created groups. If a live check shows the pairing id is elsewhere, flip the two constants at the top of `resolver.ts`. Numeric `id:` remains the guaranteed escape hatch ([`docs/permissions.md`](permissions.md) "domainId semantics") |
| ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ — **`ct adopt grants <domainType> <domainId>` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/permissions.md`](permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run `ct adopt grants group_role <id>` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config |
| Permission catalog lifecycle | `catalog.json` is a one-off HAR-trace snapshot of a single CT version, with no staleness detection | [#25](https://github.com/eqrm/ct-cli/issues/25) | Manual regeneration procedure below (**Permission catalog lifecycle**) |
| ~~Permission catalog lifecycle~~ **(shipped)** | ~~`catalog.json` is a one-off HAR-trace snapshot with no staleness detection~~ — **shipped (#25):** `npm run regenerate:permission-catalog` rewrites it from a live instance (records the CT version in `$meta`), and `ct plan` now warns on a version mismatch or an unknown-authId live grant (which it leaves untouched, never revoking a right it cannot name). See [`docs/permissions.md`](permissions.md) "Catalog lifecycle & staleness" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed — run the command; heed the `ct plan` warnings |
| API re-audit for new CT releases | CT's OpenAPI spec is self-trimming (only shows endpoints your version has), so a new write endpoint (e.g. a group-status write) appears silently between CT upgrades | tracked by this issue ([#26](https://github.com/eqrm/ct-cli/issues/26)) | Procedure below (**Re-audit procedure for new CT releases**) |

## Out of tool scope — deliberate, not a gap
Expand All @@ -51,21 +51,36 @@ this doc's structure.

## Permission catalog lifecycle (regeneration procedure)

Until #25's scripted lifecycle lands, regenerate `src/permissions/catalog.json`
by hand when the instance's CT version changes materially:
**Scripted (#25) — the normal path.** Regenerate `src/permissions/catalog.json`
from a live instance with one command:

```bash
CT_HOST=https://your.church.tools CT_LOGINTOKEN=<token> npm run regenerate:permission-catalog
```

It reads `POST /index.php?q=churchauth/ajax` `func=getMasterData`, flattens
`data.auth_table[module][right]` to
`"module:right" → { authId: id, scopeField: datenfeld, revocable: !!isRevocable, desc: bezeichnung }`,
stamps the instance's CT version into `$meta`, and rewrites the file (read-only
against the instance). Review the `git diff` and commit.

**Staleness signals.** `ct plan`/`ct apply` throw a clear "did you mean" error
for an unknown right *name* in a config; they now also **warn** (not fail) when
the live instance's CT version differs from `$meta.ctVersion`, and when a live
grant carries an `authId` the catalog cannot name (left untouched, never
revoked). Both are fixed by regenerating.

**Manual fallback (HAR).** If you cannot run the script (no login token to
hand), capture it by hand:

1. Open the ChurchTools permission editor in a browser with devtools
recording (Network tab).
2. Trigger the request: `POST /index.php?q=churchauth/ajax` with body
`func=getMasterData`.
3. Export the HAR and extract `log.entries[].response` for that request.
4. Flatten `data.auth_table[module][right]` to
`"module:right" → { authId: id, scopeField: datenfeld, revocable: !!isRevocable, desc: bezeichnung }`.
5. Overwrite `src/permissions/catalog.json` and update the "Captured
<date> from ... (CT <version>)" note in `src/permissions/README.md`.

`ct plan`/`ct apply` throw a clear "did you mean" error for an unknown right
name today; there is no version-mismatch warning yet (tracked under #25).
4. Flatten `data.auth_table[module][right]` as above.
5. Overwrite `src/permissions/catalog.json` (keep the `$meta` block, updating
its `ctVersion`/`capturedAt`).

## Re-audit procedure for new CT releases

Expand Down
18 changes: 16 additions & 2 deletions examples/permissions.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* Runnable example: a group-type-role declaration with one unscoped (global)
* grant and one scoped grant. See docs/permissions.md for the full feature
* guide, and `ct get permissions-catalog` to discover right names.
* grant and one scoped grant, plus a group-role declared by (group, role)
* reference (#25). See docs/permissions.md for the full feature guide, and
* `ct get permissions-catalog` to discover right names.
*
* Portable references (#20): the permission domain is declared by name
* (`groupType: "kids"`) instead of a hardcoded numeric domainId — the per-host
Expand Down Expand Up @@ -33,4 +34,17 @@ export default (ct: ConfigContext): void => {
{ right: "churchgroup:view group", scope: ["kids_area"] },
],
});

// group_role by reference (#25): the domain is declared by the (group, role)
// pair instead of a numeric domainId. The group must be managed (declared
// above / adopted) and already created; the resolver maps the pair to the
// pairing domainId per host. Numeric escape hatch: `id: <domainId>` instead
// of `group`/`role`. (See docs/permissions.md "domainId semantics" for the
// resolution assumption still to be confirmed live.)
ct.groupRole({
key: "kids_leiter_grant",
group: "kids_area",
role: "Leiter",
grants: [{ right: "churchgroup:edit group memberships of group", scope: ["kids_area"] }],
});
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"generate:client": "openapi-typescript \"$CT_HOST/system/runtime/swagger/openapi.json\" -o src/api/schema.d.ts"
"generate:client": "openapi-typescript \"$CT_HOST/system/runtime/swagger/openapi.json\" -o src/api/schema.d.ts",
"regenerate:permission-catalog": "tsx scripts/regenerate-permission-catalog.ts"
},
"dependencies": {
"commander": "^12.1.0",
Expand Down
Loading
Loading