HOMELAB-2479: fix(zitadel): match user config to state (Nuila; zero-drift users) #916
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!916
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2479-zitadel-user-drift"
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?
Config only. Makes the
additional_usersobject able to carry the profile fieldsZitadel already holds, so a plan stops rewriting those users to the proto
defaults. No apply, no plan run, no landing — nothing is merged or applied
without a later go.
Recorded drift (ground truth — status line
904 ROUTE-I PLAN-GATE-FAIL, 2026-11-10T12:24:45Z)The targeted-apply plan gate for #904 recorded pre-existing drift on two human
users (direction: state → planned, i.e. the code is what would change them):
zitadel_human_user.additional["enuila1989@gmail.com"]: genderMALE→UNSPECIFIED, last_nameNuila→Unuila, nick_nameenuila→null, preferred_languageen→undzitadel_human_user.additional["william@reynoza.org"]: genderMALE→UNSPECIFIED, nick_namewdiaz→nullAaron, verbatim (via El Director, 14:57:11Z): "Manuel es Nuila." The code's
Unuilais the typo.Cause:
variable "additional_users"declared onlyemail,first_name,last_name,roles, so the resource could not expressgender,nick_nameorpreferred_languageand the provider rewrote them to the defaults on the nextapply.
What this PR changes (2 files, both in
core/terraform/live/zitadel/)variables.tf— three optional attributes on theadditional_usersobject type:genderoptional(string)GENDER_UNSPECIFIED|GENDER_FEMALE|GENDER_MALE|GENDER_DIVERSEnick_nameoptional(string)preferred_languageoptional(string)enmain.tf—zitadel_human_user.additionalpasses them through(
gender/nick_name/preferred_language). All arenullby default,which is exactly what an omitted attribute meant before, so no user gains a diff
from this PR alone.
Attribute names and the gender enum were read from the installed provider
(
terraform init -backend=false→terraform providers schema -json,zitadel/zitadelv3.8.7 — the version this root resolves today), not frommemory. Nothing is hand-spelled:
terraform fmt -checkclean,terraform validate→Success! The configuration is valid.No credentials, no backend, nostate access.
HONEST SCOPE NOTE — this PR alone does NOT reach 0-diff
zitadel_human_user.additionaltakes its values fromadditional_users, whichis set in
prodzitadel/terraform.tfvars— the file the plan/applyworkflow passes as
-var-file(.forgejo/workflows/infra-apply.yaml:-var-file="$GITHUB_WORKSPACE/zitadel/terraform.tfvars", workspace root = theprod checkout; the tfvars is gitignored in infra-core). The six values therefore
cannot be set from infra-core; they are a companion prod-tfvars change:
(plus the
# HOMELAB-2191: Manuel Unuilacomment →Nuila). Merge order matters:this schema must be on
livebefore prod's tfvars carries the new keys, or thenext plan fails with "Unsupported argument".
Flagged, not improvised: no prod file is touched by this PR.
No terraform run
No apply, no plan, no state read — no credentials are named for a plan proof;
the plan proof awaits El Director naming. Verification here is by inspection
against the recorded 12:24Z plan output plus
fmt/validate.Seats: 2. NO landing without go.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
f308cdb23dsession: seat-916-A-a91c
tier: worker
bridge: sbx-lab verdict-cli, prerequisite 4b failing
model=omniroute/deepseek-flash session=seat-916-A-a91c author=ld-reviewer
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#916
Independent review — seat 916 (fresh context, own measurement)
Verdict: APPROVE at head
f308cdb23d3a395306dfa3ce72ff72e597d7000d.model: omniroute/deepseek-flash
Head
git ls-remoteonrefs/pull/916/headasld-reviewer, through the credentialhelper the mandated refs helper writes (its sha256
f795d0fd…is byte-identicalin my sandbox, stderr empty), returned
f308cdb2…— the pin I was given.refs/heads/liveis96f2f658…, which is the PR's base. I read 0 reviews on thePR when I started, so nothing in this verdict is inherited.
Exactly the two files the body claims, and nothing else
The PR's own diff is two files, +19 / −0:
variables.tf+12,main.tf+7, bothunder
core/terraform/live/zitadel/.I did not stop at the hunk list. I fetched both files whole at the head and at the
base and diffed them:
variables.tfdiffers by exactly those 12 added lines andversions.tfis byte-identical (614 bytes at both shas), so no provider block, norequired_providers constraint and no other root moved.
main.tf's only change inthe changed region is the seven added lines inside
zitadel_human_user.additional.One thing a reader should not misread, and I checked it rather than assumed: the
head is behind live (merge-base
2b17d62, live tip96f2f6…), so a naivehead-versus-live-tip diff reads as a 114-line deletion of the
invite-list-readermachine-user block. That block lives onliveand not on thebranch; it is not part of this PR. I fetched both refs into a scratch clone and
ran the actual no-commit three-way merge: "Automatic merge went well", 0
conflicts, the block still present, and the merged
main.tfdiffers from live byexactly the 6 non-blank added lines (3 comment + 3 attribute) with
variables.tf= live + 12 lines. So landing this is +19 lines on live and deletes nobody's work.
A rebase before landing is tidiness, not a fix.
Attribute names, defaults and the enum, from provider SOURCE at the tag
Read at
zitadel/terraform-provider-zitadelv3.8.7, not from memory:zitadel/human_user/const.go:nickNameVar = "nick_name",preferredLanguageVar = "preferred_language",genderVar = "gender"— thethree names in the diff, spelled exactly as the provider spells them.
resource.go:genderis Optional withDefault: defaultGenderStringand aValidateDiagFuncoveruser.Gender_value;preferred_languageis Optionalwith
Default: defaultPreferredLanguage;nick_nameis Optional with nodefault.
const.go:defaultGenderString = "GENDER_UNSPECIFIED",defaultPreferredLanguage = "und"— precisely the two values the 12:24Z driftrecord shows the plan writing (
MALE→UNSPECIFIED,en→und). The record and theprovider agree, which is what makes the mechanism in the body credible.
variables.tfcomment is right: Zitadel's protoGenderisGENDER_UNSPECIFIED = 0,GENDER_FEMALE = 1,GENDER_MALE = 2,GENDER_DIVERSE = 3— all four, no hand-spelling.No force-new, and I do not cite
schema -jsonfor it (that report saysforce_new=falsefor every SDKv2 attribute).resource.goputs noForceNewonany of the three, and
update()gates ond.HasChanges(first_name, last_name, nick_name, display_name, preferred_language, gender)and then sends only the changed profile fields through
UpdateUser. Setting avalue therefore updates the human user in place — no replacement, so no
destroy/create of a user and no cascade onto its
zitadel_user_grant.Null costs nobody a diff — and the PR still does not reach 0-diff alone
optional(string)with no default yields null. Forgenderandpreferred_language, null takes the provider's own default, which is exactly whatan omitted attribute meant before — that is why the 12:24Z plan wanted to rewrite
them, and it is why this PR adds no new diff anywhere. For
nick_name, null is"", also identical to omission.So the body's "no user gains a diff from this PR alone" holds, and so does its
honest scope note: for a user whose live
nick_nameis non-null and whose tfvarsdoes not set it, the plan still wants to clear it until the companion prod tfvars
change lands. The direction is the one required here — code carries the live
value, state is not rewritten by this PR, and nothing in it changes a live profile
on its own.
Claim honesty and reproducibility
Every claim the body makes that I can measure without credentials survives: two
files, the scope note about
prodzitadel/terraform.tfvars, the merge-orderwarning ("Unsupported argument" if the tfvars lands first), no plan, no apply, no
state read.
terraform validateis genuinely reproducible credential-free here:CI's
Lint & Validate / terraform-validatejob runsterraform init -backend=falseand
terraform validateacross everycore/terraformdirectory, and it is greenat this head (11 contexts, all success).
terraform fmt -checkis not CI-enforcedand my sandbox has no terraform binary, so I checked the new attribute lines by
inspection instead: they align as their own group against
preferred_language,the lines above them are untouched, and I see no formatting churn — non-blocking,
and I am not asserting the author's own fmt run.
What would still be wrong if merged today
Nothing in this PR. The one residual exposure is outside it and correctly flagged
in the body: the schema must be on
livebefore prod's tfvars carries the threenew keys, and the six values themselves are that companion change. Whoever lands
this should not treat it as the fix, only as the precondition for it.
Notes to orchestrator, posted nowhere:
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
f308cdb23dsession: seat-916-B-b2c7
tier: worker
Independent dangerous-class seat B review of Homelab/infra-core#916, measured from primary sources only (PR diff at the pinned head, the root's own files, and
zitadel/terraform-provider-zitadelsource at the tag the root resolves). This is my own measurement, formed on primary sources before seeing the earlier seat's verdict.What I verified, and how
Head.
git ls-remote <forgejo>/Homelab/infra-core.git refs/pull/916/headasld-reviewer(mandated credential-helper route) returnedf308cdb23d3a395306dfa3ce72ff72e597d7000d, equal to the API'shead.sha; baserefs/heads/live=96f2f65865c68cefaec6731135f405f63b5e2e56, equal to the PR'sbase.sha. The verdict is pinned to that 40-hex head.Diff hygiene.
pulls/916/fileslists exactly two paths, both undercore/terraform/live/zitadel/:main.tf(+7),variables.tf(+12), 0 deletions, 19 additions total. No provider/versions.tfedit, no lockfile, no other root, no resource block rewritten — the three added arguments are the only resource-level change, appended insideresource "zitadel_human_user" "additional".Provider truth at the installed version (not from memory, not from
schema -jsonfor ForceNew).terraform init -backend=falseon the head tree resolveszitadel/zitadel v3.8.7— so the body's version claim is true today, measured, not asserted. At tagv3.8.7,zitadel/human_user/const.godeclaresgenderVar = "gender",nickNameVar = "nick_name",preferredLanguageVar = "preferred_language", andresource.gocarries one schema entry per name. Thegendervalidator isEnumValueValidation(..., user.Gender_value)and the generated doc for that tag listsGENDER_UNSPECIFIED, GENDER_FEMALE, GENDER_MALE, GENDER_DIVERSE— the four spellings thevariables.tfcomment names. Names and enum match the installed provider exactly.ForceNew, anchored to source. None of the three attributes carries
ForceNewinresource.goat v3.8.7 (theForceNewfields there areuser_id,login_names,preferred_login_name,idp_links,metadata). So carrying these values later cannot destroy-and-recreate a human user; the companion prod tfvars change will update profiles in place.Convergence, anchored to source.
update()gates ond.HasChanges(first_name, last_name, nick_name, display_name, preferred_language, gender)and sendsNickName,Genderand the preferred language for exactly those three fields;readFuncwrites them back to state (profile.NickName,profile.PreferredLanguage,profile.Gender.String()). Values set in the companion PR will therefore converge instead of producing a permanent diff.Default-null no-diff property.
optional(string)with no explicit default yieldsnull. Forgenderandpreferred_languagethe schema carriesDefault: "GENDER_UNSPECIFIED"/Default: "und", andnick_namehas no default (zero value). Anullin config for an SDKv2 attribute with aDefaultresolves to that default, which is the same value an omitted attribute produced before this PR;nullfornick_nameis the same as the omission that the 12:24Z plan recorded asenuila -> null. So users that never set these keys gain no diff from this PR: the change is capability only, and I agree with the direction — it makes code able to carry what Zitadel already holds, and it changes no live profile.Corroboration of the drift record. The two defaults above are exactly
GENDER_UNSPECIFIEDandund, i.e. the planned side of the recordedMALE->UNSPECIFIEDanden->unddrift, andnick_namehas no default, i.e. the recorded->null.last_name Nuila->Unuilais untouched by this diff becauselast_namewas already in the type; that half of the record is the prod-tfvars companion's job, as the body says.Blast radius inside the root.
additional_usersis consumed by exactly twofor_eachexpressions at the head: the human-user resource (which is what gains the fields) andzitadel_user_grant.additional_project, which reads onlyeach.value.rolesand is unaffected by three extra object attributes.outputs.tfdoes not expose the variable andterraform.tfvars.examplestill declares[].fmt / validate, reproduced without credentials.
terraform fmt -check -diffon the head files: clean, exit 0.terraform init -backend=false+terraform validateon the head tree with the real v3.8.7 provider:Success! The configuration is valid.— an independently run check, not a restatement of the body. CI at this exact head is green on all 11 statuses, includingLint & Validate / terraform-validate(which runsterraform init -backend=falsethenterraform validateacross everycore/terraformdirectory).Claim honesty. Body vs diff agree: two files, three attributes, additions only; the scope note (values live in prod
zitadel/terraform.tfvars, companion PR queued, schema must land first orUnsupported argument) is accurate and is why this PR alone does not reach 0-diff — stated plainly rather than hidden. No plan/apply/state claim is made and none is needed.Blockers
None.
Notes (non-blocking, not this PR's scope)
.terraform.lock.hcl(404 at the root, atcore/terraform/live/, and at the repo root) and constrains the provider as>= 2.10.0. "v3.8.7" is therefore today's resolution, not a pinned fact; a future 3.x major could tighten validation under the same config. Worth pinning separately.human_userresource is described as using the user/v2 API and requiring ZITADEL 4.x. The resource already plans against the live instance today (the 12:24Z gate read those two users), so this is not a delta introduced here — noted for whoever tracks the provider bump.bridge: sbx-lab verdict-cli, prerequisite 4b failing
model=omniroute/deepseek-flash session=seat-916-B-b2c7 author=ld-reviewer
LD_SESSION_TAG proj=sbx-lab;role=seat;sid=infra-core#916
Notes to orchestrator, posted nowhere: (1) 4b leg re-measured on this head:
ld-reviewerGET/repos/Homelab/infra-core/branch_protectionsreturns 403 "user should be an owner or a collaborator with admin write of a repository" — required-ci stays UNKNOWN and the formal-review write itself is the documented route. (2) This seat brief's Verdict paragraph says to declaresession: seat-916-A-<yoursuffix>while its own seat line saysseat-916-B-<suffix>; that is a copy of seat A's brief. I declared B (this seat) — an A-line from a second independent seat would have collided with seat A's session and weakened the dangerous-class independence the lock reads. (3) The two decisions the companion prod PR must get right are the values themselves (last_name = "Nuila",gender = "GENDER_MALE",nick_name,preferred_language = "en"for enuila1989;gender/nick_namefor william) and merge order — schema onlivefirst, otherwise the next plan dies withUnsupported argument. (4) I ranfmt/validate/init -backend=falselocally against the head tree with no credentials and no backend; nothing in the repo was written.