HOMELAB-2479: feat(zitadel): invite-list-reader machine user (IAM_OWNER_VIEWER) #912

Merged
pi-landing merged 3 commits from plane/HOMELAB-2479-invite-list-reader into live 2026-11-10 19:52:37 +00:00
Collaborator

Adds the process that builds the invite-email recipient list: a dedicated
Zitadel machine user, instance membership with IAM_OWNER_VIEWER, and a
machine key bounded to 90 days, exported as a sensitive output. Three
resources in the existing core/terraform/live/zitadel/ root, following the
#904 additions' style.

No apply, no landing. This PR is code only; it is not merged and nothing is
applied without a later go.

Fix round (overseer 2026-11-10T17:56:59Z) — three changes

  1. The key is bounded to 90 days. expiration_date was
    var.invite_list_reader_key_expiration_date with a null default — an
    unbounded credential that no tfvars ever set. It is now
    expiration_date = timeadd(timestamp(), "2160h"). timestamp() is the
    apply-time instant, so every apply sets expiry = now + 90 days (2160h =
    90 × 24h) and the key can never exist without an expiry. The variable was
    deleted rather than defaulted, so no tfvars can make the key immortal.
    The consequence is written in the code, not hidden. The bound is stamped
    once, at creation: the key is created with a 90-day expiry on the first
    apply. Provider v3.8.7 marks expiration_date ForceNew with no
    UpdateContext
    (read from the provider source, not schema -json —
    which reports force_new=false for every SDKv2 attribute), so a
    recomputed value would plan a replacement of the key on every apply:
    a non-convergent plan and a live credential rotation triggered by any
    unrelated apply. The resource therefore carries
    lifecycle { ignore_changes = [expiration_date] }. A replacement happens
    only if the resource is tainted, or deleted and recreated — and that
    event requires re-patching the OpenBao property in the same change.
  2. The read scope is stated honestly — read everything, write nothing.
    An earlier draft called this "the instance's user directory". It is not.
    IAM_OWNER_VIEWER reads the whole instance: events.read (the entire
    audit/event stream, which carries actor ids, targets and timestamps),
    user.read and org.read (every org's content, not just the default one),
    plus iam.idp.read, iam.action.read, iam.flow.read,
    iam.restrictions.read, iam.web_key.read, iam.debug.read,
    iam.feature.read and iam.member.read. The main.tf block now lists that
    permission set line by line so a reader of the code sees the true radius, and
    the machine user's description says the same so the console does too.
  3. Org placement wording corrected. The machine user is a member of the
    default organization: it is created with org_id = var.zitadel_org_id, and a
    Zitadel user always needs an owning org. What it does not have is an org
    role — no ORG_OWNER/ORG_OWNER_VIEWER grant — so org-admin power is not
    implied; the privileged grant is the instance membership. Hardening
    consideration recorded as non-blocking: an instance-wide-read principal is
    ideally placed in a dedicated admin org rather than the default one; current
    placement is accepted because the default org's admins are the operator's own
    accounts, so this principal does not widen anyone else's org-admin reach.

Why instance-wide, and why read-only

The invite email goes to everyone who can log in. To build that list the reader
must enumerate every ACTIVE human in every org — the operator's requirement,
verbatim: "Everyone in zitadel will need to get it" (El Director GO
2026-11-10T15:33:45Z). An org-scoped or project-scoped grant cannot see it.

IAM_OWNER can list users too, but it can also WRITE (edit orgs, create users,
grant roles). The reader only ever lists, so it gets the instance-scope
read-only role. That is the whole privilege of the principal: instance
membership plus IAM_OWNER_VIEWER, no project role, no role grant, no console
login (a machine user has no password). No narrower shipped role exists —
Zitadel has no IAM_USER_VIEWER (upstream zitadel/zitadel#12812 is open
precisely because a users-only read role does not exist) and the provider
exposes no instance-role resource. If the key leaks, everything readable on the
instance becomes readable and nothing becomes writable.

The three resources (provider zitadel/zitadel v3.8.7 — schema checked, not guessed)

resource key attributes
zitadel_machine_user.invite_list_reader user_name = "invite-list-reader" (exact, pinned in a local so no tfvars can drift a look-alike principal), name = "Invite List Reader", org_id = var.zitadel_org_id; no with_secret, no access_token_type
zitadel_instance_member.invite_list_reader user_id = <machine user>, roles = ["IAM_OWNER_VIEWER"]
zitadel_machine_key.invite_list_reader key_type = "KEY_TYPE_JSON", user_id = <machine user>, expiration_date = timeadd(timestamp(), "2160h") — a 90-day expiry stamped at creation;
lifecycle { ignore_changes = [expiration_date] } keeps later applies from replacing the key

Resource names and attribute schemas were read from the installed provider
(terraform init -backend=false → terraform providers schema -json), not from
memory: zitadel_machine_user, zitadel_instance_member, zitadel_machine_key
all exist at v3.8.7 and carry exactly these arguments.

Sensitive-key custody

  • output "invite_list_reader_machine_key" is sensitive = true — no plan,
    apply log or CI transcript prints it. The key material (key_details, JSON:
    keyId + key + userId) exists only in this root's state and that output.
  • At apply time it lands in OpenBao kv/app-credentials property
    invite_list_reader_machine_key via ONE pi-agent patch
    (bao kv patch, not kv put — a bare put replaces the shared object and
    drops every other app's credentials). The pi-agent AppRole can write only that
    object (prod terraform/openbao.tf, policy agent-pi).
  • It is never echoed to a terminal, a ticket, or a chat message.
  • Rotation = taint the key resource, re-apply, re-patch the OpenBao property;
    deleting the machine user or the key revokes the reader. Because the key now
    carries a 90-day expiry, a replaced key is a rotation, and the re-patch is part
    of that change.

output "invite_list_reader_user_id" is deliberately not sensitive: it is an
identifier the apply-time proof needs in the clear ("the reader authenticates as
THIS id and the listing succeeds").

Scope

Three files, one root, +146 / −0 against live: main.tf (locals + the three
resources + the scope and org-placement notes), outputs.tf (two outputs + the
custody note), terraform.tfvars.example (notes that nothing here is an input).
variables.tf is untouched — the expiry variable was withdrawn. No k8s secret,
no null_resource provisioner, no backend or provider change, no other root
touched.

Verify

terraform fmt -check -diff .        → exit 0, no diff
terraform validate                  → Success! The configuration is valid.

(init -backend=false, no credentials; the S3 backend was never initialised and
plan/apply were not run.)

Fix round 2 (5ea1b8af): fmt -check -diff . → exit 0, no diff;
validate (init -backend=false, no credentials, no plan) → Success! The
configuration is valid.
The lifecycle block is accepted by validate at
provider v3.8.7.

Seat

Two DeepSeek seats APPROVED on e694ff13. Review #2025 on af9da187
returned REQUEST_CHANGES with one blocker: expiration_date is ForceNew
with no UpdateContext at provider v3.8.7, so the recomputed
timeadd(timestamp(), "2160h") forced a key replacement on every apply —
a permanently non-convergent plan, a live credential rotation from any
unrelated apply, and a silently stale OpenBao property. This head fixes it
with lifecycle { ignore_changes = [expiration_date] } (the idiom this root
already uses on three other resources) plus the corrected wording; nothing
else changed. The 90-day bound is still set at creation and cannot be
extended from a tfvars file. No landing/apply without go.

Adds the process that builds the invite-email recipient list: a dedicated Zitadel **machine user**, instance membership with **`IAM_OWNER_VIEWER`**, and a **machine key bounded to 90 days**, exported as a sensitive output. Three resources in the existing `core/terraform/live/zitadel/` root, following the #904 additions' style. **No apply, no landing.** This PR is code only; it is not merged and nothing is applied without a later go. ## Fix round (overseer 2026-11-10T17:56:59Z) — three changes 1. **The key is bounded to 90 days.** `expiration_date` was `var.invite_list_reader_key_expiration_date` with a `null` default — an unbounded credential that no tfvars ever set. It is now `expiration_date = timeadd(timestamp(), "2160h")`. `timestamp()` is the apply-time instant, so every apply sets expiry = now + 90 days (`2160h` = 90 × 24h) and the key can never exist without an expiry. The variable was deleted rather than defaulted, so no tfvars can make the key immortal. The consequence is written in the code, not hidden. The bound is stamped **once, at creation**: the key is created with a 90-day expiry on the first apply. Provider v3.8.7 marks `expiration_date` **ForceNew with no `UpdateContext`** (read from the provider *source*, not `schema -json` — which reports `force_new=false` for every SDKv2 attribute), so a recomputed value would plan a **replacement of the key on every apply**: a non-convergent plan and a live credential rotation triggered by any unrelated apply. The resource therefore carries `lifecycle { ignore_changes = [expiration_date] }`. A replacement happens only if the resource is **tainted, or deleted and recreated** — and that event requires re-patching the OpenBao property in the same change. 2. **The read scope is stated honestly — read everything, write nothing.** An earlier draft called this "the instance's user directory". It is not. `IAM_OWNER_VIEWER` reads the whole instance: `events.read` (the entire audit/event stream, which carries actor ids, targets and timestamps), `user.read` and `org.read` (every org's content, not just the default one), plus `iam.idp.read`, `iam.action.read`, `iam.flow.read`, `iam.restrictions.read`, `iam.web_key.read`, `iam.debug.read`, `iam.feature.read` and `iam.member.read`. The `main.tf` block now lists that permission set line by line so a reader of the code sees the true radius, and the machine user's `description` says the same so the console does too. 3. **Org placement wording corrected.** The machine user **is** a member of the default organization: it is created with `org_id = var.zitadel_org_id`, and a Zitadel user always needs an owning org. What it does **not** have is an org *role* — no `ORG_OWNER`/`ORG_OWNER_VIEWER` grant — so org-admin power is not implied; the privileged grant is the instance membership. Hardening consideration recorded as non-blocking: an instance-wide-read principal is ideally placed in a dedicated admin org rather than the default one; current placement is accepted because the default org's admins are the operator's own accounts, so this principal does not widen anyone else's org-admin reach. ## Why instance-wide, and why read-only The invite email goes to everyone who can log in. To build that list the reader must enumerate every ACTIVE human in **every** org — the operator's requirement, verbatim: *"Everyone in zitadel will need to get it"* (El Director GO 2026-11-10T15:33:45Z). An org-scoped or project-scoped grant cannot see it. `IAM_OWNER` can list users too, but it can also WRITE (edit orgs, create users, grant roles). The reader only ever lists, so it gets the instance-scope **read-only** role. That is the whole privilege of the principal: instance membership plus `IAM_OWNER_VIEWER`, no project role, no role grant, no console login (a machine user has no password). No narrower shipped role exists — Zitadel has no `IAM_USER_VIEWER` (upstream `zitadel/zitadel#12812` is open precisely because a users-only read role does not exist) and the provider exposes no instance-role resource. If the key leaks, everything readable on the instance becomes readable and nothing becomes writable. ## The three resources (provider `zitadel/zitadel` v3.8.7 — schema checked, not guessed) | resource | key attributes | |---|---| | `zitadel_machine_user.invite_list_reader` | `user_name = "invite-list-reader"` (exact, pinned in a `local` so no tfvars can drift a look-alike principal), `name = "Invite List Reader"`, `org_id = var.zitadel_org_id`; no `with_secret`, no `access_token_type` | | `zitadel_instance_member.invite_list_reader` | `user_id = <machine user>`, `roles = ["IAM_OWNER_VIEWER"]` | | `zitadel_machine_key.invite_list_reader` | `key_type = "KEY_TYPE_JSON"`, `user_id = <machine user>`, `expiration_date = timeadd(timestamp(), "2160h")` — a 90-day expiry stamped at creation; `lifecycle { ignore_changes = [expiration_date] }` keeps later applies from replacing the key | Resource names and attribute schemas were read from the installed provider (`terraform init -backend=false` → `terraform providers schema -json`), not from memory: `zitadel_machine_user`, `zitadel_instance_member`, `zitadel_machine_key` all exist at v3.8.7 and carry exactly these arguments. ## Sensitive-key custody * `output "invite_list_reader_machine_key"` is `sensitive = true` — no plan, apply log or CI transcript prints it. The key material (`key_details`, JSON: `keyId` + `key` + `userId`) exists only in this root's state and that output. * At apply time it lands in OpenBao `kv/app-credentials` property `invite_list_reader_machine_key` via ONE pi-agent patch (`bao kv patch`, **not** `kv put` — a bare put replaces the shared object and drops every other app's credentials). The pi-agent AppRole can write only that object (`prod` `terraform/openbao.tf`, policy `agent-pi`). * It is never echoed to a terminal, a ticket, or a chat message. * Rotation = taint the key resource, re-apply, re-patch the OpenBao property; deleting the machine user or the key revokes the reader. Because the key now carries a 90-day expiry, a replaced key is a rotation, and the re-patch is part of that change. `output "invite_list_reader_user_id"` is deliberately **not** sensitive: it is an identifier the apply-time proof needs in the clear ("the reader authenticates as THIS id and the listing succeeds"). ## Scope Three files, one root, **+146 / −0** against `live`: `main.tf` (locals + the three resources + the scope and org-placement notes), `outputs.tf` (two outputs + the custody note), `terraform.tfvars.example` (notes that nothing here is an input). `variables.tf` is untouched — the expiry variable was withdrawn. No k8s secret, no `null_resource` provisioner, no backend or provider change, no other root touched. ## Verify ``` terraform fmt -check -diff . → exit 0, no diff terraform validate → Success! The configuration is valid. ``` (`init -backend=false`, no credentials; the S3 backend was never initialised and `plan`/`apply` were not run.) Fix round 2 (`5ea1b8af`): `fmt -check -diff .` → exit 0, no diff; `validate` (`init -backend=false`, no credentials, **no plan**) → *Success! The configuration is valid.* The `lifecycle` block is accepted by `validate` at provider v3.8.7. ## Seat **Two DeepSeek seats APPROVED on `e694ff13`.** Review **#2025** on `af9da187` returned **REQUEST_CHANGES with one blocker**: `expiration_date` is ForceNew with no `UpdateContext` at provider v3.8.7, so the recomputed `timeadd(timestamp(), "2160h")` forced a key **replacement on every apply** — a permanently non-convergent plan, a live credential rotation from any unrelated apply, and a silently stale OpenBao property. This head fixes it with `lifecycle { ignore_changes = [expiration_date] }` (the idiom this root already uses on three other resources) plus the corrected wording; nothing else changed. The 90-day bound is still set at creation and cannot be extended from a tfvars file. No landing/apply without go.
HOMELAB-2479: feat(zitadel): invite-list-reader machine user (IAM_OWNER_VIEWER)
All checks were successful
CI Review / runner-manifest-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 8s
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Lint & Validate / shellcheck (pull_request) Successful in 5s
Lint & Validate / yaml-lint (pull_request) Successful in 25s
CI Review / record-sql-test (pull_request) Successful in 52s
Lint & Validate / terraform-validate (pull_request) Successful in 2m10s
CI Review / cockpit-test (pull_request) Successful in 2m37s
CI Review / cockpit-e2e (pull_request) Successful in 2m55s
CI Review / dispatcher-test (pull_request) Successful in 3m24s
e694ff133e
Machine user 'invite-list-reader' + instance membership IAM_OWNER_VIEWER
(instance-wide, READ-ONLY) + machine key (KEY_TYPE_JSON) exported as a
sensitive output. Provider resources confirmed against zitadel/zitadel
v3.8.7 schema: zitadel_machine_user, zitadel_instance_member,
zitadel_machine_key. fmt + validate (init -backend=false) pass.
ld-reviewer approved these changes 2026-11-10 17:17:02 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: e694ff133e
session: seat-912-A-e694ff13

bridge: sbx-lab verdict-cli, prerequisite 4b failing
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912

Independent review — seat A (fresh context, own measurement)

I judged the PR's own bytes at the pinned head e694ff13. The PR description, the
ticket's prose and any earlier verdict were treated as claims to check, not as
evidence. The head was confirmed first, as ld-reviewer, with git ls-remote on
refs/pull/912/head through the mandatory helper's own credential shim;
FORGEJO_API_KEY was unset in every git and API call.

What the change is

Four files, all inside core/terraform/live/zitadel/, +104 / −0 — new lines only:

  • main.tf (+55) — one locals pin (invite_list_reader_user_name = "invite-list-reader") and three new resources: zitadel_machine_user .invite_list_reader, zitadel_instance_member.invite_list_reader
    (roles = ["IAM_OWNER_VIEWER"]), zitadel_machine_key.invite_list_reader
    (key_type = "KEY_TYPE_JSON"). They sit after zitadel_user_grant .additional_project, before the app-side OIDC section. Nothing already in the
    file is edited.
  • outputs.tf (+35) — invite_list_reader_machine_key (sensitive = true) and
    invite_list_reader_user_id (deliberately not sensitive), preceded by the
    custody comment.
  • variables.tf (+7) and terraform.tfvars.example (+7) — one optional
    variable, invite_list_reader_key_expiration_date, default null.

Base == merge-base == live tip 2b17d62f, so the two-dot and three-dot diffs
agree: 4 files, none outside core/terraform/live/zitadel/, no workflow, no
deploy path, no k8s manifest, no state file, no credential file.

The three resources against the pinned provider schema

terraform init -backend=false (no credentials, no backend) in a copy of the
head's zitadel root resolved zitadel/zitadel v3.8.7 — the constraint is
>= 2.10.0 and the lockfile is gitignored in this repo, so init is what pins the
version. I then read the schema out of the installed provider with
terraform providers schema -json:

  • zitadel_machine_user — user_name and name are REQUIRED; with_secret
    and access_token_type optional. The PR sets user_name = local.invite_list_reader_user_name, whose value is the literal
    invite-list-reader, and omits both secret options. The provider's own
    attribute doc says with_secret generates a machine secret "only applicable if
    creation or change from false" — omitted, no client secret is created, so the
    reader holds nothing but the key below.
  • zitadel_instance_member — its attributes are exactly user_id, roles,
    id: there is no org_id, and the resource description reads "membership
    of a user on an instance, defined with the given role". The roles doc says
    "Instance member roles must start with 'IAM_' (e.g., IAM_OWNER,
    IAM_OWNER_VIEWER)". That is the distinction that matters here: the
    zitadel_org_member used just above for admin_iam carries org_id and takes
    ORG_* roles, while this resource cannot be org-scoped — which is exactly what
    makes the listing instance-wide instead of org-local.
  • zitadel_machine_key — user_id and key_type REQUIRED; key_details is
    computed and flagged SENSITIVE at the schema level; expiration_date is
    optional + computed, so a null default takes Zitadel's own value and cannot
    produce a perpetual diff. key_type accepts KEY_TYPE_JSON ("supported
    values: KEY_TYPE_UNSPECIFIED, KEY_TYPE_JSON") — the JSON document
    (keyId + key + userId) the JWT-bearer grant consumes.
  • IAM_OWNER_VIEWER is a real instance role and it is the read-only one.
    ZITADEL's administrators table lists "Instance Owner Viewer | IAM_OWNER_VIEWER |
    View the Instance and view all organizations with their content", beside
    "Instance Owner | IAM_OWNER | Manage the Instance, manage all organizations with
    their content". The same page states that only instance-level administrators can
    view users across all organizations — so the role, and specifically not a
    project or org grant, is what the "everyone who can log in gets the invite"
    requirement needs.

Custody of the key

key_details is computed + sensitive in the provider, so no plan, apply output or
terraform output prints it by accident, and the output layered on it is
sensitive = true as well. Reading all 104 added lines, there is no key
material, no secret value, no state file and no credential file in the diff; the
only credential-shaped strings are the OpenBao property name and the bao kv patch command inside a comment. The route is stated once and is the estate's
established one: one bao kv patch into the shared kv/app-credentials object as
the pi-agent AppRole, patch rather than put so the other apps' properties
survive. I checked that claim where it is checkable from here: the agent-pi
policy in prod terraform/openbao.tf grants create/read/update on
kv/data/app-credentials plus kv/metadata/app-credentials read (and token
renew-self) and nothing else, so that principal really can write only that one
object; and memory/operations/openbao-pi-agent-access.md (HOMELAB-1485) records
the same VAULT_TOKEN=… bao kv patch kv/app-credentials invocation as working
practice. invite_list_reader_user_id is correctly not marked sensitive: an id
is an identifier, and the apply-time proof needs it.

Blast radius and shape

Pure adds: no existing resource, variable or output is modified, no dependency
rewired, no provider constraint changed, nothing ArgoCD could prune. Two locals
blocks in one file is legal HCL and validate accepts it. The grant is
instance-scoped and read-only, so a leaked key reads the directory and org content
and writes nothing — it cannot grant roles, create users or edit policies.

The checks named in the brief, re-run

  • terraform fmt -check -diff over the head's zitadel root: exit 0, no diff.
  • terraform init -backend=false then terraform validate: "Success! The
    configuration is valid." No credentials, no backend, nothing applied.

CI at the head

commits/e694ff13…/status answers state: success with 11 contexts —
Lint & Validate / terraform-validate, yaml-lint, shellcheck, and the eight
CI Review / * jobs (dispatcher-test, cockpit-e2e, cockpit-test, record-sql-test,
alert-receiver-test, helm-validate, receiver-test, runner-manifest-test) — and the
two pull_request runs against this sha (ci.yaml 13227, lint.yaml 13228) both
report success. Read with the ld-reviewer credential, not FORGEJO_API_KEY.

4b

repos/Homelab/infra-core/branch_protections as ld-reviewer answers 403
("user should be an owner or a collaborator with admin write of a repository"), so
the required CI contexts are UNKNOWN, not empty, and the read-back reports
official=false on that documented leg. I also tried the HOMELAB-2686 route,
repos/Homelab/infra-core/branches/live: it answers 200, protected: true,
user_can_push: false, but carries no status_check_contexts, so the named set
stays unknown. The verdict below is pinned to the exact head regardless.

Non-blocking notes

  • The comment's leak-radius sentence ("the instance's user directory becomes
    readable") is narrower than the role is. By ZITADEL's own table
    IAM_OWNER_VIEWER views the instance and all organizations with their content.
    Still read-only and still proportionate to the requirement, but the comment
    should not be read as "users only" — the honest radius is "read everything,
    write nothing".
  • expiration_date defaults to null, so the key never expires on its own;
    rotation is a documented taint. Setting the variable in prod later is the cheap
    way to bound it.
  • A pre-existing invite-list-reader username would make the apply fail (Zitadel
    refuses a duplicate login name) rather than silently fork a second principal —
    fail-closed, and the pinned local is what stops a tfvars value creating a
    look-alike.

Nothing in this diff stops the change: the four files are new lines only, the
schema supports every field they set, the key cannot print, and CI is green at the
pinned head. APPROVED for the head above.

Blockers: none.

## Verdict: APPROVED model: omniroute/deepseek-flash head: e694ff133ef76e11b059d4531c040c5694f1ad56 session: seat-912-A-e694ff13 bridge: sbx-lab verdict-cli, prerequisite 4b failing LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912 ## Independent review — seat A (fresh context, own measurement) I judged the PR's own bytes at the pinned head `e694ff13`. The PR description, the ticket's prose and any earlier verdict were treated as claims to check, not as evidence. The head was confirmed first, as `ld-reviewer`, with `git ls-remote` on `refs/pull/912/head` through the mandatory helper's own credential shim; `FORGEJO_API_KEY` was unset in every git and API call. ### What the change is Four files, all inside `core/terraform/live/zitadel/`, +104 / −0 — new lines only: * `main.tf` (+55) — one `locals` pin (`invite_list_reader_user_name = "invite-list-reader"`) and three new resources: `zitadel_machine_user .invite_list_reader`, `zitadel_instance_member.invite_list_reader` (`roles = ["IAM_OWNER_VIEWER"]`), `zitadel_machine_key.invite_list_reader` (`key_type = "KEY_TYPE_JSON"`). They sit after `zitadel_user_grant .additional_project`, before the app-side OIDC section. Nothing already in the file is edited. * `outputs.tf` (+35) — `invite_list_reader_machine_key` (`sensitive = true`) and `invite_list_reader_user_id` (deliberately not sensitive), preceded by the custody comment. * `variables.tf` (+7) and `terraform.tfvars.example` (+7) — one optional variable, `invite_list_reader_key_expiration_date`, default `null`. Base == merge-base == `live` tip `2b17d62f`, so the two-dot and three-dot diffs agree: 4 files, none outside `core/terraform/live/zitadel/`, no workflow, no deploy path, no k8s manifest, no state file, no credential file. ### The three resources against the pinned provider schema `terraform init -backend=false` (no credentials, no backend) in a copy of the head's zitadel root resolved **zitadel/zitadel v3.8.7** — the constraint is `>= 2.10.0` and the lockfile is gitignored in this repo, so init is what pins the version. I then read the schema out of the installed provider with `terraform providers schema -json`: * **`zitadel_machine_user`** — `user_name` and `name` are REQUIRED; `with_secret` and `access_token_type` optional. The PR sets `user_name = local.invite_list_reader_user_name`, whose value is the literal `invite-list-reader`, and omits both secret options. The provider's own attribute doc says `with_secret` generates a machine secret "only applicable if creation or change from false" — omitted, no client secret is created, so the reader holds nothing but the key below. * **`zitadel_instance_member`** — its attributes are exactly `user_id`, `roles`, `id`: there is **no `org_id`**, and the resource description reads "membership of a user on an instance, defined with the given role". The `roles` doc says "Instance member roles must start with 'IAM_' (e.g., IAM_OWNER, IAM_OWNER_VIEWER)". That is the distinction that matters here: the `zitadel_org_member` used just above for `admin_iam` carries `org_id` and takes `ORG_*` roles, while this resource cannot be org-scoped — which is exactly what makes the listing instance-wide instead of org-local. * **`zitadel_machine_key`** — `user_id` and `key_type` REQUIRED; `key_details` is `computed` and flagged **SENSITIVE** at the schema level; `expiration_date` is `optional + computed`, so a `null` default takes Zitadel's own value and cannot produce a perpetual diff. `key_type` accepts `KEY_TYPE_JSON` ("supported values: KEY_TYPE_UNSPECIFIED, KEY_TYPE_JSON") — the JSON document (keyId + key + userId) the JWT-bearer grant consumes. * **`IAM_OWNER_VIEWER` is a real instance role and it is the read-only one.** ZITADEL's administrators table lists "Instance Owner Viewer | IAM_OWNER_VIEWER | View the Instance and view all organizations with their content", beside "Instance Owner | IAM_OWNER | Manage the Instance, manage all organizations with their content". The same page states that only instance-level administrators can view users across all organizations — so the role, and specifically *not* a project or org grant, is what the "everyone who can log in gets the invite" requirement needs. ### Custody of the key `key_details` is computed + sensitive in the provider, so no plan, apply output or `terraform output` prints it by accident, and the output layered on it is `sensitive = true` as well. Reading all 104 added lines, there is no key material, no secret value, no state file and no credential file in the diff; the only credential-shaped strings are the OpenBao property name and the `bao kv patch` command inside a comment. The route is stated once and is the estate's established one: one `bao kv patch` into the shared `kv/app-credentials` object as the `pi-agent` AppRole, patch rather than put so the other apps' properties survive. I checked that claim where it is checkable from here: the `agent-pi` policy in `prod` `terraform/openbao.tf` grants `create/read/update` on `kv/data/app-credentials` plus `kv/metadata/app-credentials` read (and token renew-self) and nothing else, so that principal really can write only that one object; and `memory/operations/openbao-pi-agent-access.md` (HOMELAB-1485) records the same `VAULT_TOKEN=… bao kv patch kv/app-credentials` invocation as working practice. `invite_list_reader_user_id` is correctly *not* marked sensitive: an id is an identifier, and the apply-time proof needs it. ### Blast radius and shape Pure adds: no existing resource, variable or output is modified, no dependency rewired, no provider constraint changed, nothing ArgoCD could prune. Two `locals` blocks in one file is legal HCL and `validate` accepts it. The grant is instance-scoped and read-only, so a leaked key reads the directory and org content and writes nothing — it cannot grant roles, create users or edit policies. ### The checks named in the brief, re-run * `terraform fmt -check -diff` over the head's zitadel root: exit 0, no diff. * `terraform init -backend=false` then `terraform validate`: "Success! The configuration is valid." No credentials, no backend, nothing applied. ### CI at the head `commits/e694ff13…/status` answers `state: success` with 11 contexts — `Lint & Validate / terraform-validate`, `yaml-lint`, `shellcheck`, and the eight `CI Review / *` jobs (dispatcher-test, cockpit-e2e, cockpit-test, record-sql-test, alert-receiver-test, helm-validate, receiver-test, runner-manifest-test) — and the two `pull_request` runs against this sha (ci.yaml 13227, lint.yaml 13228) both report `success`. Read with the `ld-reviewer` credential, not `FORGEJO_API_KEY`. ### 4b `repos/Homelab/infra-core/branch_protections` as `ld-reviewer` answers 403 ("user should be an owner or a collaborator with admin write of a repository"), so the required CI contexts are UNKNOWN, not empty, and the read-back reports `official=false` on that documented leg. I also tried the HOMELAB-2686 route, `repos/Homelab/infra-core/branches/live`: it answers 200, `protected: true`, `user_can_push: false`, but carries no `status_check_contexts`, so the named set stays unknown. The verdict below is pinned to the exact head regardless. ### Non-blocking notes * The comment's leak-radius sentence ("the instance's user directory becomes readable") is narrower than the role is. By ZITADEL's own table IAM_OWNER_VIEWER views the instance *and all organizations with their content*. Still read-only and still proportionate to the requirement, but the comment should not be read as "users only" — the honest radius is "read everything, write nothing". * `expiration_date` defaults to `null`, so the key never expires on its own; rotation is a documented taint. Setting the variable in prod later is the cheap way to bound it. * A pre-existing `invite-list-reader` username would make the apply fail (Zitadel refuses a duplicate login name) rather than silently fork a second principal — fail-closed, and the pinned local is what stops a tfvars value creating a look-alike. Nothing in this diff stops the change: the four files are new lines only, the schema supports every field they set, the key cannot print, and CI is green at the pinned head. APPROVED for the head above. Blockers: none.
ld-reviewer approved these changes 2026-11-10 17:55:23 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: e694ff133e
session: seat-912-B-e694ff13
tier: worker

verdict helper — Homelab/infra-core#912 @ e694ff1 (APPROVED, model omniroute/deepseek-flash, session seat-912-B-e694ff13, author ld-reviewer)

Verdict: APPROVED (two non-blocking concerns)

Independent second seat (bridge seat B) on a dangerous-class change. Everything below is
my own measurement at the pinned head: git objects I fetched as ld-reviewer, the provider
schema from a credential-free terraform init -backend=false, and Zitadel v4.10.1's own
shipped role table. Seat A's body was not read — deliberately — before this was posted.

bridge: sbx-lab verdict-cli, prerequisite 4b failing
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912

Head, scope and CI

git ls-remote refs/pull/912/head as ld-reviewer answers
e694ff133ef76e11b059d4531c040c5694f1ad56 — the pinned head, verified before anything else.

The PR's own delta (merge-base 2b17d62f .. head, three-dot) is 4 files, 104 insertions,
0 deletions
, all under core/terraform/live/zitadel/: main.tf (+55), outputs.tf (+35),
terraform.tfvars.example (+7), variables.tf (+7). That matches the API's own
changed_files=4, additions=104, deletions=0, and it matches the PR's "four files, one root"
claim.

Worth stating because a two-dot diff against the live tip shows a fifth file: live carries
commit 5a663a5 ("prefer prod-wk-02 for the seat sandbox") on
core/manifests/apps/sbx-lab/statefulset.yaml after this branch's merge-base. That is not
part of this PR and merging #912 will not roll the seat sandbox. The two-dot view is the
artifact, not the PR.

CI at head: combined state success, 11 contexts, all success, including
Lint & Validate / terraform-validate (2m10s) and CI Review / helm-validate.

I re-ran the PR's own verification myself, in a credential-free copy of the root:
terraform init -backend=false → provider zitadel/zitadel v3.8.7 (matching the body's
claim), terraform fmt -check -diff . → exit 0, no output, terraform validate →
Success! The configuration is valid. No backend initialised, no Zitadel credential used.

Privilege minimality — is IAM_OWNER_VIEWER the smallest instance-scope read?

Yes, of the roles that ship, and here is the evidence rather than an impression. Zitadel
v4.10.1's own InternalAuthZ role table (cmd/defaults.yaml at the tag this instance runs —
ghcr.io/zitadel/zitadel:v4.10.1) defines exactly seven instance roles: IAM_OWNER,
IAM_OWNER_VIEWER, IAM_ORG_MANAGER, IAM_USER_MANAGER, IAM_ADMIN_IMPERSONATOR,
IAM_END_USER_IMPERSONATOR, IAM_LOGIN_CLIENT. Only IAM_OWNER_VIEWER is read-only and
instance-wide. There is no IAM_USER_VIEWER: that is upstream issue zitadel/zitadel#12812,
open precisely because a users-only read role does not exist. IAM_LOGIN_CLIENT is not a
smaller alternative (it is the login UI's role, and its permission set exists to finalize auth
requests). The provider has no zitadel_instance_role resource (checked the v3.8.7 schema:
instance-scope resources are custom_domain, features, member, restrictions, secret_generator,
trusted_domain — no role), so a narrower role cannot be created from this root.

So the role choice is correct as stated: the smallest shipped read-only instance role. It is
also genuinely read-only — all 34 permissions in the v4.10.1 table are *.read, and
user.write, user.delete, iam.member.write and session.delete are absent. The body's
"nothing becomes writable" is true at the verb level.

Concern 1 (non-blocking): the blast radius sentence understates the read surface.
IAM_OWNER_VIEWER does not read "the instance's user directory"; it reads the instance:
iam.idp.read, iam.action.read, iam.flow.read, iam.restrictions.read, iam.feature.read,
iam.web_key.read, iam.debug.read, events.read (the whole audit event stream, which
includes actor ids and payloads across every org), plus all org/project/policy/app/grant data.
That is a much wider disclosure than "who can log in", and it is what a leaked key would give
away. For a principal whose only job is listing active humans, either the custody note should
say what is actually exposed, or — if that breadth is unacceptable — the narrower path exists
only as a custom instance role in Zitadel's InternalAuthZ.RolePermissionMappings
(self-hosted runtime config). I am not asking for it: touching the authz runtime config is a
larger risk to the instance than the read breadth it would remove. I am asking that the claim
match the permissions.

Expiry — what does expiration_date = null mean here

The provider schema (read from the installed v3.8.7, not from memory) marks
zitadel_machine_key.expiration_date Optional + Computed. That is the precise meaning of
the default: with the variable null, this configuration does not assert "no expiry" — it
asserts nothing, and the value in state is whatever Zitadel returned. Zitadel documents expiry
only for the case where a date is supplied ("that key will expire at midnight on the specified
day"); no platform default is documented and I found none in the shipped defaults, so the
practical reading is that the key does not expire. The PR's "drift-free" claim is structurally
sound (null config + computed attribute cannot diff), but "provider-computed" is not the same
as "bounded".

Concern 2 (non-blocking): an unbounded credential for an instance-wide read, with rotation
that has no trigger.
Rotation-by-taint is documented and works, but nothing forces it: no
expiry, no scheduled job, no review date on the ticket means the key lives forever unless a
human remembers. The variable to pin a finite date already exists, so the cheapest fix is to
set it (say one year) and record the re-key date — but note the consequence honestly: pinning a
date does not rotate anything, it makes the reader stop that day, so it must come with a
planned re-key, not just a date. That trade-off is why this is a concern and not a blocker;
either choice is defensible, silence about which one was chosen is not.

Org placement — org_id = var.zitadel_org_id

var.zitadel_org_id is the root's "Zitadel default organization" (variables.tf) — the org
that already holds the other 40 resources, i.e. the application's org, not a dedicated
administration org. Three things follow, one of which is a wording error in the diff:

  1. Placement grants no additional authority. The instance role is instance-scope; no
    ORG_* role is granted to this user, so being in that org adds no permission of its own.
  2. The comment in main.tf — "instance membership plus IAM_OWNER_VIEWER, and nothing else.
    No project role, no role grant, no org membership" — is inaccurate. A Zitadel user
    always belongs to an org, and org_id is exactly what chooses it. The reader is a member of
    the default org.
  3. That membership is not cosmetic: holders of ORG_OWNER / ORG_USER_MANAGER in that org hold
    user.write over its users, which is the permission that mints a machine key. So an
    organization administrator of the default org can produce a credential for a principal that
    reads the whole instance. Zitadel's own hardening guide says exactly this in general form:
    instance-role-bearing principals and automation belong in a dedicated
    instance-administration organization, not in a mixed application organization, because the
    application org's administrators can shape those accounts' settings and credentials.

I am not blocking on it: today the people who hold ORG_* in the default org are the operator
and the instance administrators, so the practical delta is nil. It becomes a real problem the
day an application org gets its own org administrator. Recording it here is the ask; a
dedicated org for instance automation is the option to weigh before that day.

Custody

No key material in the diff: key_details is referenced only from an output marked
sensitive = true, and the non-sensitive output is the user id, which is an identifier the
apply-time proof needs. The single-write path is stated (bao kv patch, never kv put, one
object property, pi-agent AppRole limited to that object), the state/S3 trust boundary is
stated, and nothing in the PR echoes the key. Two things to keep visible rather than as
findings: the PR is code-only, so the reader is inert until an apply and that patch both
happen; and the key does live in Terraform state, which is the same boundary every OIDC client
secret in this root already sits on.

Apply-time notes for whoever applies (not defects in this PR)

  • versions.tf floats zitadel >= 2.10.0 and no .terraform.lock.hcl is committed, so the
    provider version is resolved at init time (3.8.7 today). Pre-existing, worth a lock file
    before an apply is trusted to be reproducible.
  • The instance is v4.10.1, and v4 marks the management-service machine endpoints
    (AddMachineUser, AddMachineKey, …) deprecated in favour of the v2 user API. They still
    serve, and this provider still calls them; a future provider/server upgrade is where this
    root will need attention.

Verdict

APPROVED. The three resources, the role, the sensitive-output custody and the scoped diff are
what a dangerous-class change of this shape should look like, and CI plus my own fmt/validate
agree at the pinned head. The two concerns above are about how the blast radius is described
(the read surface is wider than the user directory) and about an expiry decision left
implicit; neither blocks landing, and both are cheap to address in a follow-up or on the
ticket. No apply, and no landing, without the operator's go — nothing here is evidence that an
apply was run.

## Verdict: APPROVED model: omniroute/deepseek-flash head: e694ff133ef76e11b059d4531c040c5694f1ad56 session: seat-912-B-e694ff13 tier: worker verdict helper — Homelab/infra-core#912 @ e694ff1 (APPROVED, model omniroute/deepseek-flash, session seat-912-B-e694ff13, author ld-reviewer) ## Verdict: APPROVED (two non-blocking concerns) Independent second seat (bridge seat B) on a dangerous-class change. Everything below is my own measurement at the pinned head: git objects I fetched as `ld-reviewer`, the provider schema from a credential-free `terraform init -backend=false`, and Zitadel v4.10.1's own shipped role table. Seat A's body was not read — deliberately — before this was posted. bridge: sbx-lab verdict-cli, prerequisite 4b failing LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912 ### Head, scope and CI `git ls-remote refs/pull/912/head` as `ld-reviewer` answers `e694ff133ef76e11b059d4531c040c5694f1ad56` — the pinned head, verified before anything else. The PR's own delta (merge-base `2b17d62f` .. head, three-dot) is **4 files, 104 insertions, 0 deletions**, all under `core/terraform/live/zitadel/`: `main.tf` (+55), `outputs.tf` (+35), `terraform.tfvars.example` (+7), `variables.tf` (+7). That matches the API's own `changed_files=4, additions=104, deletions=0`, and it matches the PR's "four files, one root" claim. Worth stating because a two-dot diff against the live tip shows a fifth file: `live` carries commit `5a663a5` ("prefer prod-wk-02 for the seat sandbox") on `core/manifests/apps/sbx-lab/statefulset.yaml` **after** this branch's merge-base. That is not part of this PR and merging #912 will not roll the seat sandbox. The two-dot view is the artifact, not the PR. CI at head: combined state `success`, 11 contexts, all success, including `Lint & Validate / terraform-validate` (2m10s) and `CI Review / helm-validate`. I re-ran the PR's own verification myself, in a credential-free copy of the root: `terraform init -backend=false` → provider `zitadel/zitadel v3.8.7` (matching the body's claim), `terraform fmt -check -diff .` → exit 0, no output, `terraform validate` → `Success! The configuration is valid.` No backend initialised, no Zitadel credential used. ### Privilege minimality — is `IAM_OWNER_VIEWER` the smallest instance-scope read? Yes, of the roles that ship, and here is the evidence rather than an impression. Zitadel v4.10.1's own `InternalAuthZ` role table (`cmd/defaults.yaml` at the tag this instance runs — `ghcr.io/zitadel/zitadel:v4.10.1`) defines exactly seven instance roles: `IAM_OWNER`, `IAM_OWNER_VIEWER`, `IAM_ORG_MANAGER`, `IAM_USER_MANAGER`, `IAM_ADMIN_IMPERSONATOR`, `IAM_END_USER_IMPERSONATOR`, `IAM_LOGIN_CLIENT`. Only `IAM_OWNER_VIEWER` is read-only and instance-wide. There is no `IAM_USER_VIEWER`: that is upstream issue zitadel/zitadel#12812, open precisely because a users-only read role does not exist. `IAM_LOGIN_CLIENT` is not a smaller alternative (it is the login UI's role, and its permission set exists to finalize auth requests). The provider has no `zitadel_instance_role` resource (checked the v3.8.7 schema: instance-scope resources are custom_domain, features, member, restrictions, secret_generator, trusted_domain — no role), so a narrower role cannot be created from this root. So the role choice is correct as stated: the smallest shipped read-only instance role. It is also genuinely read-only — all 34 permissions in the v4.10.1 table are `*.read`, and `user.write`, `user.delete`, `iam.member.write` and `session.delete` are absent. The body's "nothing becomes writable" is true at the verb level. **Concern 1 (non-blocking): the blast radius sentence understates the read surface.** `IAM_OWNER_VIEWER` does not read "the instance's user directory"; it reads the instance: `iam.idp.read`, `iam.action.read`, `iam.flow.read`, `iam.restrictions.read`, `iam.feature.read`, `iam.web_key.read`, `iam.debug.read`, `events.read` (the whole audit event stream, which includes actor ids and payloads across every org), plus all org/project/policy/app/grant data. That is a much wider disclosure than "who can log in", and it is what a leaked key would give away. For a principal whose only job is listing active humans, either the custody note should say what is actually exposed, or — if that breadth is unacceptable — the narrower path exists only as a custom instance role in Zitadel's `InternalAuthZ.RolePermissionMappings` (self-hosted runtime config). I am not asking for it: touching the authz runtime config is a larger risk to the instance than the read breadth it would remove. I am asking that the claim match the permissions. ### Expiry — what does `expiration_date = null` mean here The provider schema (read from the installed v3.8.7, not from memory) marks `zitadel_machine_key.expiration_date` **Optional + Computed**. That is the precise meaning of the default: with the variable null, this configuration does not assert "no expiry" — it asserts nothing, and the value in state is whatever Zitadel returned. Zitadel documents expiry only for the case where a date is supplied ("that key will expire at midnight on the specified day"); no platform default is documented and I found none in the shipped defaults, so the practical reading is that the key does not expire. The PR's "drift-free" claim is structurally sound (null config + computed attribute cannot diff), but "provider-computed" is not the same as "bounded". **Concern 2 (non-blocking): an unbounded credential for an instance-wide read, with rotation that has no trigger.** Rotation-by-taint is documented and works, but nothing forces it: no expiry, no scheduled job, no review date on the ticket means the key lives forever unless a human remembers. The variable to pin a finite date already exists, so the cheapest fix is to set it (say one year) and record the re-key date — but note the consequence honestly: pinning a date does not rotate anything, it makes the reader stop that day, so it must come with a planned re-key, not just a date. That trade-off is why this is a concern and not a blocker; either choice is defensible, silence about which one was chosen is not. ### Org placement — `org_id = var.zitadel_org_id` `var.zitadel_org_id` is the root's "Zitadel default organization" (`variables.tf`) — the org that already holds the other 40 resources, i.e. the application's org, not a dedicated administration org. Three things follow, one of which is a wording error in the diff: 1. Placement grants **no** additional authority. The instance role is instance-scope; no `ORG_*` role is granted to this user, so being in that org adds no permission of its own. 2. The comment in `main.tf` — "instance membership plus `IAM_OWNER_VIEWER`, and nothing else. No project role, no role grant, **no org membership**" — is inaccurate. A Zitadel user always belongs to an org, and `org_id` is exactly what chooses it. The reader is a member of the default org. 3. That membership is not cosmetic: holders of `ORG_OWNER` / `ORG_USER_MANAGER` in that org hold `user.write` over its users, which is the permission that mints a machine key. So an organization administrator of the default org can produce a credential for a principal that reads the whole instance. Zitadel's own hardening guide says exactly this in general form: instance-role-bearing principals and automation belong in a dedicated instance-administration organization, not in a mixed application organization, because the application org's administrators can shape those accounts' settings and credentials. I am not blocking on it: today the people who hold `ORG_*` in the default org are the operator and the instance administrators, so the practical delta is nil. It becomes a real problem the day an application org gets its own org administrator. Recording it here is the ask; a dedicated org for instance automation is the option to weigh before that day. ### Custody No key material in the diff: `key_details` is referenced only from an output marked `sensitive = true`, and the non-sensitive output is the user id, which is an identifier the apply-time proof needs. The single-write path is stated (`bao kv patch`, never `kv put`, one object property, pi-agent AppRole limited to that object), the state/S3 trust boundary is stated, and nothing in the PR echoes the key. Two things to keep visible rather than as findings: the PR is code-only, so the reader is inert until an apply and that patch both happen; and the key does live in Terraform state, which is the same boundary every OIDC client secret in this root already sits on. ### Apply-time notes for whoever applies (not defects in this PR) * `versions.tf` floats `zitadel >= 2.10.0` and no `.terraform.lock.hcl` is committed, so the provider version is resolved at init time (3.8.7 today). Pre-existing, worth a lock file before an apply is trusted to be reproducible. * The instance is v4.10.1, and v4 marks the management-service machine endpoints (`AddMachineUser`, `AddMachineKey`, …) deprecated in favour of the v2 user API. They still serve, and this provider still calls them; a future provider/server upgrade is where this root will need attention. ### Verdict APPROVED. The three resources, the role, the sensitive-output custody and the scoped diff are what a dangerous-class change of this shape should look like, and CI plus my own fmt/validate agree at the pinned head. The two concerns above are about how the blast radius is described (the read surface is wider than the user directory) and about an expiry decision left implicit; neither blocks landing, and both are cheap to address in a follow-up or on the ticket. No apply, and no landing, without the operator's go — nothing here is evidence that an apply was run.
HOMELAB-2479: fix(zitadel): bound reader key to 90d, honest read scope, org wording
All checks were successful
CI Review / runner-manifest-test (pull_request) Successful in 5s
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 11s
Lint & Validate / shellcheck (pull_request) Successful in 7s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Lint & Validate / yaml-lint (pull_request) Successful in 20s
CI Review / record-sql-test (pull_request) Successful in 1m7s
Lint & Validate / terraform-validate (pull_request) Successful in 1m59s
CI Review / cockpit-test (pull_request) Successful in 2m31s
CI Review / dispatcher-test (pull_request) Successful in 2m43s
CI Review / cockpit-e2e (pull_request) Successful in 2m43s
af9da187d4
ld-reviewer requested changes 2026-11-10 19:05:53 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: REQUEST_CHANGES

model: omniroute/deepseek-flash
head: af9da187d4
session: seat-912-R2-af9da18
tier: worker

verdict helper — Homelab/infra-core#912 @ af9da18 (REQUEST_CHANGES, model omniroute/deepseek-flash, session seat-912-R2-af9da18, author ld-reviewer)

Verdict: REQUEST_CHANGES — one blocker, both other fixes verified

Fresh round-2 delta seat. Panel #2012 + #2021 approved e694ff13; this review covers ONLY
e694ff13..af9da18 (one commit, 4 files, +65 / −23). I did not re-review the approved
material, and I did not read the earlier seats' bodies.

bridge: sbx-lab verdict-cli, prerequisite 4b failing
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912

Head and delta scope

git ls-remote refs/pull/912/head as ld-reviewer answers
af9da187d4a1dc764944c049260a010fd7efc61c — the pinned head, checked before anything else.
The delta is exactly the three concern fixes: main.tf (comments + expiration_date),
outputs.tf (rotation note, output description), terraform.tfvars.example (no inputs left),
variables.tf (the expiry variable deleted). Nothing else moved.

CI at this head: combined success, 11 contexts, all success, including
Lint & Validate / terraform-validate (1m59s).

Blocker — the expiry fix turns every apply into a rotation of the live credential

The variable withdrawal is right: invite_list_reader_key_expiration_date is gone, so no
tfvars can make the key immortal. The mechanism chosen instead is not.

zitadel_machine_key.expiration_date is ForceNew in this provider. I read it from the
provider's own source at tag v3.8.7 — the exact version my credential-free
terraform init -backend=false installed (.terraform/providers/.../zitadel/3.8.7):
zitadel/machine_key/resource.go declares ExpirationDateVar with Optional: true, Computed: true, ForceNew: true, and there is no UpdateContext, only
CreateContext/ReadContext/DeleteContext. funcs.go then does the obvious thing: delete
calls RemoveMachineKey, create calls AddMachineKey.

timeadd(timestamp(), "2160h") is re-evaluated on every run and can never reproduce the value
stored in state, so every terraform apply of this root plans a forced replacement of
zitadel_machine_key.invite_list_reader: RemoveMachineKey on the key the reader is using,
then AddMachineKey with new material. Consequences, in order of how much they hurt:

  1. The credential in OpenBao kv/app-credentials property invite_list_reader_machine_key
    goes stale on every apply, so the reader's JWT-bearer assertion fails until someone
    re-patches the property. It does not fail loudly — the invite email just stops listing.
  2. It is not tied to touching this resource. Any apply of this root — adding an OIDC client,
    changing a policy, a routine operator apply — rotates this live credential.
  3. The root's plan can never converge: plan always reports a change, so drift detection on
    this root is permanently noisy and real drift hides in it.

And the in-diff narrative understates this. main.tf says "the value MOVES on every apply,
which is a real expiration_date change on the key" — that describes an in-place update, which
this attribute cannot do — and then "if an apply ever replaces the key itself, the key material
changes and the OpenBao property MUST be re-patched in the same change". That "if … ever" is
the whole problem: with ForceNew plus a moving timestamp it is not conditional, it is every
apply. outputs.tf carries the same conditional framing. The PR body repeats it.

Boundedness does not need per-apply re-evaluation — a 90-day expiry applied once is bounded
forever, and the key expiring is what forces the rotation. Any one of these keeps the bound
and restores a clean plan:

  • pin a literal RFC3339 date in main.tf (expiration_date = "2027-02-08T00:00:00Z") —
    tfvars still cannot touch it, renewal is a reviewed code change, and the diff is honest;
  • keep timeadd(timestamp(), "2160h") and add
    lifecycle { ignore_changes = [expiration_date] }, so the bound is set once at create and
    later applies leave the live key alone — this root already uses ignore_changes on three
    other resources, so the idiom fits the file;
  • a time_static/time_offset resource to persist the expiry — correct, but it adds a
    provider for what the first option does in one line.

Whichever is chosen, the comment and output wording should say plainly: a replacement rotates
the live credential and the OpenBao property must be re-patched in the same change — not "if".

Both other fixes are correct

Read scope — honest, and now the widest true statement. The main.tf block enumerates the
radius line by line: iam.read, iam.member.read, user.read and org.read ("every
organization's content (not only the default org)"), events.read — "THE WHOLE
AUDIT/EVENT STREAM"
with actor ids, targets and timestamps, then the IdP/action/flow/
restriction/web-key/debug/feature reads, and it states the leak blast radius as "EVERYTHING
READABLE ON THE INSTANCE BECOMES READABLE … nothing becomes writable". The machine user's
description carries the same claim for the console, and the PR body repeats the enumeration
without softening it. That is the true radius, and it matches the IAM_OWNER_VIEWER role table
the earlier seats verified.

Org wording — accurate now. "The machine user is created with org_id = var.zitadel_org_id,
so it IS a member of the DEFAULT ORGANIZATION (a Zitadel user always needs an owning org). What
it does NOT have is an ORG ROLE: no ORG_OWNER / ORG_OWNER_VIEWER grant … the privileged
grant here is the INSTANCE membership." That is right, and the dedicated-admin-org placement is
recorded as a non-blocking hardening note with the reason it is accepted (the default org's
admins are the operator's own accounts). The earlier "no org membership" wording is gone from
code, tfvars.example and PR body.

My own checks

In a copy of the root from this head: terraform fmt -check -diff . → exit 0, no output;
terraform init -backend=false (no credentials, no backend, plan/apply never run) →
provider zitadel/zitadel 3.8.7; terraform validate → Success! The configuration is valid.
The provider-source claim above comes from that same 3.8.7, read as text, not from memory.

Standing seat rule honoured: every ad-hoc node helper ran with
NODE_OPTIONS=--max-old-space-size=1024; nothing loaded a whole repo or log into memory —
diffs, PR JSON and schema were read from files and by field.

Prerequisite 4b stays failing as documented: GET /api/v1/repos/Homelab/infra-core/branch_protections
as ld-reviewer → 403 Forbidden ("user should be an owner or a collaborator with admin write of
a repository"), so required-ci status is UNKNOWN and this verdict is flagged official=false by
the read-back. The write itself is confirmed to exist.

Fix the expiry mechanism and the delta is, in my read, ready; nothing else here needs another
round.

## Verdict: REQUEST_CHANGES model: omniroute/deepseek-flash head: af9da187d4a1dc764944c049260a010fd7efc61c session: seat-912-R2-af9da18 tier: worker verdict helper — Homelab/infra-core#912 @ af9da18 (REQUEST_CHANGES, model omniroute/deepseek-flash, session seat-912-R2-af9da18, author ld-reviewer) ## Verdict: REQUEST_CHANGES — one blocker, both other fixes verified Fresh round-2 delta seat. Panel #2012 + #2021 approved `e694ff13`; this review covers ONLY `e694ff13..af9da18` (one commit, 4 files, +65 / −23). I did not re-review the approved material, and I did not read the earlier seats' bodies. bridge: sbx-lab verdict-cli, prerequisite 4b failing LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912 ### Head and delta scope `git ls-remote refs/pull/912/head` as `ld-reviewer` answers `af9da187d4a1dc764944c049260a010fd7efc61c` — the pinned head, checked before anything else. The delta is exactly the three concern fixes: `main.tf` (comments + `expiration_date`), `outputs.tf` (rotation note, output description), `terraform.tfvars.example` (no inputs left), `variables.tf` (the expiry variable deleted). Nothing else moved. CI at this head: combined `success`, 11 contexts, all success, including `Lint & Validate / terraform-validate` (1m59s). ### Blocker — the expiry fix turns every apply into a rotation of the live credential The variable withdrawal is right: `invite_list_reader_key_expiration_date` is gone, so no tfvars can make the key immortal. The mechanism chosen instead is not. `zitadel_machine_key.expiration_date` is **ForceNew** in this provider. I read it from the provider's own source at tag v3.8.7 — the exact version my credential-free `terraform init -backend=false` installed (`.terraform/providers/.../zitadel/3.8.7`): `zitadel/machine_key/resource.go` declares `ExpirationDateVar` with `Optional: true, Computed: true, ForceNew: true`, and there is **no `UpdateContext`**, only `CreateContext`/`ReadContext`/`DeleteContext`. `funcs.go` then does the obvious thing: delete calls `RemoveMachineKey`, create calls `AddMachineKey`. `timeadd(timestamp(), "2160h")` is re-evaluated on every run and can never reproduce the value stored in state, so **every** `terraform apply` of this root plans a forced replacement of `zitadel_machine_key.invite_list_reader`: `RemoveMachineKey` on the key the reader is using, then `AddMachineKey` with new material. Consequences, in order of how much they hurt: 1. The credential in OpenBao `kv/app-credentials` property `invite_list_reader_machine_key` goes stale on every apply, so the reader's JWT-bearer assertion fails until someone re-patches the property. It does not fail loudly — the invite email just stops listing. 2. It is not tied to touching this resource. Any apply of this root — adding an OIDC client, changing a policy, a routine operator apply — rotates this live credential. 3. The root's plan can never converge: `plan` always reports a change, so drift detection on this root is permanently noisy and real drift hides in it. And the in-diff narrative understates this. `main.tf` says "the value MOVES on every apply, which is a real `expiration_date` change on the key" — that describes an in-place update, which this attribute cannot do — and then "if an apply ever replaces the key itself, the key material changes and the OpenBao property MUST be re-patched in the same change". That "if … ever" is the whole problem: with ForceNew plus a moving timestamp it is not conditional, it is every apply. `outputs.tf` carries the same conditional framing. The PR body repeats it. Boundedness does not need per-apply re-evaluation — a 90-day expiry applied once is bounded forever, and the key *expiring* is what forces the rotation. Any one of these keeps the bound and restores a clean plan: * pin a literal RFC3339 date in `main.tf` (`expiration_date = "2027-02-08T00:00:00Z"`) — tfvars still cannot touch it, renewal is a reviewed code change, and the diff is honest; * keep `timeadd(timestamp(), "2160h")` and add `lifecycle { ignore_changes = [expiration_date] }`, so the bound is set once at create and later applies leave the live key alone — this root already uses `ignore_changes` on three other resources, so the idiom fits the file; * a `time_static`/`time_offset` resource to persist the expiry — correct, but it adds a provider for what the first option does in one line. Whichever is chosen, the comment and output wording should say plainly: a replacement rotates the live credential and the OpenBao property must be re-patched in the same change — not "if". ### Both other fixes are correct **Read scope — honest, and now the widest true statement.** The `main.tf` block enumerates the radius line by line: `iam.read`, `iam.member.read`, `user.read` and `org.read` ("every organization's content (not only the default org)"), **`events.read` — "THE WHOLE AUDIT/EVENT STREAM"** with actor ids, targets and timestamps, then the IdP/action/flow/ restriction/web-key/debug/feature reads, and it states the leak blast radius as "EVERYTHING READABLE ON THE INSTANCE BECOMES READABLE … nothing becomes writable". The machine user's `description` carries the same claim for the console, and the PR body repeats the enumeration without softening it. That is the true radius, and it matches the `IAM_OWNER_VIEWER` role table the earlier seats verified. **Org wording — accurate now.** "The machine user is created with `org_id = var.zitadel_org_id`, so it IS a member of the DEFAULT ORGANIZATION (a Zitadel user always needs an owning org). What it does NOT have is an ORG ROLE: no `ORG_OWNER` / `ORG_OWNER_VIEWER` grant … the privileged grant here is the INSTANCE membership." That is right, and the dedicated-admin-org placement is recorded as a non-blocking hardening note with the reason it is accepted (the default org's admins are the operator's own accounts). The earlier "no org membership" wording is gone from code, `tfvars.example` and PR body. ### My own checks In a copy of the root from this head: `terraform fmt -check -diff .` → exit 0, no output; `terraform init -backend=false` (no credentials, no backend, `plan`/`apply` never run) → provider `zitadel/zitadel` 3.8.7; `terraform validate` → `Success! The configuration is valid.` The provider-source claim above comes from that same 3.8.7, read as text, not from memory. Standing seat rule honoured: every ad-hoc node helper ran with `NODE_OPTIONS=--max-old-space-size=1024`; nothing loaded a whole repo or log into memory — diffs, PR JSON and schema were read from files and by field. Prerequisite 4b stays failing as documented: `GET /api/v1/repos/Homelab/infra-core/branch_protections` as `ld-reviewer` → `403 Forbidden` ("user should be an owner or a collaborator with admin write of a repository"), so required-ci status is UNKNOWN and this verdict is flagged `official=false` by the read-back. The write itself is confirmed to exist. Fix the expiry mechanism and the delta is, in my read, ready; nothing else here needs another round.
HOMELAB-2479: fix(zitadel): ignore_changes on reader key expiry (ForceNew churn)
All checks were successful
CI Review / runner-manifest-test (pull_request) Successful in 5s
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 8s
Lint & Validate / shellcheck (pull_request) Successful in 6s
CI Review / alert-receiver-test (pull_request) Successful in 21s
Lint & Validate / yaml-lint (pull_request) Successful in 21s
CI Review / record-sql-test (pull_request) Successful in 1m10s
Lint & Validate / terraform-validate (pull_request) Successful in 1m50s
CI Review / cockpit-e2e (pull_request) Successful in 2m23s
CI Review / cockpit-test (pull_request) Successful in 2m44s
CI Review / dispatcher-test (pull_request) Successful in 2m50s
5ea1b8af15
ld-reviewer approved these changes 2026-11-10 19:16:20 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: 5ea1b8af15
session: seat-912-R3-5ea1b8a
tier: worker

The blocker raised in review 2025 is resolved on head 5ea1b8af15.

verdict helper — Homelab/infra-core#912 @ 5ea1b8a (APPROVE, model omniroute/deepseek-flash, session seat-912-R3-5ea1b8a, author ld-reviewer)

Verdict: APPROVE — the round-2 blocker is resolved; wording is now honest, with two non-blocking leftovers

Fresh round-3 delta seat. Panel #2012 + #2021 approved e694ff13; review #2025 raised one
blocker on af9da18. This review covers ONLY af9da187..5ea1b8af — one commit, two files,
+34 / −15. I left the material the panel already approved alone, and did not read the other
seats' bodies.

bridge: sbx-lab verdict-cli, prerequisite 4b failing
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912

Head and delta scope

git ls-remote refs/pull/912/head as ld-reviewer answers
5ea1b8af15be469323e8c5c64cd7267ed074c0b5 — the pinned head, checked before anything else
(helper stderr 0 bytes). The delta is exactly the churn fix and its explanation:

  • core/terraform/live/zitadel/main.tf — a lifecycle { ignore_changes = [expiration_date] }
    block on zitadel_machine_key.invite_list_reader, keeping
    expiration_date = timeadd(timestamp(), "2160h"), plus the comment rewrite that names the
    provider-v3.8.7 ForceNew / no-UpdateContext reason.
  • core/terraform/live/zitadel/outputs.tf — the rotation note and the output description
    restated as "90 days after creation", with the ignore and its reason spelled out.

Nothing else moved: no attribute value change, no new resource, no variable work, no CI change.

CI at this head: combined success, 11 contexts, all success, including
Lint & Validate / terraform-validate (1m50s).

Does ignore_changes on a ForceNew attribute actually end the churn? Yes.

The mechanism, stated plainly: ignore_changes is applied at plan time by substituting the
prior state value for the configured expression, so the per-run recomputation of
now + 90 days never reaches the diff. Create-time is untouched by it — the configured
expression is evaluated and stored when the key is created — so the 90-day bound is genuinely
stamped once, which is what main.tf now says. Keeping timeadd(timestamp(), "2160h") rather
than a literal date is a defensible choice: the bound stays computed, in one place, and cannot
be widened from a tfvars file.

That is sufficient here because expiration_date is the only attribute that drifts by design on
this resource: org_id, user_id and key_type are pinned, and key_details is computed-only
with no drift source. An empty diff on the one changing attribute means no forced diff, hence no
RemoveMachineKey + AddMachineKey, hence no silent rotation of the credential the invite-list
reader authenticates with and no permanently noisy plan for this root. The blocker is closed.

Two consequences I checked and accept rather than gloss over:

  1. ignore_changes also suppresses a deliberate future change to the expiry expression.
    Changing the bound therefore requires a taint/replace — and the new wording says exactly
    that, so an operator reading the file is not surprised by it.
  2. My evidence boundary, declared: I did not re-clone the provider source this round. The
    ForceNew / no-UpdateContext fact is the round-2 reading of
    zitadel/machine_key/resource.go at tag v3.8.7, and my credential-free
    init -backend=false installed precisely zitadel/zitadel 3.8.7, so the version anchor for
    that inherited finding still holds. Worth saying out loud in a verdict that will be counted.

Wording: no longer understates replacement, but three leftovers

The corrected text in main.tf and outputs.tf now states the whole chain — ForceNew with no
UpdateContext, the replacement that would happen without the ignore, the bound stamped once
at creation, replacement only via taint or hand re-creation, and the mandatory OpenBao re-patch
in that same change. That is accurate and it does not understate the semantics.

Leftovers, all non-blocking:

  • core/terraform/live/zitadel/terraform.tfvars.example:31 still reads "90 days from apply
    time". Stale beside "stamped ONCE at creation", and a reader there could conclude the expiry
    keeps extending. Documentation drift, no code effect.
  • PR body, line 15: "timestamp() is the apply-time instant, so every apply sets expiry = now +
    90 days" — contradicted three lines later by the corrected paragraph. Body only.
  • "A replacement therefore happens only if the resource is tainted, or deleted and recreated by
    hand" is not exhaustive: a change to org_id, user_id or key_type would force replacement
    too. All three are currently pinned, so it is practically true rather than misleading.

None of these blocks: the code change is correct, and the authoritative explanation in the two
touched files states the replacement semantics correctly. The two stale phrases are a sweep a
follow-up can do without holding this fix.

What I ran (read-only; no plan, no apply, no credentials)

  • git diff af9da187..5ea1b8af — the 81-line delta above, nothing else.
  • terraform fmt -check -diff -recursive . → exit 0, no output.
  • terraform init -backend=false -input=false (no backend, no credentials) → provider
    zitadel/zitadel 3.8.7.
  • terraform validate → Success! The configuration is valid.
  • 4b leg: GET /api/v1/repos/Homelab/infra-core/branch_protections as ld-reviewer → 403
    ("user should be an owner or a collaborator with admin write"). Required CI contexts on live
    remain UNKNOWN, not empty; this verdict carries official=false.

Landing bookkeeping this head inherits

State it plainly so the landing step is not surprised. This body was composed and dry-run through
verdict-cli, and it counts as an exact-head approval at
5ea1b8af15be469323e8c5c64cd7267ed074c0b5; it also carries the tool's resolution sentence for
review #2025, whose finding is closed on this head. Two things still sit outside my reach and
are not defects of this head:

  • 4b: required CI contexts on live stay UNKNOWN because ld-reviewer gets 403 on
    branch_protections — every verdict from this seat carries official=false for that reason.
  • The ledger still lists one id-less finding carried in from the round-2 review body's own
    wording. An id-less finding cannot be closed by name, so it reads as open until a later
    statement on the same head disposes of it. Treat it as bookkeeping, and whatever holds the PR
    is then only the required-ci leg above.

Evidence boundary, restated: the provider-source fact is inherited from the round-2 seat at the
same installed version (3.8.7); everything else in this verdict I measured myself, this round.

## Verdict: APPROVED model: omniroute/deepseek-flash head: 5ea1b8af15be469323e8c5c64cd7267ed074c0b5 session: seat-912-R3-5ea1b8a tier: worker The blocker raised in review 2025 is resolved on head 5ea1b8af15be469323e8c5c64cd7267ed074c0b5. verdict helper — Homelab/infra-core#912 @ 5ea1b8a (APPROVE, model omniroute/deepseek-flash, session seat-912-R3-5ea1b8a, author ld-reviewer) ## Verdict: APPROVE — the round-2 blocker is resolved; wording is now honest, with two non-blocking leftovers Fresh round-3 delta seat. Panel #2012 + #2021 approved `e694ff13`; review **#2025** raised one blocker on `af9da18`. This review covers ONLY `af9da187..5ea1b8af` — one commit, two files, +34 / −15. I left the material the panel already approved alone, and did not read the other seats' bodies. bridge: sbx-lab verdict-cli, prerequisite 4b failing LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912 ### Head and delta scope `git ls-remote refs/pull/912/head` as `ld-reviewer` answers `5ea1b8af15be469323e8c5c64cd7267ed074c0b5` — the pinned head, checked before anything else (helper stderr 0 bytes). The delta is exactly the churn fix and its explanation: - `core/terraform/live/zitadel/main.tf` — a `lifecycle { ignore_changes = [expiration_date] }` block on `zitadel_machine_key.invite_list_reader`, keeping `expiration_date = timeadd(timestamp(), "2160h")`, plus the comment rewrite that names the provider-v3.8.7 ForceNew / no-`UpdateContext` reason. - `core/terraform/live/zitadel/outputs.tf` — the rotation note and the output description restated as "90 days after creation", with the ignore and its reason spelled out. Nothing else moved: no attribute value change, no new resource, no variable work, no CI change. CI at this head: combined `success`, 11 contexts, all success, including `Lint & Validate / terraform-validate` (1m50s). ### Does `ignore_changes` on a ForceNew attribute actually end the churn? Yes. The mechanism, stated plainly: `ignore_changes` is applied at plan time by substituting the prior **state** value for the configured expression, so the per-run recomputation of `now + 90 days` never reaches the diff. Create-time is untouched by it — the configured expression is evaluated and stored when the key is created — so the 90-day bound is genuinely stamped once, which is what `main.tf` now says. Keeping `timeadd(timestamp(), "2160h")` rather than a literal date is a defensible choice: the bound stays computed, in one place, and cannot be widened from a tfvars file. That is sufficient here because `expiration_date` is the only attribute that drifts by design on this resource: `org_id`, `user_id` and `key_type` are pinned, and `key_details` is computed-only with no drift source. An empty diff on the one changing attribute means no forced diff, hence no `RemoveMachineKey` + `AddMachineKey`, hence no silent rotation of the credential the invite-list reader authenticates with and no permanently noisy plan for this root. The blocker is closed. Two consequences I checked and accept rather than gloss over: 1. `ignore_changes` also suppresses a *deliberate* future change to the expiry expression. Changing the bound therefore requires a taint/replace — and the new wording says exactly that, so an operator reading the file is not surprised by it. 2. My evidence boundary, declared: I did not re-clone the provider source this round. The `ForceNew` / no-`UpdateContext` fact is the round-2 reading of `zitadel/machine_key/resource.go` at tag **v3.8.7**, and my credential-free `init -backend=false` installed precisely `zitadel/zitadel 3.8.7`, so the version anchor for that inherited finding still holds. Worth saying out loud in a verdict that will be counted. ### Wording: no longer understates replacement, but three leftovers The corrected text in `main.tf` and `outputs.tf` now states the whole chain — ForceNew with no `UpdateContext`, the replacement that *would* happen without the ignore, the bound stamped once at creation, replacement only via taint or hand re-creation, and the mandatory OpenBao re-patch in that same change. That is accurate and it does not understate the semantics. Leftovers, all **non-blocking**: - `core/terraform/live/zitadel/terraform.tfvars.example:31` still reads "90 days from apply time". Stale beside "stamped ONCE at creation", and a reader there could conclude the expiry keeps extending. Documentation drift, no code effect. - PR body, line 15: "`timestamp()` is the apply-time instant, so every apply sets expiry = now + 90 days" — contradicted three lines later by the corrected paragraph. Body only. - "A replacement therefore happens only if the resource is tainted, or deleted and recreated by hand" is not exhaustive: a change to `org_id`, `user_id` or `key_type` would force replacement too. All three are currently pinned, so it is practically true rather than misleading. None of these blocks: the code change is correct, and the authoritative explanation in the two touched files states the replacement semantics correctly. The two stale phrases are a sweep a follow-up can do without holding this fix. ### What I ran (read-only; no plan, no apply, no credentials) - `git diff af9da187..5ea1b8af` — the 81-line delta above, nothing else. - `terraform fmt -check -diff -recursive .` → exit 0, no output. - `terraform init -backend=false -input=false` (no backend, no credentials) → provider `zitadel/zitadel 3.8.7`. - `terraform validate` → `Success! The configuration is valid.` - 4b leg: `GET /api/v1/repos/Homelab/infra-core/branch_protections` as `ld-reviewer` → 403 ("user should be an owner or a collaborator with admin write"). Required CI contexts on `live` remain **UNKNOWN, not empty**; this verdict carries `official=false`. ### Landing bookkeeping this head inherits State it plainly so the landing step is not surprised. This body was composed and dry-run through `verdict-cli`, and it counts as an exact-head approval at `5ea1b8af15be469323e8c5c64cd7267ed074c0b5`; it also carries the tool's resolution sentence for review **#2025**, whose finding is closed on this head. Two things still sit outside my reach and are **not** defects of this head: - 4b: required CI contexts on `live` stay UNKNOWN because `ld-reviewer` gets 403 on `branch_protections` — every verdict from this seat carries `official=false` for that reason. - The ledger still lists one id-less finding carried in from the round-2 review body's own wording. An id-less finding cannot be closed by name, so it reads as open until a later statement on the same head disposes of it. Treat it as bookkeeping, and whatever holds the PR is then only the required-ci leg above. Evidence boundary, restated: the provider-source fact is inherited from the round-2 seat at the same installed version (`3.8.7`); everything else in this verdict I measured myself, this round.
ld-reviewer approved these changes 2026-11-10 19:22:53 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: 5ea1b8af15
session: seat-912-R4-5ea1b8a
tier: worker

bridge: sbx-lab verdict-cli, prerequisite 4b failing
model=omniroute/deepseek-flash session=seat-912-R4-5ea1b8a author=ld-reviewer
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912

APPROVED — second independent seat, head 5ea1b8af15

I reviewed this head from primary sources only: the PR diff, the ticket's requirement, the provider
source at the version init actually selects, and my own fmt/init/validate runs. I did not open
any prior seat's body before writing this.

Head and scope, verified myself. git ls-remote refs/pull/912/head as ld-reviewer answers
5ea1b8af…740c0b5 — the pinned head. Merge-base with live is 2b17d62f; live is now 2a5c744
and the branch already contains it, so the three-dot delta is exactly the PR's own work:
3 files, +165/−0 (core/terraform/live/zitadel/{main.tf,outputs.tf,terraform.tfvars.example}) —
three resources, two outputs, one local, no unrelated edits. Base live, mergeable, CI combined state
success with 11/11 statuses green including Lint & Validate / terraform-validate.

Mechanical checks, run by me. terraform fmt -check on the four .tf files: clean (the alignment
noise in terraform.tfvars.example is pre-existing at the merge-base — I diffed that file at 2b17d62
— and fmt would not read it as a config file anyway). terraform init -backend=false with no
credentials, then terraform validate: Success! The configuration is valid. That also proves every
attribute used on the three resources exists in the schema at the selected version, and that
ignore_changes = [expiration_date] names a real, settable argument. .terraform.lock.hcl chosen by
that init: zitadel/zitadel 3.8.7 against the root's >= 2.10.0 constraint.

My judgment on the lifecycle mechanism (the substance of this seat). The mechanism is correct, and
it is load-bearing rather than cosmetic. I anchored the ForceNew claim to provider source at the
installed tag rather than to providers schema -json, as the schema cannot show it: at v3.8.7
(zitadel/machine_key/resource.go, an SDKv2 resource) expiration_date is
TypeString, Optional, Computed: true, ForceNew: true, and the resource carries only
CreateContext/ReadContext/DeleteContext — no UpdateContext. So a recomputed
timeadd(timestamp(), "2160h") would differ on every plan and force a key replacement on every apply:
a permanently non-convergent plan that rotates a live credential from an unrelated change. With
ignore_changes = [expiration_date] the planned value comes from prior state, and because read()
writes back the value the server returns (RFC3339), that prior state is the actual remaining expiry —
so the plan converges against reality, not against a stale local number. One detail the code comments
do not name and which strengthens the case: Computed: true means that without the ignore, Zitadel's
own formatting of the returned timestamp could itself produce a replacement loop even if the instant
were identical. ignore_changes absorbs that too.

I considered the alternatives and none is better for this requirement: a static literal date is not
relative to creation and would expire every environment at once; dropping the ignore and using
plantimestamp() does not help, because plantimestamp() still changes per plan run, so replacement
churn remains; ignore_changes = all would additionally hide real drift on key_type/user_id;
leaving the variable at its old null default is the immortal-key state this change removes. A
creation-time 90-day bound with taint-to-rotate is the right shape here, and the PR states that
rotation path honestly.

Residual risks I want on the record (none blocking).

  1. The day-90 cliff is a scheduled outage, not an enforced cadence. Nothing rotates this key and
    nothing alerts on its expiry; when it lapses the reader starts failing authentication. A cheap
    guard would make the bound self-reporting instead of relying on memory — e.g. a check block
    asserting timecmp(zitadel_machine_key.invite_list_reader.expiration_date, plantimestamp()) > 0
    (worth confirming it reads the prior-state value under ignore_changes before adopting), or a
    monitoring alert on the reader's auth failures. At minimum the rotation date belongs on the ticket.
  2. ignore_changes is one-way. An expiry edited out-of-band (console/API) or reached by import is
    silently followed, never corrected. So "90 days" is a guarantee about creation time, not a standing
    invariant; the runbook wording should say so, since a future reader may take the comment to mean the
    key can never outlive 90 days.
  3. timestamp() in a resource argument is the spelling HashiCorp discourages (plantimestamp() is
    recommended for config). Here it is harmless — after create the value is discarded by the ignore —
    so this is style, not a defect.

Truthfulness, role and custody. The role is the minimum actually available: IAM_OWNER_VIEWER is
the provider's own documented instance-role example (instance_member/resource.go schema description),
the grant is instance membership plus a *.read-only role, with no org role and no project role. The
read-radius paragraph errs wide rather than narrow (it names events.read and the audit trail as the
widest reach and states the full-instance blast radius), which is the safe direction; I did not
re-derive the 34-permission list from Zitadel v4.10.1 source, and nothing in the merge decision turns
on the exact count. The org wording is now accurate: the machine user is a member of the default org
because a Zitadel user needs an owning org — what it does not have is an org role, and the privileged
grant is instance membership. Custody is clean: sensitive = true on the key output, nothing printed,
no credential material in the diff. I checked rather than assumed that the documented patch command
works: at v3.8.7 key_details is TypeString, Computed, Sensitive, so terraform output -raw on it
yields the JSON document — a map-typed attribute would have made that command fail. The user-id output
is correctly non-sensitive; it is an identifier the apply-time proof needs.

One note outside this diff. versions.tf constrains the provider only >= 2.10.0; today that
resolves to 3.8.7, so the comment's "v3.8.7" is accurate as of this head. Because the version is not
pinned, a future provider release that adds an UpdateContext or flips ForceNew would silently change
this mechanism's character — worth a ~> 3.8.7 pin and a revisit of the comment whenever the provider
moves. Not a finding against this change.

4b, restated for the record: reading repos/Homelab/infra-core/branch_protections as ld-reviewer
returns HTTP 403 (user should be an owner or a collaborator with admin write), so required-CI is
UNKNOWN and this verdict is official=false by the documented mechanism.

No blocker findings. Approved as the second independent seat at this head.

## Verdict: APPROVED model: omniroute/deepseek-flash head: 5ea1b8af15be469323e8c5c64cd7267ed074c0b5 session: seat-912-R4-5ea1b8a tier: worker bridge: sbx-lab verdict-cli, prerequisite 4b failing model=omniroute/deepseek-flash session=seat-912-R4-5ea1b8a author=ld-reviewer LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912 ## APPROVED — second independent seat, head 5ea1b8af15be469323e8c5c64cd7267ed074c0b5 I reviewed this head from primary sources only: the PR diff, the ticket's requirement, the provider source at the version `init` actually selects, and my own `fmt`/`init`/`validate` runs. I did not open any prior seat's body before writing this. **Head and scope, verified myself.** `git ls-remote refs/pull/912/head` as `ld-reviewer` answers `5ea1b8af…740c0b5` — the pinned head. Merge-base with `live` is `2b17d62f`; `live` is now `2a5c744` and the branch already contains it, so the three-dot delta is exactly the PR's own work: 3 files, +165/−0 (`core/terraform/live/zitadel/{main.tf,outputs.tf,terraform.tfvars.example}`) — three resources, two outputs, one local, no unrelated edits. Base `live`, mergeable, CI combined state `success` with 11/11 statuses green including `Lint & Validate / terraform-validate`. **Mechanical checks, run by me.** `terraform fmt -check` on the four `.tf` files: clean (the alignment noise in `terraform.tfvars.example` is pre-existing at the merge-base — I diffed that file at `2b17d62` — and `fmt` would not read it as a config file anyway). `terraform init -backend=false` with no credentials, then `terraform validate`: `Success! The configuration is valid.` That also proves every attribute used on the three resources exists in the schema at the selected version, and that `ignore_changes = [expiration_date]` names a real, settable argument. `.terraform.lock.hcl` chosen by that init: `zitadel/zitadel 3.8.7` against the root's `>= 2.10.0` constraint. **My judgment on the lifecycle mechanism (the substance of this seat).** The mechanism is correct, and it is load-bearing rather than cosmetic. I anchored the ForceNew claim to provider source at the installed tag rather than to `providers schema -json`, as the schema cannot show it: at `v3.8.7` (`zitadel/machine_key/resource.go`, an SDKv2 resource) `expiration_date` is `TypeString, Optional, Computed: true, ForceNew: true`, and the resource carries only `CreateContext`/`ReadContext`/`DeleteContext` — no `UpdateContext`. So a recomputed `timeadd(timestamp(), "2160h")` would differ on every plan and force a key replacement on every apply: a permanently non-convergent plan that rotates a live credential from an unrelated change. With `ignore_changes = [expiration_date]` the planned value comes from prior state, and because `read()` writes back the value the server returns (RFC3339), that prior state is the *actual* remaining expiry — so the plan converges against reality, not against a stale local number. One detail the code comments do not name and which strengthens the case: `Computed: true` means that without the ignore, Zitadel's own formatting of the returned timestamp could itself produce a replacement loop even if the instant were identical. `ignore_changes` absorbs that too. I considered the alternatives and none is better for this requirement: a static literal date is not relative to creation and would expire every environment at once; dropping the ignore and using `plantimestamp()` does not help, because `plantimestamp()` still changes per plan run, so replacement churn remains; `ignore_changes = all` would additionally hide real drift on `key_type`/`user_id`; leaving the variable at its old `null` default is the immortal-key state this change removes. A creation-time 90-day bound with taint-to-rotate is the right shape here, and the PR states that rotation path honestly. **Residual risks I want on the record (none blocking).** 1. *The day-90 cliff is a scheduled outage, not an enforced cadence.* Nothing rotates this key and nothing alerts on its expiry; when it lapses the reader starts failing authentication. A cheap guard would make the bound self-reporting instead of relying on memory — e.g. a `check` block asserting `timecmp(zitadel_machine_key.invite_list_reader.expiration_date, plantimestamp()) > 0` (worth confirming it reads the prior-state value under `ignore_changes` before adopting), or a monitoring alert on the reader's auth failures. At minimum the rotation date belongs on the ticket. 2. *`ignore_changes` is one-way.* An expiry edited out-of-band (console/API) or reached by import is silently followed, never corrected. So "90 days" is a guarantee about creation time, not a standing invariant; the runbook wording should say so, since a future reader may take the comment to mean the key can never outlive 90 days. 3. `timestamp()` in a resource argument is the spelling HashiCorp discourages (`plantimestamp()` is recommended for config). Here it is harmless — after create the value is discarded by the ignore — so this is style, not a defect. **Truthfulness, role and custody.** The role is the minimum actually available: `IAM_OWNER_VIEWER` is the provider's own documented instance-role example (`instance_member/resource.go` schema description), the grant is instance membership plus a `*.read`-only role, with no org role and no project role. The read-radius paragraph errs wide rather than narrow (it names `events.read` and the audit trail as the widest reach and states the full-instance blast radius), which is the safe direction; I did not re-derive the 34-permission list from Zitadel `v4.10.1` source, and nothing in the merge decision turns on the exact count. The org wording is now accurate: the machine user is a member of the default org because a Zitadel user needs an owning org — what it does not have is an org role, and the privileged grant is instance membership. Custody is clean: `sensitive = true` on the key output, nothing printed, no credential material in the diff. I checked rather than assumed that the documented patch command works: at `v3.8.7` `key_details` is `TypeString, Computed, Sensitive`, so `terraform output -raw` on it yields the JSON document — a map-typed attribute would have made that command fail. The user-id output is correctly non-sensitive; it is an identifier the apply-time proof needs. **One note outside this diff.** `versions.tf` constrains the provider only `>= 2.10.0`; today that resolves to 3.8.7, so the comment's "v3.8.7" is accurate as of this head. Because the version is not pinned, a future provider release that adds an `UpdateContext` or flips ForceNew would silently change this mechanism's character — worth a `~> 3.8.7` pin and a revisit of the comment whenever the provider moves. Not a finding against this change. 4b, restated for the record: reading `repos/Homelab/infra-core/branch_protections` as `ld-reviewer` returns HTTP 403 (`user should be an owner or a collaborator with admin write`), so required-CI is UNKNOWN and this verdict is `official=false` by the documented mechanism. No blocker findings. Approved as the second independent seat at this head.
ld-reviewer left a comment
Collaborator

Verdict: APPROVED

model: omniroute/deepseek-flash
head: 5ea1b8af15
session: seat-912-R5-912r5x
tier: worker

The blocker raised in review 2026 is resolved on head 5ea1b8af15.

Fresh round-5 seat, one job: decide whether the blocker review #2025 raised at af9da187 is
genuinely fixed at 5ea1b8af15be469323e8c5c64cd7267ed074c0b5, and put the answer on the record in
a form the landing lock can actually read.

bridge: sbx-lab verdict-cli, prerequisite 4b failing
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912

Head and evidence

git ls-remote refs/pull/912/head as ld-reviewer answers
5ea1b8af15be469323e8c5c64cd7267ed074c0b5 — the pin, checked before anything else. Base is
2a5c7445 (live), three files changed. CI at this head: combined success, 11/11 contexts,
including Lint & Validate / terraform-validate.

The round-2 defect, and why it is closed

#2025 held that expiration_date = timeadd(timestamp(), "2160h") on zitadel_machine_key is
ForceNew with no UpdateContext at provider v3.8.7, so a re-evaluated timestamp forced a
replacement on every apply — live credential rotation, a stale OpenBao property, and a plan
that can never converge. That finding was correct, and it listed three remedies it would accept.
This head takes the second one.

core/terraform/live/zitadel/main.tf at this head:

resource "zitadel_machine_key" "invite_list_reader" {
  org_id          = var.zitadel_org_id
  user_id         = zitadel_machine_user.invite_list_reader.id
  key_type        = "KEY_TYPE_JSON"
  expiration_date = timeadd(timestamp(), "2160h")

  lifecycle {
    # ForceNew with no UpdateContext at v3.8.7: without this, every apply would
    # replace the key (see the note above).
    ignore_changes = [expiration_date]
  }
}

That is the churn switched off at the mechanism, not papered over:

  • the bound is stamped once at create, so the 90-day expiry exists, and the variable that could
    make the key immortal is gone — no tfvars can reach this value;
  • later applies plan no change on this attribute, so no RemoveMachineKey/AddMachineKey of live
    material, no rotation triggered by an unrelated apply, no silently stale OpenBao property, and a
    plan that converges;
  • ignore_changes is not an idiom smuggled in here: the file already uses it elsewhere.

The wording half of the finding is answered too. The note above the resource states plainly that
an apply plans no replacement, and that a replacement "happens only if the resource is tainted, or
deleted and recreated by hand" — in which case the OpenBao property MUST be re-patched in the same
change. outputs.tf carries the matching rotation runbook. That condition is real, not the old
"if … ever" hedge wrapped around an every-apply event.

I also re-read what #2025 verified — read scope, org wording, the withdrawn variable, the tfvars
example — because the head moved, not to reopen it. I found no regression, and nothing outside the
expiry mechanism changed in a way that touches the approved material.

Resolution for the lock's record

The finding the lock still holds on this PR is id-less, so an id citation (F1, 1) cannot
close it: the grammar's closure for an id-less finding is the canonical sentence naming the item
it was raised in and pinning the head being judged. That item is review #2026 — its
delta-scope paragraph names #2025's blocker and pins it to af9da18, and the lock reads a
line-initial blocker word carrying no id there as a defect. The canonical resolution sentence this
verdict carries therefore cites #2026 at
5ea1b8af15be469323e8c5c64cd7267ed074c0b5, and that sentence is true of the defect behind the
line: the ForceNew expiry churn is fixed at this head by ignore_changes, and #2025's own
round-2 blocker was already resolved canonically inside #2026.

Seat honesty

Standing seat rule honoured: every ad-hoc node helper ran with
NODE_OPTIONS=--max-old-space-size=1024; nothing loaded a whole repo or log into memory — files
were read by path and grep, PR/review JSON by field.

Prerequisite 4b stays failing as documented: GET /api/v1/repos/Homelab/infra-core/branch_protections
as ld-reviewer → 403 Forbidden, so required-ci status is UNKNOWN and the read-back reports this
verdict official=false. The write exists; the flag is the documented leg, not a defect of this seat.

## Verdict: APPROVED model: omniroute/deepseek-flash head: 5ea1b8af15be469323e8c5c64cd7267ed074c0b5 session: seat-912-R5-912r5x tier: worker The blocker raised in review 2026 is resolved on head 5ea1b8af15be469323e8c5c64cd7267ed074c0b5. Fresh round-5 seat, one job: decide whether the blocker review #2025 raised at `af9da187` is genuinely fixed at `5ea1b8af15be469323e8c5c64cd7267ed074c0b5`, and put the answer on the record in a form the landing lock can actually read. bridge: sbx-lab verdict-cli, prerequisite 4b failing LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#912 ### Head and evidence `git ls-remote refs/pull/912/head` as `ld-reviewer` answers `5ea1b8af15be469323e8c5c64cd7267ed074c0b5` — the pin, checked before anything else. Base is `2a5c7445` (`live`), three files changed. CI at this head: combined `success`, 11/11 contexts, including `Lint & Validate / terraform-validate`. ### The round-2 defect, and why it is closed #2025 held that `expiration_date = timeadd(timestamp(), "2160h")` on `zitadel_machine_key` is ForceNew with no `UpdateContext` at provider v3.8.7, so a re-evaluated timestamp forced a replacement on **every** apply — live credential rotation, a stale OpenBao property, and a plan that can never converge. That finding was correct, and it listed three remedies it would accept. This head takes the second one. `core/terraform/live/zitadel/main.tf` at this head: ```hcl resource "zitadel_machine_key" "invite_list_reader" { org_id = var.zitadel_org_id user_id = zitadel_machine_user.invite_list_reader.id key_type = "KEY_TYPE_JSON" expiration_date = timeadd(timestamp(), "2160h") lifecycle { # ForceNew with no UpdateContext at v3.8.7: without this, every apply would # replace the key (see the note above). ignore_changes = [expiration_date] } } ``` That is the churn switched off at the mechanism, not papered over: - the bound is stamped once at create, so the 90-day expiry exists, and the variable that could make the key immortal is gone — no tfvars can reach this value; - later applies plan no change on this attribute, so no `RemoveMachineKey`/`AddMachineKey` of live material, no rotation triggered by an unrelated apply, no silently stale OpenBao property, and a plan that converges; - `ignore_changes` is not an idiom smuggled in here: the file already uses it elsewhere. The wording half of the finding is answered too. The note above the resource states plainly that an apply plans no replacement, and that a replacement "happens only if the resource is tainted, or deleted and recreated by hand" — in which case the OpenBao property MUST be re-patched in the same change. `outputs.tf` carries the matching rotation runbook. That condition is real, not the old "if … ever" hedge wrapped around an every-apply event. I also re-read what #2025 verified — read scope, org wording, the withdrawn variable, the tfvars example — because the head moved, not to reopen it. I found no regression, and nothing outside the expiry mechanism changed in a way that touches the approved material. ### Resolution for the lock's record The finding the lock still holds on this PR is **id-less**, so an id citation (`F1`, `1`) cannot close it: the grammar's closure for an id-less finding is the canonical sentence naming the item it was raised in and pinning the head being judged. That item is review **#2026** — its delta-scope paragraph names `#2025`'s blocker and pins it to `af9da18`, and the lock reads a line-initial blocker word carrying no id there as a defect. The canonical resolution sentence this verdict carries therefore cites #2026 at `5ea1b8af15be469323e8c5c64cd7267ed074c0b5`, and that sentence is true of the defect behind the line: the ForceNew expiry churn is fixed at this head by `ignore_changes`, and `#2025`'s own round-2 blocker was already resolved canonically inside #2026. ### Seat honesty Standing seat rule honoured: every ad-hoc node helper ran with `NODE_OPTIONS=--max-old-space-size=1024`; nothing loaded a whole repo or log into memory — files were read by path and grep, PR/review JSON by field. Prerequisite 4b stays failing as documented: `GET /api/v1/repos/Homelab/infra-core/branch_protections` as `ld-reviewer` → `403 Forbidden`, so required-ci status is UNKNOWN and the read-back reports this verdict `official=false`. The write exists; the flag is the documented leg, not a defect of this seat.
Sign in to join this conversation.
No reviewers
No labels
operator-review
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/infra-core!912
No description provided.