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
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ ct plan # diff the config against ChurchTools (read-only)
ct plan --format markdown # plain-language review report (German by default)
ct apply # create + update in dependency order (confirm + backup first)
ct refresh --group <key> # make ChurchTools re-evaluate one auto-group now

ct export tf # render managed state as OpenTofu HCL + import blocks
ct ids sync --tofu-state - # refresh the key->id map ct resolves tofu-owned resources through
ct auth token --env dev # hand a short-lived session to another tool (credential helper)
```

Run without `--token`, `ct auth login` asks how you want to authenticate: **username and
Expand All @@ -250,12 +254,19 @@ export `CT_HOST` and `CT_LOGINTOKEN` there.

`state rm` is the inverse of `adopt`, and only of `adopt`: it removes the entry
from the state file, makes no HTTP call, and leaves the resource in place in
ChurchTools, now unmanaged. It refuses a key the config still declares — that
ChurchTools, now unmanaged. It refuses a key the config still **declares** — that
would make the next plan propose creating a resource that already exists — so
delete the declaration first, or pass `--force` to do both in one change.
"Declares" covers permission declarations too, not only resources: a key named
by a `ct.groupRole` domain or a group scope is just as broken to remove, and the
refusal is what keeps that from surfacing one command later as a plan error.

A key the config only **references** is a different matter and is removed: a
logical reference asks the host for an id, so it never proposes creating the
resource it points at. `state rm` says how many references stay behind, because
they now resolve against the live instance by name (or through the
[id map](docs/opentofu-migration.md)) rather than from state. The one reference
that still blocks removal is a **group**: groups are managed-only, with no live
catalog to fall back to, so dropping a group a `ct.groupRole` domain or a group
scope names would make the next plan fail to resolve it — the refusal is what
keeps that from surfacing one command later.

`apply` reconciles **creates and updates** only, saving state after each action
(crash-safe / resumable). It **never deletes**: a resource dropped from the
Expand Down Expand Up @@ -336,6 +347,7 @@ only; it carries no instance data.

- [`docs/configuration.md`](docs/configuration.md) — writing the config: keys, portable references, escape hatches
- [`docs/environments.md`](docs/environments.md) · [`docs/ci.md`](docs/ci.md) — multi-instance and automation
- [`docs/opentofu-migration.md`](docs/opentofu-migration.md) — living alongside terraform-provider-churchtools: the committed id map, and `ct auth token` as a credential helper
- [`docs/handbuch/`](docs/handbuch/) — the **generic ChurchTools reference** (permissions, dynamic groups, field definitions, blueprints), published into the Handbuch
- [`docs/api-coverage.md`](docs/api-coverage.md) · [`docs/runbook-manual-surface.md`](docs/runbook-manual-surface.md) — what the API supports, and what still has to be done by hand
- [`docs/README.md`](docs/README.md) — how the docs are organised and how pages stay in sync with the code
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ A page with no code behaviour to track declares `sources: []` plus a
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| [`adoption-contract.md`](adoption-contract.md) | What else comes along when `ct adopt` adopts one resource — the five categories and their defaults |
| [`api-coverage.md`](api-coverage.md) | Which ChurchTools endpoints support which CRUD verbs |
| [`opentofu-migration.md`](opentofu-migration.md) | Living alongside terraform-provider-churchtools: the committed id map, and `ct auth token` |
| [`group-field-decisions.md`](group-field-decisions.md) | Which group fields are managed vs. left to the CT UI, and why |
| [`runbook-manual-surface.md`](runbook-manual-surface.md) | What `ct` cannot automate today — where the write path is missing, and the manual steps around it |
| `superpowers/` | Historical implementation plans; kept as a record, never published |
2 changes: 1 addition & 1 deletion docs/handbuch/blueprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sources:
- src/config/context.ts
- src/engine/graph.ts
- src/engine/hierarchy.ts
sources_hash: 6f4be8d3a93113ce
sources_hash: b183fe0075bd7ad3
reviewed: 2026-08-28
---

Expand Down
2 changes: 1 addition & 1 deletion docs/handbuch/field-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Field definitions & security levels
sources:
- src/commands/get.ts
- src/api/ctClient.ts
sources_hash: 659b65504025ebb8
sources_hash: 5c991e89fd5b6b9b
reviewed: 2026-08-13
---

Expand Down
2 changes: 1 addition & 1 deletion docs/handbuch/group-member-fields.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sources_hash: c18b710bff24503d
sources_hash: 83e743cd6cf4caf3
title: Group member fields
sources:
- src/engine/member-fields.ts
Expand Down
59 changes: 57 additions & 2 deletions docs/handbuch/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sources:
- src/resolve/resolver.ts
- src/resolve/refs.ts
- src/config/context.ts
sources_hash: c3a6740c4d4bc134
sources_hash: 1ccb85af4d54a491
reviewed: 2026-08-28
---

Expand Down Expand Up @@ -152,7 +152,10 @@ Each line shows the name, its numeric `authId`, and whether it's `scoped`
when grants are resolved to tuples against the catalog, after the authed
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.)
`{ right, scope }`; it does not resolve the name against the catalog.) A name the
**active** catalog does not define but ct's bundled catalog does is a different
case — a host difference, not a typo — and is skipped rather than fatal; see
[A right this host does not have](#a-right-this-host-does-not-have-178).

## Catalog lifecycle & staleness (#25)

Expand Down Expand Up @@ -212,6 +215,50 @@ records the version the catalog was captured from. On every `plan`/`apply`:
Both are warnings, not errors: the plan still runs and the exit code stays
success. `ct permissions catalog --refresh` is the fix for both.

### A right this host does not have (#178)

An estate's instances do not all have the same modules installed, and one
declarative config is supposed to serve all of them. So a **declared** right
that the active catalog does not define is not automatically an error:

- the name is in **ct's bundled catalog** but not in this host's capture → a real
right this instance does not have (a module that is not installed). `ct` prints
a warning naming the right, the declaration and both catalog versions, and
**skips that one grant on this host** — it is never granted and never revoked.
Everything else in the same declaration still plans.
- the name is in **no** catalog `ct` has ever seen → still a hard error with the
"did you mean" hint. That is a typo, or a right ChurchTools deleted, and it is
the case the error was written for.

The same split applies to a [`preserveUnknown`](#partial-ownership-preserveunknown-opt-in-102)
dimension: one no right on this host scopes by is reported and ignored, one no
catalog knows is still rejected at config-evaluation time.

This mirrors what `ct` has always done in the other direction — a _live_ grant
whose `authId` the catalog cannot name is reported and left alone. Say what you
cannot manage, manage the rest.

Note the precondition: the "it exists elsewhere" verdict needs a **per-instance
capture** to be active. Without one, the active catalog _is_ the bundled snapshot,
so "missing here" and "missing everywhere" are the same statement and every
absence stays fatal. This is one more reason to commit
`.ct/permission-catalog.<host>.json` per host.

```bash
ct plan --env dev
# ! group_role "implementierung_churchtools_mitglied": right "jpmFlowManager:view" is absent
# from this host's permission catalog (this host's catalog: ChurchTools 3.137.0-RC13, 221 rights)
# — skipped for this host — never granted, never revoked. ct's bundled catalog
# (ChurchTools 3.134.0) defines it, so this reads as a module this instance does not have.
# Pass --strict-catalog to fail on it instead.

ct plan --env dev --strict-catalog # every unresolvable declaration is an error again
```

`--strict-catalog` is available on `ct plan` and `ct apply`. Exit codes are
unchanged either way: a skip is not a pending change, so `--detailed-exitcode`
still reports 0 for a clean plan.

## `domainId` semantics

The two DSL functions manage two different ChurchTools "domain types," and
Expand Down Expand Up @@ -489,6 +536,14 @@ 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.

Between those two sits one more source, for a repo mid-migration: the committed
**OpenTofu id map** (`.ct/ids.<host>.json`, #181). It is consulted after managed
state and before the live catalog, and it exists because a resource that moved to
`terraform-provider-churchtools` leaves ct's state entirely — at which point a
reference like `personStatus: "status_unbekannt"` has nothing left to resolve
against, since ct's keys are not derived from the live names the catalog matches
on. See [Living alongside terraform-provider-churchtools](https://github.com/eqrm/ct-cli/blob/main/docs/opentofu-migration.md).

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
Expand Down
194 changes: 194 additions & 0 deletions docs/opentofu-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# Living alongside terraform-provider-churchtools

The TypeScript DSL is frozen and the tier-0 resources — campuses, group types,
Bereiche, person statuses, comment viewers — are moving to
[`terraform-provider-churchtools`](https://github.com/eqrm/terraform-provider-churchtools).
`ct export tf` produces the HCL and the import blocks for that move. This page
covers the two things needed to live in the in-between state, where OpenTofu owns
tier-0 and `ct` still owns everything above it.

## The id map: references that outlive their declarations (#181)

When tier-0 leaves `ct.config.ts`, its **references** stay: several hundred
`campus: "mainz"`, `personStatus: "status_unbekannt"`, `{ groupType: "struktur" }`
on groups, grants and rulesets. Those are logical references — they ask the host
for an id rather than carrying one — so they look like they should survive.

They do not, on their own. `ct` resolves a logical reference from its own state
first and falls back to matching the key against the live object's **name**. While
the resource was in ct's state the key resolved exactly; with the state entry gone,
only the name fallback is left, and ct's tier-0 keys were never name-derived:

| key | live name |
| ------------------ | --------------------------- |
| `status_unbekannt` | `Unbekannt` |
| `status_5_core` | `5 - Core` |
| `egc` | `Equippers Germany Central` |

No slug of those names produces those keys, so removing the state entries makes
`ct plan` fail to resolve them. Pinning numeric ids is not an escape either: one
config serves two hosts, and on the eqrm estate 39 of 43 tier-0 ids differ between
them.

So `ct` reads a committed **id map** — the exact `key → id` table for this host:

```
.ct/ids.<host>.json
```

```json
{
"$meta": {
"host": "https://eqrm.church.tools",
"source": "ct export tf",
"generatedAt": "2026-09-21",
"entries": 50
},
"campus": { "mainz": { "id": 0 } },
"person-status": { "status_unbekannt": { "id": 0 }, "status_5_core": { "id": 6 } }
}
```

It sits **between** the two existing sources: after ct's own managed state (ct
never stops trusting what it owns) and before the live catalog (an exact table
beats a name guess). A repo without a map behaves exactly as before.

`ct plan` names the map it loaded in its header, next to the permission catalog:

```
permission catalog: .ct/permission-catalog.eqrm.church.tools.json
tofu id map: .ct/ids.eqrm.church.tools.json
```

**Commit the map, one per host.** It is host-checked on load — a map whose `$meta.host`
does not match the resolved host is rejected rather than applied, because 39 of 43
ids differing means a foreign map would resolve every reference to a real,
_wrong_ resource, which nothing downstream could detect.

### Writing it

`ct export tf` writes it from the state it is exporting, so the cutover itself
needs no extra step:

```bash
ct export tf --env prod # tofu/*.tf, tofu/imports.tf, and .ct/ids.<host>.json
ct export tf --env prod --no-ids # …without the map, if your repo generates it another way
```

The map is written under `.ct/`, not into the tofu output directory: it is `ct`'s
input, not part of the root module tofu reads.

A partial export (`--only campus`) rewrites only the types it was asked for and
carries the rest of the map over untouched — otherwise a type-at-a-time cutover
would drop the ids of every type it had not reached yet. Within a type the
rewrite is wholesale: a resource that has left `ct`'s state has left `ct`'s
ownership, and its id goes with it.

An export that maps **nothing** leaves an existing map alone and says so, rather
than emptying it. That is not an edge case but the end state: once tier-0 is gone
from `ct.config.ts` and `ct-state.<env>.json`, every `ct export tf` exports zero
resources, and the map it would overwrite is the only thing still resolving the
references that stayed behind. From that point on the map is refreshed from tofu,
with `ct ids sync` — not from `ct`.

### Keeping it current

An export can only describe what `ct` still holds. Once tier-0 belongs to tofu,
tofu is the only place new ids appear — a campus created by `tofu apply` exists in
no ct state file. `ct ids sync` reads tofu's own state instead:

```bash
tofu state pull | ct ids sync --env prod --tofu-state - # any backend, no S3 client in ct
ct ids sync --env prod --tofu-state terraform.tfstate # or a local file
ct ids sync --env prod --tofu-state terraform.tfstate --dry-run
ct ids list --env prod # what ct would resolve through
```

`sync` reports every id it adds, changes or drops, and **refuses to replace a
populated map with an empty one** — the likeliest cause of an empty read is the
wrong workspace, and overwriting 50 working ids with nothing would break every
reference at once. Provider resource types `ct` has no mapping for (a
`churchtools_group`, another provider's resources in a shared state) are reported
and skipped.

One subtlety: `ct export tf` relabels keys that are not valid HCL identifiers
(`3_groupactive` → `g_3_groupactive`), and that mapping is many-to-one, so it
cannot be inverted by rule. The map records the label alongside the key, and
`ct ids sync` uses the existing map to translate a tofu address back to the ct
key. Without a previous map, the label is taken as the key — correct for every
key that needed no relabelling.

Reading tofu's **remote** state directly was considered and rejected: it would put
an S3 backend, its credentials and an AWS SDK inside a CLI whose every other read
is ChurchTools. Piping `tofu state pull` keeps the backend tofu's problem.

## `ct auth token`: a credential helper (#179)

The provider needs credentials, and its `token` attribute wants a ChurchTools
personal login token — which is permanent, cannot be scoped or rotated by
ChurchTools, and is an admin credential on prod. Writing one to a `.env` file for
the provider to read is the thing worth avoiding.

`ct auth token` hands over the **session** that token buys instead:

```bash
$ ct auth token --env dev | jq
{
"operation": "auth",
"action": "token",
"environment": "dev",
"host": "https://eqrm-dev.church.tools",
"cookie": "ChurchTools_eqrm-dev=…",
"csrfToken": "…",
"expiresAt": "2026-09-22T06:12:00.000Z",
"source": "cache"
}

$ ct auth token --env dev --raw # the bare cookie, for command substitution
```

Why the session rather than the token: it expires, `ct auth logout --env dev`
kills it, and a copy that leaks into a `tofu` debug log or a CI artifact is dead
within hours instead of being the permanent admin credential forever. The login
token itself never leaves the Keychain.

The contract:

- the credential goes to **stdout and nothing else does** — every message,
warning and Keychain prompt is on stderr, so `$(ct auth token --raw)` is safe;
- a failure writes **nothing** to stdout and exits non-zero, with the remedy
named (`ct auth login --env <name>`);
- printing to a **terminal is refused** unless `--allow-tty` — a credential in
your scrollback defeats the point of it being short-lived;
- `expiresAt` is `ct`'s reuse ceiling (12h), not a promise from ChurchTools:
treat a 401 as "ask again", which is cheap because this command answers from
the Keychain-cached session.

Calling it on every `tofu` run is the intended usage. Sessions are cached per host
in the Keychain (macOS), so that is normally zero network calls; when a handshake
_is_ needed, a cross-process brake keeps it from becoming a burst:

- handshakes against one host are spaced at least 3s apart (waited out, not an
error);
- more than 120 in a rolling hour is refused, naming when the window frees up —
that is a runaway loop, and hammering a throttled instance only lengthens the
outage for everyone on it;
- `CT_NO_LOGIN_THROTTLE=1` disables it, for a CI job that knows it runs alone.

The counter lives in `$XDG_CACHE_HOME/ct-cli/login-throttle.<host>.json` and holds
nothing but timestamps. Deleting it, or being unable to write it, simply means no
throttle. It is read-modify-written without a lock, so it bounds a _sequence_ of
invocations rather than a simultaneous burst: two `ct` processes starting at the
same instant are spaced no better than not at all.

**The brake sits in the login handshake, so it covers every `ct` command**, not
just `ct auth token` — and on Linux and Windows there is no session cache, so
there each invocation is one handshake. That is why the hourly cap is 120 rather
than a number sized for a credential helper alone: a pipeline should never reach
it, while a runaway loop passes it in about six minutes. A CI job that runs more
`ct` invocations than that against one host in an hour should set
`CT_NO_LOGIN_THROTTLE=1`.

A CI job otherwise needs none of this: it passes the token explicitly from a
GitHub secret, which is already storage-free. This path exists for local
development, where the alternative was a token on disk.
Loading
Loading