You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(unified-identity): link existing GitHub and Dex principals to ZITADEL identities explicitly #435
Epic MCTL Unified Identity and Agent Identity Broker — manifest roadmap/epics/unified-identity.yaml, work item zitadel-identity-linking, phase migration.
Epic goal
Decouple MCTL principals from GitHub identity by introducing stable canonical identities for humans, agents and services, with linked external identities, swappable federation, CIMD-aware MCP OAuth and delegated agent identity.
Decision context
On 2026-10-03 the owner chose self-hosted ZITADEL as the MCTL human identity provider (mctlhq/.github#157). It enters as one more OIDC provider in the mctl-api federation registry (MCTL_OIDC_PROVIDERS, mctl-api#374 slice A, docs/federation.md), not as a broker in front of every request: static service tokens, local MCP OAuth JWTs and GitHub PATs stay verified by mctl-api itself. It replaces Argo CD's embedded Dex (ops.mctl.ai/api/dex) as the source of human sign-in.
Scope of this work item
Give every existing human principal (today keyed on GitHub numeric id or Dex (iss, sub)) a way to gain a ZITADEL identity on the same principal, through an explicit, verified flow: the user proves both identities in one session (signed in with the existing one, then completes ZITADEL sign-in), and the link is recorded with verified_at and audited. Per the #373 decision (2026-09-24) linking never happens by matching usernames or emails, and an unknown ZITADEL identity still provisions a new principal. Define unlinking/revocation and what an operator sees for duplicates. Tenant membership moving off GitHub logins (#377) depends on this.
This issue binds the work item so the roadmap control plane (ready.py, health.py, the mctl-api wave planner) can observe it. Refine the scope on this issue; the manifest carries the dependency graph, not the prose.
Required by the epic's allRequired rule. Closing this issue as completed marks zitadel-identity-linking done; closing it as not planned or duplicate keeps the epic incomplete by design.
Status: design only, not implemented. It needs a user-facing ZITADEL client (an owner decision) and two policy choices (marked Decision) before code.
Starting point (as of 4.59.0)
external_identities already has the shape this needs: UNIQUE(provider, issuer, subject), plus verified_at and revoked_at (internal/principals/store.go, docs/principals.md).
1. Flow: both identities proven in one browser session
Everything is server-side in mctl-api, with no token pasting. It reuses the GitHub browser flow mctl-api already runs for its OAuth server (/oauth/github/callback).
GET /identity/link/zitadel (browser). mctl-api starts its existing GitHub sign-in, which proves identity ci: Bump docker/setup-buildx-action from 3 to 4 #1 and resolves it to principal P. A Dex principal proves itself the same way through the Dex client mctl-api, whose redirect https://api.mctl.ai/oauth2/callback is already registered.
mctl-api writes a row in identity_link_challenges: id, principal_id=P, proven_by (the xid_ that proved ci: Bump docker/setup-buildx-action from 3 to 4 #1), state, nonce, PKCE verifier, expires_at = now+10m, used_at. It sets an httpOnly, Secure, SameSite=Lax cookie holding an HMAC of the challenge id, then redirects to ZITADEL's authorize endpoint with state, nonce and PKCE.
GET /identity/link/zitadel/callback. It requires all of: matching state, the cookie of the same browser, an unexpired and unused challenge. mctl-api exchanges the code (confidential client, PKCE) and verifies the ID token through the existing zitadel federation verifier (issuer, signature, enforced aud). It also checks nonce and auth_time within the challenge window. It reads only (iss, sub), never e-mail.
It shows a confirmation page: "Link ZITADEL user <preferred_username> to principal prn_… (GitHub <login>)". The link is written on POST, with a CSRF token tied to the same cookie.
The cookie binding stops link fixation, where an attacker sends a victim the authorize URL of the attacker's own challenge so the victim's ZITADEL identity lands on the attacker's principal. A challenge started by API call with a bearer token could not be bound to the browser, so start is browser-only by design.
2. Recording the link
It is one transaction, holding an advisory lock on (zitadel, iss, sub) and on P:
Existing state of (zitadel, iss, sub)
Result
absent
insert on P, verified_at=now → linked
live on P
refresh verified_at → already_linked (idempotent)
revoked on P
refused. Revocation was a decision, same rule as Provision. Only an operator re-enables it.
Each outcome writes an audit event identity.link with actor_principal_id=P, both identities (xids, providers, subjects) and the result. It also increments identity_links_total{provider="zitadel",result}. On success, the resolver cache entry for the ZITADEL identity is dropped (single replica today; otherwise the 5-minute TTL bounds it).
3. Unlinking and revocation
Self-service: DELETE /api/v1/identity/links/{xid}, authenticated as P through any live identity of P. It sets revoked_at and is audited as identity.unlink. A principal cannot unlink its last live identity (409 last_identity), so nobody locks themselves out.
Operator: POST /api/v1/admin/identities/{xid}/revoke and /restore, both admin-only and audited. Restore is the only path back for a revoked identity.
A revoked ZITADEL identity is then refused (401), as today. It never silently becomes a new principal.
4. Duplicates (the case operators will hit)
Someone signs in with ZITADEL before linking, gets auto-provisioned principal Q, and then tries to link that identity to their existing P.
Decision A (recommended): refuse in self-service. Return 409 identity_belongs_to_other_principal, audit it, and increment identity_link_conflicts_total. An operator resolves it with POST /api/v1/admin/principals/{Q}/merge-into/{P}, which is allowed only when Q is human, every live identity of Q is a zitadel identity, and the operator confirms. The merge moves the identity rows to P, sets Q disabled (never deleted, because audit rows reference it), and writes principal.merged with both ids. Alternative B: let self-service move the identity when Q holds that one identity and nothing else. That is less operator work, but a principal changes owner without review.
What operators see: GET /api/v1/admin/principals?conflicts=true lists the refused-link events still open (Q, P, when, by whom). Plus the metric. Duplicates are not computed by comparing names or e-mails (#373), only from refused link attempts.
5. Prerequisites outside this repo
Decision B (owner): a user-facing ZITADEL client. It would be OIDC web, confidential, code + PKCE, redirect https://api.mctl.ai/identity/link/zitadel/callback, in project MCTL API, declared in mctl-gitops zitadel-iac, with its client id and secret written into the pre-created Secret the same way as #1564. In the same project, its tokens carry the API audience. That is acceptable only because mctl-api itself is the relying party and the tokens never reach a user agent.
A cookie/HMAC key: a new Vault property in the mctl-api ExternalSecret. Do not reuse OAUTH_JWT_SECRET.
ZITADEL_LINK_CLIENT_ID / _SECRET env, read optionally. Without them the routes answer 503 and nothing else changes.
6. Slices (once A and B are decided)
Schema and store: identity_link_challenges, LinkIdentity / UnlinkIdentity / RevokeIdentity / RestoreIdentity with the table above as tests, including concurrency (two links of one sub at once yield one row).
Browser flow: start, callback, confirm, cookie binding, metrics and audit. Tests: fixation (wrong cookie), replay (used challenge), expiry, nonce mismatch, aud of another client.
Admin endpoints (revoke, restore, conflicts, merge) and MCP tools.
mctl-gitops: the ZITADEL client and Secret wiring, then enablement as a canary on identity_links_total.
#377 (authorization on principals) can start once slice 2 is live and the admins have linked.
Owner decisions on the two open questions from the design above, recorded 2026-10-04:
(A) Duplicate policy. Self-service linking refuses when the ZITADEL identity already belongs to another principal, and the response explains why and what to do: 409 identity_belongs_to_other_principal, audited and counted. Merging two principals is a separate, audited admin operation. There is no automatic merge: not in self-service, and not for a principal that holds only that one identity.
(B) Client. A confidential web application in the ZITADEL project MCTL API. It authenticates with client_secretplus PKCE, and its redirect URIs point only to api.mctl.ai. The client secret reaches mctl-api through the S4 pattern: a Secret pre-created in mctl-api, which the zitadel-iac Job fills and the Deployment reads optionally, as in mctl-gitops#1562/#1564.
Sequencing: implementation starts only after the mctl CLI PKCE canary on #434 is proven both ways, meaning a real call accepted and a wrong-aud token rejected.
Activity
mashkovd commentedon Oct 4, 2026
Proposed design: explicit ZITADEL linking (#435)
Status: design only, not implemented. It needs a user-facing ZITADEL client (an owner decision) and two policy choices (marked Decision) before code.
Starting point (as of 4.59.0)
external_identitiesalready has the shape this needs: UNIQUE(provider,issuer,subject), plusverified_atandrevoked_at(internal/principals/store.go,docs/principals.md).('zitadel', 'https://auth.mctl.ai', sub). Per feat(unified-identity): define canonical human, agent and service principals plus linked external identities #373, an unknown one is auto-provisioned as a new principal, and nothing ever links by username or e-mail. This proposal keeps both rules.MCTL APIholds only an API application (the audience). No ZITADEL client exists yet that can sign a user in to mctl-api.1. Flow: both identities proven in one browser session
Everything is server-side in mctl-api, with no token pasting. It reuses the GitHub browser flow mctl-api already runs for its OAuth server (
/oauth/github/callback).GET /identity/link/zitadel(browser). mctl-api starts its existing GitHub sign-in, which proves identity ci: Bump docker/setup-buildx-action from 3 to 4 #1 and resolves it to principal P. A Dex principal proves itself the same way through the Dex clientmctl-api, whose redirecthttps://api.mctl.ai/oauth2/callbackis already registered.identity_link_challenges:id,principal_id=P,proven_by(thexid_that proved ci: Bump docker/setup-buildx-action from 3 to 4 #1),state,nonce, PKCE verifier,expires_at = now+10m,used_at. It sets an httpOnly, Secure, SameSite=Lax cookie holding an HMAC of the challenge id, then redirects to ZITADEL's authorize endpoint withstate,nonceand PKCE.GET /identity/link/zitadel/callback. It requires all of: matchingstate, the cookie of the same browser, an unexpired and unused challenge. mctl-api exchanges the code (confidential client, PKCE) and verifies the ID token through the existingzitadelfederation verifier (issuer, signature, enforcedaud). It also checksnonceandauth_timewithin the challenge window. It reads only (iss,sub), never e-mail.<preferred_username>to principalprn_…(GitHub<login>)". The link is written on POST, with a CSRF token tied to the same cookie.The cookie binding stops link fixation, where an attacker sends a victim the authorize URL of the attacker's own challenge so the victim's ZITADEL identity lands on the attacker's principal. A challenge started by API call with a bearer token could not be bound to the browser, so start is browser-only by design.
2. Recording the link
It is one transaction, holding an advisory lock on (
zitadel, iss, sub) and on P:verified_at=now→linkedverified_at→already_linked(idempotent)Provision. Only an operator re-enables it.zitadelidentity409 principal_already_linked(unlink first)Each outcome writes an audit event
identity.linkwithactor_principal_id=P, both identities (xids, providers, subjects) and the result. It also incrementsidentity_links_total{provider="zitadel",result}. On success, the resolver cache entry for the ZITADEL identity is dropped (single replica today; otherwise the 5-minute TTL bounds it).3. Unlinking and revocation
DELETE /api/v1/identity/links/{xid}, authenticated as P through any live identity of P. It setsrevoked_atand is audited asidentity.unlink. A principal cannot unlink its last live identity (409 last_identity), so nobody locks themselves out.POST /api/v1/admin/identities/{xid}/revokeand/restore, both admin-only and audited. Restore is the only path back for a revoked identity.4. Duplicates (the case operators will hit)
Someone signs in with ZITADEL before linking, gets auto-provisioned principal Q, and then tries to link that identity to their existing P.
Decision A (recommended): refuse in self-service. Return
409 identity_belongs_to_other_principal, audit it, and incrementidentity_link_conflicts_total. An operator resolves it withPOST /api/v1/admin/principals/{Q}/merge-into/{P}, which is allowed only when Q is human, every live identity of Q is azitadelidentity, and the operator confirms. The merge moves the identity rows to P, sets Qdisabled(never deleted, because audit rows reference it), and writesprincipal.mergedwith both ids. Alternative B: let self-service move the identity when Q holds that one identity and nothing else. That is less operator work, but a principal changes owner without review.What operators see:
GET /api/v1/admin/principals?conflicts=truelists the refused-link events still open (Q, P, when, by whom). Plus the metric. Duplicates are not computed by comparing names or e-mails (#373), only from refused link attempts.5. Prerequisites outside this repo
https://api.mctl.ai/identity/link/zitadel/callback, in projectMCTL API, declared in mctl-gitopszitadel-iac, with its client id and secret written into the pre-created Secret the same way as #1564. In the same project, its tokens carry the API audience. That is acceptable only because mctl-api itself is the relying party and the tokens never reach a user agent.OAUTH_JWT_SECRET.ZITADEL_LINK_CLIENT_ID/_SECRETenv, read optionally. Without them the routes answer 503 and nothing else changes.6. Slices (once A and B are decided)
identity_link_challenges,LinkIdentity/UnlinkIdentity/RevokeIdentity/RestoreIdentitywith the table above as tests, including concurrency (two links of one sub at once yield one row).audof another client.identity_links_total.#377 (authorization on principals) can start once slice 2 is live and the admins have linked.
mashkovd commentedon Oct 4, 2026
Owner decisions on the two open questions from the design above, recorded 2026-10-04:
409 identity_belongs_to_other_principal, audited and counted. Merging two principals is a separate, audited admin operation. There is no automatic merge: not in self-service, and not for a principal that holds only that one identity.MCTL API. It authenticates withclient_secretplus PKCE, and its redirect URIs point only toapi.mctl.ai. The client secret reaches mctl-api through the S4 pattern: a Secret pre-created inmctl-api, which the zitadel-iac Job fills and the Deployment reads optionally, as in mctl-gitops#1562/#1564.Sequencing: implementation starts only after the mctl CLI PKCE canary on #434 is proven both ways, meaning a real call accepted and a wrong-aud token rejected.
mashkovd commentedon Oct 10, 2026
Done; closing.
/identity/link/zitadel, feat(principals): explicit ZITADEL identity linking and admin merge #446/feat(helm): optional zitadelLinkSecret for the identity link client #447) has been live since mctl-api 4.63.1, under the owner decisions above: refusal with409 identity_belongs_to_other_principal, and merges only as an audited admin operation.OAUTH_UPSTREAM=zitadel(feat(mctl-api): sign in through ZITADEL only (OAUTH_UPSTREAM=zitadel) mctl-gitops#1694),/oauth/authorizeresolves a ZITADEL sign-in through its linked principal, so tokens keep the same login and tenant groups as before. The Dex verifier is gone (feat(auth): no default Dex issuer; unset DEX_ISSUER_URL turns Dex off #521).