HOMELAB-2479: feat(zitadel): invite-list-reader machine user (IAM_OWNER_VIEWER) #912
No reviewers
Labels
No labels
operator-review
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/infra-core!912
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2479-invite-list-reader"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds the process that builds the invite-email recipient list: a dedicated
Zitadel machine user, instance membership with
IAM_OWNER_VIEWER, and amachine 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
expiration_datewasvar.invite_list_reader_key_expiration_datewith anulldefault — anunbounded credential that no tfvars ever set. It is now
expiration_date = timeadd(timestamp(), "2160h").timestamp()is theapply-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_dateForceNew with noUpdateContext(read from the provider source, notschema -json—which reports
force_new=falsefor every SDKv2 attribute), so arecomputed 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 happensonly if the resource is tainted, or deleted and recreated — and that
event requires re-patching the OpenBao property in the same change.
An earlier draft called this "the instance's user directory". It is not.
IAM_OWNER_VIEWERreads the whole instance:events.read(the entireaudit/event stream, which carries actor ids, targets and timestamps),
user.readandorg.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.readandiam.member.read. Themain.tfblock now lists thatpermission set line by line so a reader of the code sees the true radius, and
the machine user's
descriptionsays the same so the console does too.default organization: it is created with
org_id = var.zitadel_org_id, and aZitadel user always needs an owning org. What it does not have is an org
role — no
ORG_OWNER/ORG_OWNER_VIEWERgrant — so org-admin power is notimplied; 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_OWNERcan 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 consolelogin (a machine user has no password). No narrower shipped role exists —
Zitadel has no
IAM_USER_VIEWER(upstreamzitadel/zitadel#12812is openprecisely 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/zitadelv3.8.7 — schema checked, not guessed)zitadel_machine_user.invite_list_readeruser_name = "invite-list-reader"(exact, pinned in alocalso no tfvars can drift a look-alike principal),name = "Invite List Reader",org_id = var.zitadel_org_id; nowith_secret, noaccess_token_typezitadel_instance_member.invite_list_readeruser_id = <machine user>,roles = ["IAM_OWNER_VIEWER"]zitadel_machine_key.invite_list_readerkey_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 keyResource names and attribute schemas were read from the installed provider
(
terraform init -backend=false→terraform providers schema -json), not frommemory:
zitadel_machine_user,zitadel_instance_member,zitadel_machine_keyall exist at v3.8.7 and carry exactly these arguments.
Sensitive-key custody
output "invite_list_reader_machine_key"issensitive = 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.kv/app-credentialspropertyinvite_list_reader_machine_keyvia ONE pi-agent patch(
bao kv patch, notkv put— a bare put replaces the shared object anddrops every other app's credentials). The pi-agent AppRole can write only that
object (
prodterraform/openbao.tf, policyagent-pi).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 anidentifier 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 threeresources + the scope and org-placement notes),
outputs.tf(two outputs + thecustody note),
terraform.tfvars.example(notes that nothing here is an input).variables.tfis untouched — the expiry variable was withdrawn. No k8s secret,no
null_resourceprovisioner, no backend or provider change, no other roottouched.
Verify
(
init -backend=false, no credentials; the S3 backend was never initialised andplan/applywere not run.)Fix round 2 (
5ea1b8af):fmt -check -diff .→ exit 0, no diff;validate(init -backend=false, no credentials, no plan) → Success! Theconfiguration is valid. The
lifecycleblock is accepted byvalidateatprovider v3.8.7.
Seat
Two DeepSeek seats APPROVED on
e694ff13. Review #2025 onaf9da187returned REQUEST_CHANGES with one blocker:
expiration_dateis ForceNewwith no
UpdateContextat provider v3.8.7, so the recomputedtimeadd(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 rootalready 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.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
e694ff133esession: 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, theticket's prose and any earlier verdict were treated as claims to check, not as
evidence. The head was confirmed first, as
ld-reviewer, withgit ls-remoteonrefs/pull/912/headthrough the mandatory helper's own credential shim;FORGEJO_API_KEYwas 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) — onelocalspin (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 afterzitadel_user_grant .additional_project, before the app-side OIDC section. Nothing already in thefile is edited.
outputs.tf(+35) —invite_list_reader_machine_key(sensitive = true) andinvite_list_reader_user_id(deliberately not sensitive), preceded by thecustody comment.
variables.tf(+7) andterraform.tfvars.example(+7) — one optionalvariable,
invite_list_reader_key_expiration_date, defaultnull.Base == merge-base ==
livetip2b17d62f, so the two-dot and three-dot diffsagree: 4 files, none outside
core/terraform/live/zitadel/, no workflow, nodeploy 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 thehead's zitadel root resolved zitadel/zitadel v3.8.7 — the constraint is
>= 2.10.0and the lockfile is gitignored in this repo, so init is what pins theversion. I then read the schema out of the installed provider with
terraform providers schema -json:zitadel_machine_user—user_nameandnameare REQUIRED;with_secretand
access_token_typeoptional. The PR setsuser_name = local.invite_list_reader_user_name, whose value is the literalinvite-list-reader, and omits both secret options. The provider's ownattribute doc says
with_secretgenerates a machine secret "only applicable ifcreation 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 exactlyuser_id,roles,id: there is noorg_id, and the resource description reads "membershipof a user on an instance, defined with the given role". The
rolesdoc 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_memberused just above foradmin_iamcarriesorg_idand takesORG_*roles, while this resource cannot be org-scoped — which is exactly whatmakes the listing instance-wide instead of org-local.
zitadel_machine_key—user_idandkey_typeREQUIRED;key_detailsiscomputedand flagged SENSITIVE at the schema level;expiration_dateisoptional + computed, so anulldefault takes Zitadel's own value and cannotproduce a perpetual diff.
key_typeacceptsKEY_TYPE_JSON("supportedvalues: KEY_TYPE_UNSPECIFIED, KEY_TYPE_JSON") — the JSON document
(keyId + key + userId) the JWT-bearer grant consumes.
IAM_OWNER_VIEWERis 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_detailsis computed + sensitive in the provider, so no plan, apply output orterraform outputprints it by accident, and the output layered on it issensitive = trueas well. Reading all 104 added lines, there is no keymaterial, 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 patchcommand inside a comment. The route is stated once and is the estate'sestablished one: one
bao kv patchinto the sharedkv/app-credentialsobject asthe
pi-agentAppRole, patch rather than put so the other apps' propertiessurvive. I checked that claim where it is checkable from here: the
agent-pipolicy in
prodterraform/openbao.tfgrantscreate/read/updateonkv/data/app-credentialspluskv/metadata/app-credentialsread (and tokenrenew-self) and nothing else, so that principal really can write only that one
object; and
memory/operations/openbao-pi-agent-access.md(HOMELAB-1485) recordsthe same
VAULT_TOKEN=… bao kv patch kv/app-credentialsinvocation as workingpractice.
invite_list_reader_user_idis correctly not marked sensitive: an idis 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
localsblocks in one file is legal HCL and
validateaccepts it. The grant isinstance-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 -diffover the head's zitadel root: exit 0, no diff.terraform init -backend=falsethenterraform validate: "Success! Theconfiguration is valid." No credentials, no backend, nothing applied.
CI at the head
commits/e694ff13…/statusanswersstate: successwith 11 contexts —Lint & Validate / terraform-validate,yaml-lint,shellcheck, and the eightCI 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_requestruns against this sha (ci.yaml 13227, lint.yaml 13228) bothreport
success. Read with theld-reviewercredential, notFORGEJO_API_KEY.4b
repos/Homelab/infra-core/branch_protectionsasld-revieweranswers 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=falseon 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 nostatus_check_contexts, so the named setstays unknown. The verdict below is pinned to the exact head regardless.
Non-blocking notes
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_datedefaults tonull, 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.
invite-list-readerusername would make the apply fail (Zitadelrefuses 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:
e694ff133esession: 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 providerschema from a credential-free
terraform init -backend=false, and Zitadel v4.10.1's ownshipped 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/headasld-revieweranswerse694ff133ef76e11b059d4531c040c5694f1ad56— 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 ownchanged_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:
livecarriescommit
5a663a5("prefer prod-wk-02 for the seat sandbox") oncore/manifests/apps/sbx-lab/statefulset.yamlafter this branch's merge-base. That is notpart 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, includingLint & Validate / terraform-validate(2m10s) andCI Review / helm-validate.I re-ran the PR's own verification myself, in a credential-free copy of the root:
terraform init -backend=false→ providerzitadel/zitadel v3.8.7(matching the body'sclaim),
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_VIEWERthe 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
InternalAuthZrole table (cmd/defaults.yamlat 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. OnlyIAM_OWNER_VIEWERis read-only andinstance-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_CLIENTis not asmaller alternative (it is the login UI's role, and its permission set exists to finalize auth
requests). The provider has no
zitadel_instance_roleresource (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, anduser.write,user.delete,iam.member.writeandsession.deleteare 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_VIEWERdoes 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, whichincludes 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 = nullmean hereThe provider schema (read from the installed v3.8.7, not from memory) marks
zitadel_machine_key.expiration_dateOptional + Computed. That is the precise meaning ofthe 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_idvar.zitadel_org_idis the root's "Zitadel default organization" (variables.tf) — the orgthat 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:
ORG_*role is granted to this user, so being in that org adds no permission of its own.main.tf— "instance membership plusIAM_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_idis exactly what chooses it. The reader is a member ofthe default org.
ORG_OWNER/ORG_USER_MANAGERin that org holduser.writeover its users, which is the permission that mints a machine key. So anorganization 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 operatorand 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_detailsis referenced only from an output markedsensitive = true, and the non-sensitive output is the user id, which is an identifier theapply-time proof needs. The single-write path is stated (
bao kv patch, neverkv put, oneobject 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.tffloatszitadel >= 2.10.0and no.terraform.lock.hclis committed, so theprovider version is resolved at init time (3.8.7 today). Pre-existing, worth a lock file
before an apply is trusted to be reproducible.
(
AddMachineUser,AddMachineKey, …) deprecated in favour of the v2 user API. They stillserve, 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: REQUEST_CHANGES
model: omniroute/deepseek-flash
head:
af9da187d4session: 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 ONLYe694ff13..af9da18(one commit, 4 files, +65 / −23). I did not re-review the approvedmaterial, 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/headasld-revieweranswersaf9da187d4a1dc764944c049260a010fd7efc61c— 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, includingLint & 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_dateis gone, so notfvars can make the key immortal. The mechanism chosen instead is not.
zitadel_machine_key.expiration_dateis ForceNew in this provider. I read it from theprovider's own source at tag v3.8.7 — the exact version my credential-free
terraform init -backend=falseinstalled (.terraform/providers/.../zitadel/3.8.7):zitadel/machine_key/resource.godeclaresExpirationDateVarwithOptional: true, Computed: true, ForceNew: true, and there is noUpdateContext, onlyCreateContext/ReadContext/DeleteContext.funcs.gothen does the obvious thing: deletecalls
RemoveMachineKey, create callsAddMachineKey.timeadd(timestamp(), "2160h")is re-evaluated on every run and can never reproduce the valuestored in state, so every
terraform applyof this root plans a forced replacement ofzitadel_machine_key.invite_list_reader:RemoveMachineKeyon the key the reader is using,then
AddMachineKeywith new material. Consequences, in order of how much they hurt:kv/app-credentialspropertyinvite_list_reader_machine_keygoes 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.
changing a policy, a routine operator apply — rotates this live credential.
planalways reports a change, so drift detection onthis root is permanently noisy and real drift hides in it.
And the in-diff narrative understates this.
main.tfsays "the value MOVES on every apply,which is a real
expiration_datechange on the key" — that describes an in-place update, whichthis 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.tfcarries 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:
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;
timeadd(timestamp(), "2160h")and addlifecycle { ignore_changes = [expiration_date] }, so the bound is set once at create andlater applies leave the live key alone — this root already uses
ignore_changeson threeother resources, so the idiom fits the file;
time_static/time_offsetresource to persist the expiry — correct, but it adds aprovider 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.tfblock enumerates theradius line by line:
iam.read,iam.member.read,user.readandorg.read("everyorganization's content (not only the default org)"),
events.read— "THE WHOLEAUDIT/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
descriptioncarries the same claim for the console, and the PR body repeats the enumerationwithout softening it. That is the true radius, and it matches the
IAM_OWNER_VIEWERrole tablethe 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_VIEWERgrant … the privilegedgrant 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.exampleand 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/applynever run) →provider
zitadel/zitadel3.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_protectionsas
ld-reviewer→403 Forbidden("user should be an owner or a collaborator with admin write ofa repository"), so required-ci status is UNKNOWN and this verdict is flagged
official=falsebythe 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: APPROVED
model: omniroute/deepseek-flash
head:
5ea1b8af15session: 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 oneblocker on
af9da18. This review covers ONLYaf9da187..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/headasld-revieweranswers5ea1b8af15be469323e8c5c64cd7267ed074c0b5— 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— alifecycle { ignore_changes = [expiration_date] }block on
zitadel_machine_key.invite_list_reader, keepingexpiration_date = timeadd(timestamp(), "2160h"), plus the comment rewrite that names theprovider-v3.8.7 ForceNew / no-
UpdateContextreason.core/terraform/live/zitadel/outputs.tf— the rotation note and the output descriptionrestated 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, includingLint & Validate / terraform-validate(1m50s).Does
ignore_changeson a ForceNew attribute actually end the churn? Yes.The mechanism, stated plainly:
ignore_changesis applied at plan time by substituting theprior state value for the configured expression, so the per-run recomputation of
now + 90 daysnever reaches the diff. Create-time is untouched by it — the configuredexpression is evaluated and stored when the key is created — so the 90-day bound is genuinely
stamped once, which is what
main.tfnow says. Keepingtimeadd(timestamp(), "2160h")ratherthan 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_dateis the only attribute that drifts by design onthis resource:
org_id,user_idandkey_typeare pinned, andkey_detailsis computed-onlywith 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-listreader authenticates with and no permanently noisy plan for this root. The blocker is closed.
Two consequences I checked and accept rather than gloss over:
ignore_changesalso 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.
ForceNew/ no-UpdateContextfact is the round-2 reading ofzitadel/machine_key/resource.goat tag v3.8.7, and my credential-freeinit -backend=falseinstalled preciselyzitadel/zitadel 3.8.7, so the version anchor forthat 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.tfandoutputs.tfnow states the whole chain — ForceNew with noUpdateContext, the replacement that would happen without the ignore, the bound stamped onceat 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:31still reads "90 days from applytime". Stale beside "stamped ONCE at creation", and a reader there could conclude the expiry
keeps extending. Documentation drift, no code effect.
timestamp()is the apply-time instant, so every apply sets expiry = now +90 days" — contradicted three lines later by the corrected paragraph. Body only.
hand" is not exhaustive: a change to
org_id,user_idorkey_typewould force replacementtoo. 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) → providerzitadel/zitadel 3.8.7.terraform validate→Success! The configuration is valid.GET /api/v1/repos/Homelab/infra-core/branch_protectionsasld-reviewer→ 403("user should be an owner or a collaborator with admin write"). Required CI contexts on
liveremain 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 at5ea1b8af15be469323e8c5c64cd7267ed074c0b5; it also carries the tool's resolution sentence forreview #2025, whose finding is closed on this head. Two things still sit outside my reach and
are not defects of this head:
livestay UNKNOWN becauseld-reviewergets 403 onbranch_protections— every verdict from this seat carriesofficial=falsefor that reason.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:
5ea1b8af15session: 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
5ea1b8af15I reviewed this head from primary sources only: the PR diff, the ticket's requirement, the provider
source at the version
initactually selects, and my ownfmt/init/validateruns. I did not openany prior seat's body before writing this.
Head and scope, verified myself.
git ls-remote refs/pull/912/headasld-revieweranswers5ea1b8af…740c0b5— the pinned head. Merge-base withliveis2b17d62f;liveis now2a5c744and 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 statesuccesswith 11/11 statuses green includingLint & Validate / terraform-validate.Mechanical checks, run by me.
terraform fmt -checkon the four.tffiles: clean (the alignmentnoise in
terraform.tfvars.exampleis pre-existing at the merge-base — I diffed that file at2b17d62— and
fmtwould not read it as a config file anyway).terraform init -backend=falsewith nocredentials, then
terraform validate:Success! The configuration is valid.That also proves everyattribute 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.hclchosen bythat init:
zitadel/zitadel 3.8.7against the root's>= 2.10.0constraint.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: atv3.8.7(
zitadel/machine_key/resource.go, an SDKv2 resource)expiration_dateisTypeString, Optional, Computed: true, ForceNew: true, and the resource carries onlyCreateContext/ReadContext/DeleteContext— noUpdateContext. So a recomputedtimeadd(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 becauseread()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: truemeans that without the ignore, Zitadel'sown formatting of the returned timestamp could itself produce a replacement loop even if the instant
were identical.
ignore_changesabsorbs 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, becauseplantimestamp()still changes per plan run, so replacementchurn remains;
ignore_changes = allwould additionally hide real drift onkey_type/user_id;leaving the variable at its old
nulldefault is the immortal-key state this change removes. Acreation-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).
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
checkblockasserting
timecmp(zitadel_machine_key.invite_list_reader.expiration_date, plantimestamp()) > 0(worth confirming it reads the prior-state value under
ignore_changesbefore adopting), or amonitoring alert on the reader's auth failures. At minimum the rotation date belongs on the ticket.
ignore_changesis one-way. An expiry edited out-of-band (console/API) or reached by import issilently 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.
timestamp()in a resource argument is the spelling HashiCorp discourages (plantimestamp()isrecommended 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_VIEWERisthe provider's own documented instance-role example (
instance_member/resource.goschema description),the grant is instance membership plus a
*.read-only role, with no org role and no project role. Theread-radius paragraph errs wide rather than narrow (it names
events.readand the audit trail as thewidest 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.1source, and nothing in the merge decision turnson 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 = trueon 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.7key_detailsisTypeString, Computed, Sensitive, soterraform output -rawon ityields 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.tfconstrains the provider only>= 2.10.0; today thatresolves 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
UpdateContextor flips ForceNew would silently changethis mechanism's character — worth a
~> 3.8.7pin and a revisit of the comment whenever the providermoves. Not a finding against this change.
4b, restated for the record: reading
repos/Homelab/infra-core/branch_protectionsasld-reviewerreturns HTTP 403 (
user should be an owner or a collaborator with admin write), so required-CI isUNKNOWN and this verdict is
official=falseby the documented mechanism.No blocker findings. Approved as the second independent seat at this head.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
5ea1b8af15session: 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
af9da187isgenuinely fixed at
5ea1b8af15be469323e8c5c64cd7267ed074c0b5, and put the answer on the record ina 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/headasld-revieweranswers5ea1b8af15be469323e8c5c64cd7267ed074c0b5— the pin, checked before anything else. Base is2a5c7445(live), three files changed. CI at this head: combinedsuccess, 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")onzitadel_machine_keyisForceNew with no
UpdateContextat provider v3.8.7, so a re-evaluated timestamp forced areplacement 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.tfat this head:That is the churn switched off at the mechanism, not papered over:
make the key immortal is gone — no tfvars can reach this value;
RemoveMachineKey/AddMachineKeyof livematerial, no rotation triggered by an unrelated apply, no silently stale OpenBao property, and a
plan that converges;
ignore_changesis 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.tfcarries 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) cannotclose 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 toaf9da18, and the lock reads aline-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 theline: the ForceNew expiry churn is fixed at this head by
ignore_changes, and#2025's ownround-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 — fileswere read by path and grep, PR/review JSON by field.
Prerequisite 4b stays failing as documented:
GET /api/v1/repos/Homelab/infra-core/branch_protectionsas
ld-reviewer→403 Forbidden, so required-ci status is UNKNOWN and the read-back reports thisverdict
official=false. The write exists; the flag is the documented leg, not a defect of this seat.