diff --git a/README.md b/README.md index c35f662..c429052 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ Early scaffold. See the [epic (#1)](https://github.com/eqrm/ct-cli/issues/1) and [`docs/runbook-manual-surface.md`](docs/runbook-manual-surface.md), so instance bootstrap ([#23](https://github.com/eqrm/ct-cli/issues/23)) can scope "selective adoption" deliberately. +- πŸ“– **Field definitions & security levels** ([#47](https://github.com/eqrm/ct-cli/issues/47), + [#48](https://github.com/eqrm/ct-cli/issues/48)): the person master-data model, + security levels, and person/group custom-field DEFINITIONS ("Datenfelder") are + readable (`ct get person-masterdata`, `ct get data-fields`) β€” schema in scope, + per-record field **values** never. Definitions are read-only (no REST write + endpoint); the boundary + writability evidence is in + [`docs/field-definitions.md`](docs/field-definitions.md). ## Requirements @@ -102,6 +109,8 @@ ct auth status # who am I? ct get campuses # JSON to stdout β€” pipe into jq ct get groups ct get group-types +ct get person-masterdata # person master-data model incl. security levels (schema, read-only) +ct get data-fields # field DEFINITIONS: person + group custom fields (schema, read-only) ct get raw /groups/42 # arbitrary GET ct adopt campus 0 # bring an existing resource under management (β†’ state file) diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 784824e..5928d66 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -63,3 +63,21 @@ No collection POST; state is set/removed through PUT/DELETE on the item path. Mo ### Version gate The installation reports **CT `3.123.0`** via `/info`, so every write endpoint above is available and the v3.96+ hierarchy/metadata requirement is satisfied. Recommend the CLI call `GET /info` on startup and hard-fail below `3.96.0`. + +## Addendum β€” field definitions & security levels (#47, #48) + +Not part of the Phase 0 structural matrix above; audited separately for the +field-definition schema surface. **Caveat:** unlike the Phase 0 matrix (audited +against a live `openapi.json`), these were verified against ChurchTools' public +API-client libraries (5pm-HDH `churchtools-api` @ CT 3.104, bensteUEM +`ChurchToolsAPI` @ CT 3.101) and CT Academy docs, because this repo's generated +`src/api/schema.d.ts` is git-ignored and was not available offline. Re-verify per +the runbook's re-audit procedure once the schema is regenerated. + +| Resource | Matched path(s) | GET | POST/PUT/PATCH/DELETE | Verdict | +| --------------------------------- | ------------------------------ | ----------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Person master-data model | `/person/masterdata` | βœ… (single object) | ❌ | **Read-only.** Versionable master-data model incl. the `securityLevels` enumeration. No write endpoint (edited in the CT master-data admin UI). | +| Data-field definitions (Datenfelder) | `/dbfields`, `/dbfields/{id}` | list βœ… / by-id βœ… | ❌ | **Read-only.** Unified person + group field definitions, discriminated by `fieldCategory`. Mutation only via legacy churchdb AJAX (`db_insert/update/deletefields`), not REST. | + +See [`docs/field-definitions.md`](field-definitions.md) for the full writability +decision, evidence, and the schema/values boundary. diff --git a/docs/field-definitions.md b/docs/field-definitions.md new file mode 100644 index 0000000..f84d682 --- /dev/null +++ b/docs/field-definitions.md @@ -0,0 +1,92 @@ +# Field definitions & security levels (#47, #48) + +`ct` can **read** the structural *schema* that defines what data a person or a +group carries: the person master-data model, the security-level enumeration, and +the data-field definitions ("Datenfelder") for both persons and groups. + +## Hard boundary β€” schema in scope, people never + +| In scope (schema / DEFINITIONS) | **Never** (people / records) | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| What fields a person/group *has* (field definitions, types, field groups) | The **value** of any field on an actual person or group record | +| 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. | + +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. + +## Read commands + +```bash +ct get person-masterdata # the person master-data model incl. the security-level enumeration +ct get data-fields # all data-field definitions (person + group), one row per field +``` + +- `ct get person-masterdata` β†’ `GET /person/masterdata`. A single object (not a + paged list) β€” the versionable person master-data model. It carries the + security-level enumeration (`securityLevels`), which is the model the churchdb + permission scopes reference (`cc_securitylevel`; see `docs/permissions.md`). + This is where **security levels** are surfaced β€” ChurchTools has no dedicated + standalone security-levels REST resource; the Persons/Groups master-data module + is where they are defined, and `/person/masterdata` is its download. +- `ct get data-fields` β†’ `GET /dbfields` (auto-paginated). The **unified** + data-field definition catalog. Person master-data fields **and** group custom + fields live in the same list, discriminated per-row by `fieldCategory` + (`internCode` / `table`, e.g. table `cdb_gruppe` = a group field). Each row + carries its `securityLevel`, type, sort key, options, and category. + + ```bash + # person master-data field definitions (#47) + ct get data-fields | jq '[.[] | select(.fieldCategory.table == "cdb_person")]' + # group custom field definitions (#48) + ct get data-fields | jq '[.[] | select(.fieldCategory.table == "cdb_gruppe")]' + ``` + +## Writability decision β€” READ-ONLY (both #47 and #48) + +**Field definitions are read-only in `ct`. No registry resource or DSL is added, +and no writability is faked.** + +Evidence: + +1. **No committed OpenAPI schema to consult.** The brief assumed a committed + `src/api/schema.d.ts` generated from a live instance, but that file is + **git-ignored** (`.gitignore`) and generated on demand by + `npm run generate:client`. It is not in the repo, so the endpoint methods + below could not be statically verified against this repo's schema. They were + verified against ChurchTools' own public API-client libraries (5pm-HDH + `churchtools-api` tested @ CT 3.104, bensteUEM `ChurchToolsAPI` @ CT 3.101) + and the ChurchTools Academy docs. **Re-verify** against a freshly generated + schema per the re-audit procedure in `docs/runbook-manual-surface.md`. +2. **The REST field-definition resource is GET-only.** Every public client + exposes data fields solely as `GET /dbfields` (list) and `GET /dbfields/{id}` + (by id). No REST `POST`/`PUT`/`PATCH`/`DELETE` on a field-definition path + exists in any of them. +3. **Mutation is legacy-AJAX only.** Creating/updating/deleting a field + definition is done through the legacy churchdb admin module + (`CTChurchDBModule`: `db_insertfields` / `db_updatefields` / + `db_deletefields`) β€” the same non-REST legacy surface as the permission + catalog (`churchauth/ajax func=getMasterData`; see `src/permissions/README.md`). + The tool deliberately treats that surface as read-reference only and never + writes it. + +Because a declarative `ct plan`/`ct apply` reconciler must own a clean, +idempotent REST write path (`POST` create + item `PUT/PATCH/DELETE`), and none +exists for field definitions, promoting them to a managed registry resource +would mean faking writability through the legacy admin AJAX. That is explicitly +out of bounds. If a future CT release adds REST write endpoints on `/dbfields` +(the OpenAPI spec is self-trimming, so they would appear silently), promote the +resource then β€” add a `src/resources/registry.ts` entry + DSL + tests β€” and move +the row out of the read-only section here and in the runbook. + +## Endpoint reference + +| Purpose | Path | Methods (this CT) | `ct` surface | +| ---------------------------------------- | ----------------------- | ------------------------ | --------------------------- | +| Person master-data model + security levels | `/person/masterdata` | GET (read-only) | `ct get person-masterdata` | +| Data-field definitions (person + group) | `/dbfields`, `/dbfields/{id}` | GET (read-only) | `ct get data-fields` | +| Field-definition **mutation** | legacy `churchdb` AJAX (`db_insertfields`/`db_updatefields`/`db_deletefields`) | non-REST | **not managed β€” manual** | + +All paths verified against public CT client libraries + CT Academy docs, **not** +against this repo's (git-ignored, ungenerated) `src/api/schema.d.ts`. diff --git a/docs/runbook-manual-surface.md b/docs/runbook-manual-surface.md index f47c497..53efaf9 100644 --- a/docs/runbook-manual-surface.md +++ b/docs/runbook-manual-surface.md @@ -39,6 +39,7 @@ this doc's structure. | Permission `group_role` domain by reference **(shipped, assumption unverified)** | `ct.groupRole({ group, role })` now resolves the (group, role) pair to its pairing domainId at plan time (#25). **ASSUMPTION not yet confirmed live:** it reads the group's role list (`GET /groups/{groupId}/roles`) and takes the matched role row's `id` as the pairing domainId β€” endpoint/field unverified (pinned in a unit test + a comment in `src/resolve/resolver.ts`) | [#25](https://github.com/eqrm/ct-cli/issues/25) (done, verify live) | Works today by reference for managed, already-created groups. If a live check shows the pairing id is elsewhere, flip the two constants at the top of `resolver.ts`. Numeric `id:` remains the guaranteed escape hatch ([`docs/permissions.md`](permissions.md) "domainId semantics") | | ~~Grant adoption~~ **(shipped)** | ~~existing rights structures must be hand-transcribed~~ β€” **`ct adopt grants ` ships this** (#25): it reads the live rows, applies the planner's normalization, and prints a paste-ready `ct.groupRole` / `ct.groupTypeRole` block (baseline/inherited excluded, denies noted-and-preserved, scope dataIds mapped back to managed-group keys). See [`docs/permissions.md`](permissions.md) "Adopting existing grants" | [#25](https://github.com/eqrm/ct-cli/issues/25) (done) | No workaround needed β€” run `ct adopt grants group_role ` (or `group_type_role`), review the `WARNING`/`NOTE` comments, paste into config | | ~~Permission catalog lifecycle~~ **(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 | +| 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/field-definitions.md`](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 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 @@ -47,7 +48,8 @@ this doc's structure. | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | People, memberships, group member lists | Hard boundary enforced in code (`assertNotPeople`, `src/engine/guard.ts`) β€” the tool manages rights-bearing _structure_ only, never who's in it. This is permanent by design, not a roadmap item; see README's "People are never managed" | | Other CT modules β€” calendars, services (`churchservice`), resource booking (`churchresource`), forms, check-in, wiki, finance, sync | Never in the tool's stated mandate ("campuses, structural groups, hierarchies, group types/roles, permission & auto-groups" β€” README). Phase 0's coverage matrix (`docs/api-coverage.md`) only analyzed the 12 resource types relevant to that structural mandate; nothing else was assessed for CRUD support and nothing else is planned | -| Module-level settings, custom fields, i18n | Out of tool scope by design β€” global instance configuration, not per-resource declarative structure | +| Module-level settings, i18n | Out of tool scope by design β€” global instance configuration, not per-resource declarative structure | +| Custom field / master-data field **VALUES on individual person/group records** | People/record data is the permanent people boundary (`assertNotPeople`). Note: the field **DEFINITIONS** (schema) are **no longer out of scope** β€” they moved to read-only supported above (#47/#48, [`docs/field-definitions.md`](field-definitions.md)); only the per-record *values* stay out of scope. | ## Permission catalog lifecycle (regeneration procedure) @@ -134,12 +136,17 @@ in where they'd otherwise be silently skipped: declaration, adopt them now with `ct adopt grants ` (paste the emitted block into config) so they don't silently diverge from what `ct plan` believes is desired. -7. **Anything from the "out of tool scope" table** β€” persons, memberships, - calendars, services, resource booking, forms, check-in, wiki, finance, - sync, module-level settings, custom fields, i18n β€” configure per your - organization's own (non-`ct`) process; this tool will never surface or +7. **Field definitions & security levels** β€” read the current schema with + `ct get person-masterdata` and `ct get data-fields` to confirm the expected + fields, field groups, and security levels exist; create/edit any missing + *definitions* by hand in the master-data admin UI (no REST write endpoint β€” + see the field-definitions row above and [`docs/field-definitions.md`](field-definitions.md)). +8. **Anything from the "out of tool scope" table** β€” persons, memberships, + per-record field values, calendars, services, resource booking, forms, + check-in, wiki, finance, sync, module-level settings, i18n β€” configure per + your organization's own (non-`ct`) process; this tool will never surface or touch these. -8. Run `ct plan` once more: it should be a clean no-op. Anything it still +9. Run `ct plan` once more: it should be a clean no-op. Anything it still proposes is a real drift, not a manual-surface item. ## Open uncertainty diff --git a/src/commands/get.ts b/src/commands/get.ts index 75f93a3..25e768b 100644 --- a/src/commands/get.ts +++ b/src/commands/get.ts @@ -35,6 +35,14 @@ const RESOURCE_PATHS: Record = { "target-groups": { path: "/group/targetgroups" }, "dynamic-groups": { path: "/dynamicgroups" }, "relationship-types": { path: "/person/relationshiptypes" }, + // Schema/DEFINITIONS only β€” never person records or field VALUES (#47/#48; see docs/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. + "person-masterdata": { path: "/person/masterdata", paginated: false }, + // Unified data-field DEFINITION catalog (Datenfelder): person master-data fields AND group custom + // fields in one list, discriminated per-row by `fieldCategory` (e.g. table `cdb_gruppe` = group). + // Read-only: mutation is only via the legacy churchdb admin AJAX, not REST β€” see docs/field-definitions.md. + "data-fields": { path: "/dbfields" }, permissions: { path: "/permissions/global", paginated: false }, }; diff --git a/tests/get-command.test.ts b/tests/get-command.test.ts index b702750..d2f517b 100644 --- a/tests/get-command.test.ts +++ b/tests/get-command.test.ts @@ -61,6 +61,41 @@ describe("ct get (#50)", () => { writeSpy.mockRestore(); }); + it("reads person master-data (incl. the security-level model) as a single object via plain get (#47)", async () => { + // `/person/masterdata` is the versionable person master-data model (sexes, statuses, + // campuses, and the security-level enumeration the churchdb permission scopes reference). + // It is a single object, not a paged list β€” so it must use the unpaginated path. + getMock.mockResolvedValue({ securityLevels: [{ id: 1, name: "Γ–ffentlich" }], sexes: [] }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["person-masterdata"]); + + expect(getMock).toHaveBeenCalledWith("/person/masterdata"); + expect(getAllMock).not.toHaveBeenCalled(); + writeSpy.mockRestore(); + }); + + it("reads the unified data-field definitions (person + group Datenfelder) via getAll (#47/#48)", async () => { + // `/dbfields` is the unified field-DEFINITION catalog: person master-data fields AND group + // custom fields, discriminated by each field's `fieldCategory` (e.g. table `cdb_gruppe` for + // group fields). List-shaped, so it auto-paginates. Field VALUES on records are never read. + getAllMock.mockResolvedValue({ + data: [ + { id: 5, name: "first_contact", securityLevel: 1, fieldCategory: { internCode: "f_person" } }, + { id: 9, name: "bezeichnung", securityLevel: 2, fieldCategory: { internCode: "f_group", table: "cdb_gruppe" } }, + ], + meta: { pagination: { total: 2, current: 1, lastPage: 1, limit: 100 } }, + }); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await runGet(["data-fields"]); + + expect(getAllMock.mock.calls[0]?.[0]).toBe("/dbfields"); + const printed = JSON.parse(writeSpy.mock.calls[0]?.[0] as string) as unknown[]; + expect(printed).toHaveLength(2); + writeSpy.mockRestore(); + }); + it("propagates a raw call's CtApiError (status + body) instead of swallowing it", async () => { getMock.mockRejectedValue( new CtApiError("GET /groups?limit=500 failed", 400, { errors: ["limit exceeds max of 100"] }),