Skip to content

feat(unified-identity): link existing GitHub and Dex principals to ZITADEL identities explicitly #435

Closed

Description

@mashkovd

Parent roadmap: mctlhq/.github#91

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.

Depends on

Completion

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.

Activity

  1. mashkovd commented on Oct 4, 2026

    @mashkovd
    MemberAuthor

    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)

    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).

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    live on another principal Q duplicate, see section 4
    P already has a different live zitadel identity refused 409 principal_already_linked (unlink first)

    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)

    1. 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).
    2. Browser flow: start, callback, confirm, cookie binding, metrics and audit. Tests: fixation (wrong cookie), replay (used challenge), expiry, nonce mismatch, aud of another client.
    3. Admin endpoints (revoke, restore, conflicts, merge) and MCP tools.
    4. 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.

  2. mashkovd commented on Oct 4, 2026

    @mashkovd
    MemberAuthor

    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_secret plus 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.

  3. mashkovd commented on Oct 10, 2026

    @mashkovd
    MemberAuthor

    Done; closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions