feat: host-portable planning, a credential helper, and tofu-aware id resolution - #183
Merged
Merged
Conversation
…resolution Four changes the tier-0 OpenTofu cutover needs, and one that makes a single config usable across an estate whose instances differ. **#178 — a right this host does not have warns instead of aborting.** A declared right absent from the active permission catalog took down planning for everything else, so one config could not serve two instances with different modules installed (13 of 113 rights do not exist on eqrm-dev). It is now skipped with a warning naming the right, the declaration and both catalog versions — never granted, never revoked — while everything else still plans. A name no catalog ct knows defines stays a hard error, which is the case that guard was written for; ct's bundled catalog is the oracle that tells the two apart, so the verdict needs a per-instance capture to be active. `--strict-catalog` on plan and apply restores the old behaviour. Same split for a `preserveUnknown` dimension. **#179 — `ct auth token`, a credential helper.** Emits the ChurchTools SESSION rather than the personal login token: it expires, `ct auth logout` kills it, and a leaked copy dies in hours instead of being a permanent admin credential. The credential goes to stdout and nothing else does, a failure writes nothing there, and a terminal is refused without `--allow-tty`. A cross-process brake bounds login handshakes per host (3s spacing, 20/hour) so calling this on every tofu run cannot burst into ChurchTools' login rate limit. **#180 — a reference is not a declaration.** `ct state rm` refused a key the config only referenced, which made `--force` mandatory for 49 of 50 tier-0 entries and suppressed the check for the case it is meant to catch. Declaredness is now decided from the resources the config actually declares, matched on type and key. References are reported, not refused — except a group, which is managed-only with no live catalog to fall back to, and is refused in its own words. **#181 — resolving what OpenTofu now owns.** Once tier-0 leaves ct's state, the references left behind fall back to matching keys against live names, which cannot work for keys that were never name-derived (`status_unbekannt` vs `Unbekannt`), and numeric ids are not portable (39 of 43 differ between hosts). ct now resolves through a committed `.ct/ids.<host>.json` — after its own state, before the live catalog — written by `ct export tf` and refreshed by `ct ids sync --tofu-state -` from a `tofu state pull`, so no S3 client enters ct. The map is host-checked on load, and a sync never replaces a populated map with an empty one. Closes #178, #179, #180, #181
Review findings on #183, each reproduced against the built binary before being fixed and pinned by a test that fails without it. The id map is the only thing resolving a leftover `campus:` or `personStatus:` reference once tier-0 leaves ct's state, and its keys are not name-derived, so a dropped entry is a hard plan error rather than a degraded guess. Three ways it was dropped: - a partial `ct export tf --only <type>` rewrote the whole file from that run's entries, so exporting one type at a time discarded every type it had not reached yet. Types the run covered are now rewritten wholesale, types it never selected are carried over. - a full export against the POST-CUTOVER state — tier-0 gone from the config and the state file, which is the end state the map exists for — found nothing and wrote `entries: 0` over a good map. An export that maps nothing now keeps what is there and says so, matching the guard `ct ids sync` already had. - `Number(attributes.id)` accepted null, "", [] and false as 0, and 0 is a real ChurchTools id, so a mid-create or tainted tofu resource became a reference silently resolving to the wrong live object. A `for_each` block likewise mapped its block label to one arbitrary instance id and reported every real key as removed; both are now refused and reported. Also, from the same review: - the login throttle's hourly cap gates every ct command, not just `ct auth token`, and there is no session cache off macOS — so 21 ct invocations in an hour on Linux CI hard-failed where they had always worked. Cap raised to 120: the 3s spacing is what protects the instance, this is the backstop for a loop that keeps going. The unlocked read-modify-write, and the fact that CI is *not* exempt, are now stated rather than implied away. - `ct ids sync` refused to run while the map it exists to replace was malformed; it now regenerates and warns. - `--dry-run` exited 0 on the empty-state condition the real run exits 1 on, so a CI gate built on it passed exactly when it should fail. - `--tofu-state` resolved against process.cwd() rather than the project cwd, unlike every other path in that operation. - `--strict-catalog` set a process global nothing reset, which in the HTTP adapter contracts.ts anticipates would leave every later plan strict; now restored in a finally. - `ct state rm` checked the key-only state-only-reference refusal before the type-and-key declaredness one, so a key that was both got the vaguer message.
This was referenced Sep 21, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #178, #179, #180, #181 — four changes the tier-0 OpenTofu cutover needs, kept in one PR because they land in the same files (the resolver, the plan operation, the auth stack) and are verified together.
#178 — a right this host does not have warns instead of aborting
A declared right absent from the active permission catalog aborted the whole plan, so one config could not serve an estate whose instances have different modules installed (13 of 113 rights do not exist on eqrm-dev). Now:
churchreport:edit masterdata), which is what the error was written for.The bundled catalog is the oracle that tells the two apart, so the lenient verdict requires a per-instance capture to be active — a repo that has not run
ct permissions catalog --refreshsees exactly today's behaviour.--strict-catalogonplan/applyrestores the old behaviour everywhere. Exit codes unchanged: a skip is not a pending change.Same split for a
preserveUnknowndimension — one no right on this host scopes by is reported and ignored, one no catalog knows is still rejected at config-eval time.Worth stating explicitly:
ct coveragenever loads the config, so nothing there needed changing (contrary to my first read of the issue). Whatever a repo'scoverage:checkscript wraps isct plan, which is what this fixes.#179 —
ct auth tokenEmits the ChurchTools session, not the personal login token. The token is permanent, unscopable and an admin credential on prod; the session it buys expires, is dropped by
ct auth logout, and a copy that leaks into atofudebug log dies within hours. The token never leaves the Keychain.$(ct auth token --raw)is safe;--allow-tty;expiresAtis ct's 12h reuse ceiling, not a promise — treat a 401 as "ask again".Because this is meant to be called on every
tofurun, it comes with a cross-process brake on login handshakes per host (3s spacing, waited out; 20/rolling hour, refused with the time the window frees up;CT_NO_LOGIN_THROTTLE=1to disable). The counter is a cache file of timestamps — no credential in it.Dependency: the provider's
tokenattribute cannot consume a cookie. This half is inert untilterraform-provider-churchtoolslearns a cookie+CSRF auth mode; the output shape is now concrete enough to file that against. CI is unaffected — it keeps passing the token from a GitHub secret.#180 — a reference is not a declaration
Declaredness is now decided from the resources the config actually declares, matched on type and key, not from the key appearing somewhere. So the tier-0 cutover no longer needs
--forcefor 49 of 50 entries, and the check survives for the case it is meant to catch.A referenced-but-undeclared key is removed with a warning saying how many references stay behind and that they now resolve live by name. One class of reference still blocks removal, for the guard's original reason rather than a spelling one: a group is managed-only with no live catalog to fall back to, so dropping a group that a
ct.groupRoledomain or a group scope names makes the next plan fail to resolve it. That refusal now says so instead of claiming the key is declared — two existing tests changed expectations accordingly (behaviour unchanged, wording accurate).#181 — resolving what OpenTofu now owns
Once tier-0 leaves ct's state, leftover references fall back to matching the key against the live name, which cannot work for keys that were never name-derived (
status_unbekanntvsUnbekannt), and numeric ids are not portable (39 of 43 tier-0 ids differ between the two hosts).ct now resolves through a committed
.ct/ids.<host>.json, slotted after its own managed state (ct never stops trusting what it owns) and before the live catalog (an exact table beats a name guess).ct plannames the map in its header next to the permission catalog.ct export tfwrites it from the state it exports (--no-idsto skip);ct ids sync --tofu-state <file|->refreshes it from tofu's own state, sotofu state pull | ct ids sync -e prod --tofu-state -works on any backend and no S3 client enters ct-cli;ct ids listshows what the resolver would use;id: 0round-trips, and a sync never replaces a populated map with an empty one.I went with
.ct/ids.<hostslug>.jsonrather than thect-ids.<env>.jsonthe issue names: it works without--env, sits besidepermission-catalog.<host>.json, and cannot be mismatched to a host resolved some other way.Verification
npm test— 1184 passed, 5 skipped (108 files); 43 new tests acrosspermission-catalog-host-difference,auth-token-command,login-throttle,tofu-id-map,ids-sync, plus thestate rmand export additionsnpm run typecheck,npx eslint src tests,npm run format:check,npm run build— all cleannode .github/scripts/docs-staleness.mjs— all 6 pages current (4 re-read and re-signed)node dist/index.js ids listagainst a real host — reports "no id map" cleanly, no networkDocs: new
docs/opentofu-migration.md(the id map andct auth token), a "A right this host does not have" section indocs/handbuch/permissions.md, and thestate rmparagraph in the README rewritten.