Repository navigation
feat(api): sign in a ZITADEL-only principal (#377 PR-B 2b) #600
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
edfe66d
69a77dd
c90fe4f
d58da55
901fa08
ebdbeb5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -182,17 +182,19 @@ read-only (`Store.LinkedGitHub`): | |
| | Lookup | Answer | | ||
| |---|---| | ||
| | linked to P, P has one live GitHub identity with a login | code for that login and P (`pid`): the same code, groups and admin decision (`ADMIN_USERS`) a GitHub sign-in of P yields; in `principal` mode no groups are minted | | ||
| | no row, or a principal of its own with no GitHub identity | 403 page linking to `/identity/link/zitadel`; nothing issued or provisioned | | ||
| | no row, or a principal of its own with no GitHub identity | 403 page linking to `/identity/link/zitadel`; nothing issued or provisioned. In `principal` mode: a code for the identity's own principal (provisioned on first sign-in), `sub` = `pid` = its principal id, with no groups; it reaches a tenant only once a GitOps member entry names that `principalId` | | ||
| | identity revoked, or P disabled | 403 | | ||
| | P's GitHub login unknown (taken by another account) or two live GitHub identities | 409, nothing guessed | | ||
| | P's GitHub login unknown (taken by another account) or two live GitHub identities | 409, nothing guessed. In `principal` mode: a code for P with no login, as in the row above | | ||
| | store error, or no principal store | 503, never "not linked" | | ||
| | (`principal` mode) the principal resolver answers a different principal than the link store | 503 `store_unavailable`; nothing issued | | ||
|
|
||
| A pending authorization records its upstream: a GitHub callback cannot | ||
| complete a ZITADEL authorization or the reverse, and an authorization whose | ||
| upstream the current mode does not allow (one pending across a rollback) | ||
| gets a 400. Sign-ins are counted in | ||
| `oauth_upstream_signins_total{upstream,result}`. For `upstream="zitadel"`, | ||
| `result` is `issued`, `unlinked`, `refused`, `store_unavailable`, | ||
| `principal_refused`, `principal_unavailable` (`principal` mode, as for GitHub), | ||
| `invalid_token`, `upstream_error` (ZITADEL redirected back with an error), | ||
| `upstream_unreachable` (discovery failed), `exchange_failed`, | ||
| `bad_request` or `internal_error`, so "ZITADEL is down" and "mctl is | ||
|
|
@@ -348,6 +350,13 @@ caller's authorization; it never merges with the legacy one: | |
| an unresolved principal is 503. | ||
| - a platform-skill role binding on `admins` means platform admin, never a | ||
| tenant named `admins`. | ||
| - `/whoami` reports `admins` in `namespaces` for a platform admin, but | ||
| never in `groups`: admin is decided by principal, not carried as a | ||
| group. Clients must read `isAdmin`, not `groups.includes("admins")`. | ||
| - `/whoami` `id` is the GitHub login for a GitHub-linked caller, but the | ||
| principal id (`prn_…`) for a ZITADEL-only principal, which has no login. | ||
| Clients must not treat `id` as a GitHub login (link to a profile, look it | ||
| up in a repo's collaborators). | ||
|
|
||
| ### Local tokens carry the principal (`pid`) | ||
|
|
||
|
|
@@ -375,15 +384,19 @@ rotation carries it forward. | |
| - In `principal` mode a token without a `pid` is refused (401), a | ||
| principal-only token must have `sub == pid`, and a refresh of a session | ||
| with no recorded principal answers `invalid_grant`. Outside it, a | ||
| principal-only token is refused. | ||
| principal-only token is refused (401), and a refresh of a session with | ||
| no login (a ZITADEL-only principal) answers `invalid_grant`. | ||
|
|
||
| At the switch, sessions minted before this release carry no `pid`, so | ||
| their holders sign in again. With the persistent refresh store the refresh | ||
| token is rotated before its missing `pid` is refused, so a client that | ||
| retries it gets `invalid_grant` and, once the rotation grace window has | ||
| passed, trips reuse detection: the family is revoked and `oauth refresh | ||
| token reuse detected; family revoked` is logged as a warning. On switch day | ||
| those warnings are pre-#377 sessions, not stolen tokens. | ||
| those warnings are pre-#377 sessions, not stolen tokens. A rollback out | ||
| of `principal` mode does the same to ZITADEL-only principals: their | ||
| sessions have no login, so their refresh is refused, and the same | ||
| reuse-detection warnings follow. | ||
|
|
||
| A disabled principal, merged-away ones included (a merge disables `from`), | ||
| is refused on every request. An **unlink is not**: the principal stays | ||
|
|
@@ -404,6 +417,8 @@ the switch. | |
| | `tenant_member_entry_unkeyed{tenant}` | member entries with no `principalId` | | ||
| | `tenant_principal_keys_invalid{tenant}` | 1 when a tenant's keys are invalid or the tenant cannot be read | | ||
| | `tenant_principal_keys_scan_ok` | 1 when the last scan could list the tenants; the two gauges above are cleared when it is 0 | | ||
| | `oauth_token_principal_mismatch_total` | local tokens whose `pid` names a different principal than the one resolved from the login (outside `principal` mode, where it is still resolved) | | ||
| | `oauth_upstream_signins_total{result}` | also `principal_refused` (disabled or refused principal, 403) and `principal_unavailable` (could not be resolved, 503 in `principal` mode), for both upstreams | | ||
|
|
||
| An `unresolved` comparison is never counted as a `match` or a `diff`: a | ||
| decision that could not be computed is not evidence either way. The one | ||
|
|
@@ -426,6 +441,14 @@ increasing for human callers; `sum(tenant_member_entry_unkeyed) == 0`, | |
| ZITADEL MCP OAuth, ZITADEL bearer). A window in which a path saw no traffic | ||
| does not count for that path. | ||
|
|
||
| Over the same window, `oauth_token_principal_mismatch_total` must stay at | ||
| zero. Before the flip, check pid coverage too: a live session minted | ||
| before the release that records no `pid` is refused at the switch and its | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: this sentence is still ambiguous, and my previous round's finding on it was itself a mis-parse — worth saying so that the wording lands right rather than oscillating. The clause
The previous wording (
Non-blocking, but this paragraph is the checklist an operator reads before flipping |
||
| holder must sign in again. A refresh carries an empty `principal_id` | ||
| forward, so such sessions end only by expiry or a new sign-in; count the | ||
| live refresh rows with an empty `principal_id` and decide whether that | ||
| many re-sign-ins are acceptable on switch day. | ||
|
|
||
| ### Backfill plan | ||
|
|
||
| `GET /api/v1/admin/tenant-members/principal-plan` (platform admin acting | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ package api | |
|
|
||
| import ( | ||
| "errors" | ||
| "fmt" | ||
| "log/slog" | ||
| "net/http" | ||
| "strconv" | ||
|
|
@@ -55,6 +56,11 @@ func (h *Handlers) signinPrincipal(w http.ResponseWriter, r *http.Request, id au | |
| return "", "" | ||
| } | ||
| prn, err := h.opts.Principals.ResolvePrincipal(r.Context(), id) | ||
| if err == nil && !auth.ValidPrincipalID(prn) { | ||
| // A resolver answering with no usable principal and no error has | ||
| // not resolved one: treat it like any other failed lookup. | ||
| err = fmt.Errorf("resolver returned %q, not a principal id", prn) | ||
| } | ||
| switch { | ||
| case err == nil: | ||
| return prn, "" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in d58da55: a nil error with an empty or malformed principal id is treated as a failed lookup, so in principal mode it is 503 principal_unavailable. New TestSigninPrincipal cases (empty answer in both modes, malformed answer in principal mode); mutation-verified. |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,7 +37,10 @@ import ( | |
| // the original upstream. ZITADEL signs the person in as the GitHub login of | ||
| // the principal their ZITADEL identity is linked to (#435), so the code, | ||
| // the tokens and the groups behind them are exactly what a GitHub sign-in of | ||
| // the same person yields. Nothing is linked or provisioned here. | ||
| // the same person yields. Nothing is linked here, and outside principal | ||
| // mode nothing is provisioned either. In principal mode (mctl-api#377) an | ||
| // identity with no single GitHub login signs in as its own principal, | ||
| // which the resolver provisions on first sight. | ||
|
|
||
| // OAuthUpstreamMode is OAUTH_UPSTREAM. | ||
| type OAuthUpstreamMode string | ||
|
|
@@ -319,20 +322,39 @@ func (h *Handlers) handleOAuthZitadelCallback(w http.ResponseWriter, r *http.Req | |
| } | ||
| id := auth.Identity{Provider: z.opts.ProviderName, Issuer: idt.Issuer, Subject: idt.Subject, Display: display, Kind: auth.KindHuman} | ||
| p, gh, err := z.opts.Store.LinkedGitHub(r.Context(), id) | ||
| if err != nil { | ||
| var login, principalID string | ||
| switch { | ||
| case err == nil: | ||
| // The GitHub callback's tail, for the linked principal's GitHub | ||
| // login: same groups, same admin decision, same code. The principal | ||
| // is the one the store just found the link on. | ||
| login, principalID = gh.Display, p.ID | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: the linked path is now the only sign-in that can mint a pid-less token in principal mode.
The case err == nil && (h.opts.TenantAuthzMode != TenantAuthzPrincipal || auth.ValidPrincipalID(p.ID)):
login, principalID = gh.Display, p.IDwhich sends a contract violation to
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred to slice 2d: treat |
||
| case h.opts.TenantAuthzMode == TenantAuthzPrincipal && zitadelOnly(p, err): | ||
| // Principal mode needs no GitHub login (mctl-api#377, Q5): the | ||
| // identity signs in as its principal, provisioned on first sight, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: this branch provisions a principal on first sight, which makes the file-level comment at line 40 ("Nothing is linked or provisioned here") stale — worth a one-line amendment so it doesn't contradict the behavior this PR adds in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in c90fe4f: the comment now says nothing is linked there, and that only principal mode provisions (the resolver does it on first sight). The same push carries the deferred #598 P3s: the token-principal claim is confined to the local OAuth provider, and the switch-criterion and Metrics docs are completed. |
||
| // and gets exactly the GitOps membership keyed by that principal. | ||
| var stopped string | ||
| if principalID, stopped = h.signinPrincipal(w, r, id); stopped != "" { | ||
| result = stopped | ||
| return | ||
| } | ||
| if p != nil && principalID != p.ID { | ||
| // The resolver and the store disagree on whose identity this | ||
| // is; minting for either would be a guess. | ||
| slog.Error("oauth: ZITADEL identity resolved to another principal", "store", p.ID, "resolver", principalID) | ||
| result = zitadelStoreUnavailable(w, errors.New("principal resolution disagrees with the link store")) | ||
| return | ||
| } | ||
| default: | ||
| result = h.refuseZitadelSignin(w, display, p, err) | ||
| return | ||
| } | ||
|
|
||
| // From here on this is the GitHub callback's tail, for the linked | ||
| // principal's GitHub login: same groups, same admin decision, same code. | ||
| // The principal is the one the store just found the link on. | ||
| login := gh.Display | ||
| var groups []string | ||
| if h.opts.TenantAuthzMode != TenantAuthzPrincipal { | ||
| groups = o.ResolveGroups(login) | ||
| } | ||
| mctlCode, err := o.IssueCode(r.Context(), login, p.ID, pending.ClientID, pending.RedirectURI, pending.CodeChallenge, groups) | ||
| mctlCode, err := o.IssueCode(r.Context(), login, principalID, pending.ClientID, pending.RedirectURI, pending.CodeChallenge, groups) | ||
| if err != nil { | ||
| slog.Error("failed to issue auth code", "error", err) | ||
| result = "internal_error" | ||
|
|
@@ -352,10 +374,25 @@ func (h *Handlers) handleOAuthZitadelCallback(w http.ResponseWriter, r *http.Req | |
| } | ||
| target.RawQuery = cq.Encode() | ||
| result = "issued" | ||
| slog.Info("oauth: ZITADEL sign-in", "principal_id", p.ID, "login", login, "zitadel_subject", idt.Subject) | ||
| slog.Info("oauth: ZITADEL sign-in", "principal_id", principalID, "login", login, "zitadel_subject", idt.Subject) | ||
| http.Redirect(w, r, target.String(), http.StatusFound) | ||
| } | ||
|
|
||
| // zitadelOnly reports whether a LinkedGitHub refusal only says the | ||
| // identity has no single GitHub login to sign in as: not linked to any | ||
| // principal yet, or a principal without exactly one live GitHub login. | ||
| // Principal mode signs those in as the principal. A refusal that names no | ||
| // principal where it must is a failed read, never this. | ||
| func zitadelOnly(p *principals.Principal, err error) bool { | ||
| switch { | ||
| case errors.Is(err, principals.ErrNotFound): | ||
| return true | ||
| case errors.Is(err, principals.ErrNoGitHubIdentity), errors.Is(err, principals.ErrGitHubLoginUnknown), errors.Is(err, principals.ErrAmbiguousGitHubIdentity): | ||
| return p != nil | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // refuseZitadelSignin answers a ZITADEL identity that cannot sign in and | ||
| // returns the metric result. Only the store's documented refusals mean | ||
| // "not linked"; any other error is a failed read and answers 503. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: this newly written
/whoamicontract coversadmins/isAdminbut omits the one field this slice changes: for a ZITADEL-only principal the token has no login, soValidateJWTbuildsUser{ID: pid}and/whoamianswers"id": "prn_01J..."instead of a GitHub login (internal/api/handlers_read.go:69). Any client that rendersidas a GitHub handle (or keys local state on it) will see aprn_there the first time a ZITADEL-only member signs in. Worth one line here, since/whoamiis the contract clients read.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in d58da55: the whoami contract in docs/principals.md now states that
idis theprn_…for a ZITADEL-only principal and must not be treated as a GitHub login.