Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ docker run -p 8080:8080 mctl-api
| `OAUTH_GITHUB_CLIENT_ID` | GitHub OAuth app client ID | — | No |
| `OAUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret | — | No |
| `OAUTH_JWT_SECRET` | JWT signing secret for OAuth tokens | — | No |
| `OAUTH_UPSTREAM` | Where `/oauth/authorize` signs people in (mctl-api#467, see [docs/principals.md](docs/principals.md)): `github`, `zitadel` (as the linked principal's GitHub login), or `both` (a chooser, ZITADEL first). `zitadel`/`both` refuse startup without `OAUTH_JWT_SECRET`, both `OAUTH_ZITADEL_CLIENT_*` and the provider entry, and `both` also without both `OAUTH_GITHUB_CLIENT_*`. An unknown value refuses startup | `github` | No |
| `OAUTH_UPSTREAM` | Where `/oauth/authorize` signs people in (mctl-api#467, see [docs/principals.md](docs/principals.md)): `github`, `zitadel` (as the linked principal's GitHub login; under `TENANT_AUTHZ_MODE=principal` an identity with no GitHub login signs in as its own principal), or `both` (a chooser, ZITADEL first). `zitadel`/`both` refuse startup without `OAUTH_JWT_SECRET`, both `OAUTH_ZITADEL_CLIENT_*` and the provider entry, and `both` also without both `OAUTH_GITHUB_CLIENT_*`. An unknown value refuses startup | `github` | No |
| `OAUTH_ZITADEL_CLIENT_ID` / `OAUTH_ZITADEL_CLIENT_SECRET` | Confidential ZITADEL client of the ZITADEL upstream (redirect `<SELF_URL>/oauth/zitadel/callback`); not the link client. Helm: `oauthZitadelSecret` | — | No |
| `OAUTH_ZITADEL_PROVIDER` | `MCTL_OIDC_PROVIDERS` entry whose issuer the ZITADEL upstream verifies against; must be the one linking writes (`ZITADEL_LINK_PROVIDER`) | `zitadel` | No |
| `OAUTH_ALLOWED_REDIRECT_URIS` | Comma-separated redirect URIs accepted at `/oauth/authorize` and by Dynamic Client Registration (`POST /oauth/register`). An entry is an **exact** string compare unless it ends in `/*`, which makes it a prefix match; prefer exact entries (the Cloudflare MCP portal's `https://mcp.mctl.ai/servers-callback` and its dashboard `…/mcp-server/oauth-callback/api` callback are listed exactly, never as a `dash.cloudflare.com/*` prefix). Loopback `http://` callbacks are always accepted (RFC 8252). A registration naming any other URI gets `400 invalid_redirect_uri` | glama, smithery, `chatgpt.com/connector/oauth/*` | No |
Expand Down
31 changes: 27 additions & 4 deletions docs/principals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: this newly written /whoami contract covers admins/isAdmin but omits the one field this slice changes: for a ZITADEL-only principal the token has no login, so ValidateJWT builds User{ID: pid} and /whoami answers "id": "prn_01J..." instead of a GitHub login (internal/api/handlers_read.go:69). Any client that renders id as a GitHub handle (or keys local state on it) will see a prn_ there the first time a ZITADEL-only member signs in. Worth one line here, since /whoami is the contract clients read.

Copy link
Copy Markdown
Member Author

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 id is the prn_… for a ZITADEL-only principal and must not be treated as a GitHub login.

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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 that records no pid`` sits next to the release, so the sentence parses two ways:

  • A: a live session minted before [the release that records no pid] — nonsense, since the release is the one that does record it.
  • B: a live session ... that records no pid — the intended meaning.

The previous wording (the release that records pid``) was correct under parse A and wrong under parse B; this one is the mirror image. Hoisting the clause removes the ambiguity in either direction:

a live session that records no pid — one minted before this release — is refused at the switch and its holder must sign in again.

Non-blocking, but this paragraph is the checklist an operator reads before flipping TENANT_AUTHZ_MODE, and the following sentence (count the live refresh rows with an empty principal_id) is the only thing that currently disambiguates it.

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
Expand Down
6 changes: 6 additions & 0 deletions internal/api/oauth_signin_principal.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package api

import (
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
Expand Down Expand Up @@ -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, ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: (prn == "", err == nil) is taken as success. oauth_upstream.go handles the mirror-image store contract violation explicitly ("A refusal that names no principal where it must is a failed read, never this", with a test case for it), but a resolver that answers an empty principal and no error slips through here: in principal mode on the ZITADEL path principalID stays "", the disagreement guard is skipped (p == nil on ErrNotFound), IssueCode accepts "", and the failure only surfaces at /oauth/token as a 500 from IssueJWT ("neither a login nor a principal") after the code was already handed to the client. A prn == "" check in this branch would turn it into the documented 503 principal_unavailable at sign-in, symmetric with how the store’s equivalent violation is treated.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Expand Down
3 changes: 3 additions & 0 deletions internal/api/oauth_signin_principal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ func TestSigninPrincipal(t *testing.T) {
{"refused, principal mode", signinResolver{err: fmt.Errorf("x: %w", auth.ErrIdentityRefused)}, true, "", http.StatusForbidden},
{"unresolved", signinResolver{err: unresolved}, false, "", 0},
{"unresolved, principal mode", signinResolver{err: unresolved}, true, "", http.StatusServiceUnavailable},
{"empty answer", signinResolver{}, false, "", 0},
{"empty answer, principal mode", signinResolver{}, true, "", http.StatusServiceUnavailable},
{"malformed answer, principal mode", signinResolver{prn: "alice"}, true, "", http.StatusServiceUnavailable},
{"no resolver", nil, false, "", 0},
{"no resolver, principal mode", nil, true, "", http.StatusServiceUnavailable},
}
Expand Down
53 changes: 45 additions & 8 deletions internal/api/oauth_upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

signinPrincipal was just hardened so a resolver answering ("", nil) becomes a 503 at sign-in rather than a broken token (oauth_signin_principal.go:59). This line takes the mirror-image answer from the other boundary — Store.LinkedGitHub, an interface in OAuthZitadelOptions — on trust: if it returns err == nil with a p whose ID is empty, principalID stays "", and in principal mode nothing downstream catches it. IssueCode only rejects a non-empty malformed id (oauth_server.go:1346), and IssueJWT only errors when both login and principal are empty (:1460) — here the login is non-empty, so the exchange succeeds and mints a token with no pid, which ValidateJWT then refuses on every request with ErrTokenWithoutPrincipal (401). The holder gets a code, a successful token exchange, and a session that never works.

The zitadelOnly branch already states this rule in a comment ("A refusal that names no principal where it must is a failed read, never this") and TestZitadelSigninRefusalWithoutAPrincipalIsAFailedRead pins it for the refusal side; the success side has no equivalent. Folding it in would be one clause:

case err == nil && (h.opts.TenantAuthzMode != TenantAuthzPrincipal || auth.ValidPrincipalID(p.ID)):
	login, principalID = gh.Display, p.ID

which sends a contract violation to refuseZitadelSignin → 503 store_unavailable, the same answer the resolver’s version now gets. DB-sourced in practice, hence P3 rather than P2.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to slice 2d: treat LinkedGitHub answering err == nil with an empty/malformed p.ID as principal_unavailable, mirroring signinPrincipal.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 principal mode.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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"
Expand All @@ -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.
Expand Down
Loading
Loading