Skip to content

feat(api): sign in a ZITADEL-only principal (#377 PR-B 2b) - #600

Merged
mashkovd merged 6 commits into
mainfrom
feat/377-zitadel-only
Oct 13, 2026
Merged

mashkovd merged 6 commits into
mainfrom
feat/377-zitadel-only

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Refs #377. PR-B slice 2b, stacked on #598 (2a, pid claim). Review only the top two commits; this retargets to main once #598 merges.

What

In TENANT_AUTHZ_MODE=principal, a ZITADEL identity that has no single GitHub login now signs in as its own principal instead of being sent to the link page:

  • If the link store has no row (a never-seen identity), the resolver provisions a principal and the sign-in mints for it.
  • If the identity's principal has no GitHub identity, an unknown GitHub login, or two GitHub identities, the sign-in mints for that principal and guesses no login.
  • The token has sub = pid = the principal id and no groups. Tenant access comes only from a GitOps member entry that names that principalId (proposal Q5, acceptance criterion 4).
  • When the link store and the principal resolver disagree, the answer is 503 store_unavailable, never a guess. A disabled principal gives 403 principal_refused. An unresolvable principal gives 503 principal_unavailable.

Nothing changes outside principal mode. ParseTenantAuthzMode still refuses principal, so the slice stays inert in production.

Tests

  • TestZitadelSigninPrincipalMode: 12 cases covering linked, provisioned, own principal, unknown login, ambiguous, resolver disagreement, no resolver, resolver down, disabled, revoked, store down, and a refusal that names no principal. Each asserts the sub, the pid, the empty groups and the sign-in counter.
  • TestZitadelOnlyPrincipal_SigninToTenantRole (B6, API side), end to end through sign-in → /oauth/token exchange → auth.Middleware with PrincipalAuthz → checkTenantRole:
    • a ZITADEL-only member is a developer on its tenant;
    • removing the GitOps entry removes both access and role on the same token;
    • an unlinked identity with the same email gets nothing.
  • Mutation-checked: disabling the principal-mode branch, mis-binding the token principal, and dropping the p != nil guard each fail a test.
  • The full suite passes against Postgres.

Docs

  • The ZITADEL sign-in table in docs/principals.md gains the principal-mode answers, and the README OAUTH_UPSTREAM row is updated.
  • The /whoami contract is now written down: a principal-mode admin has admins in namespaces but never in groups, so clients read isAdmin. This is the P3 deferred from feat(api): principal mode core for tenant authz (#377 PR-B slice 1) #595.

Remaining slices

  • 2c: relay and delegation by principal through Apply (B5).
  • 2d: a non-blocking membership snapshot, revocation of refresh families by principal_id on unlink, then unlocking ParseTenantAuthzMode.
  • B7 (wft-create-tenant writes principalId) lands in mctl-gitops.

🤖 Generated with Claude Code

https://claude.ai/code/session_0151SmHa5Uwfv9sm6G2XwDnE

In principal mode a ZITADEL identity with no single GitHub login now
signs in as its own principal (provisioned on first sign-in) instead of
being sent to the link page: the token's sub and pid are the principal
id and it carries no groups. Tenant access then comes only from a GitOps
member entry naming that principalId (proposal Q5, acceptance criterion
4). Outside principal mode nothing changes.

The link store and the principal resolver must agree on the principal;
when they do not, the sign-in is a 503 rather than a guess.

The end-to-end test covers B6 on the API side: sign-in, token,
middleware and role check give a ZITADEL-only member developer on its
tenant, removing the entry removes it on the same token, and an
unlinked identity with the same email gets nothing.

Claude-Session: https://claude.ai/code/session_0151SmHa5Uwfv9sm6G2XwDnE
The sign-in table gets the principal-mode answers. /whoami reports
admins in namespaces but never in groups for a principal-mode admin, so
clients must read isAdmin (deferred P3 from #595).

Claude-Session: https://claude.ai/code/session_0151SmHa5Uwfv9sm6G2XwDnE
Copilot AI balanced review requested due to automatic review settings October 12, 2026 23:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 4m 7s —— View job


Review complete — PR #600

  • Gather context (diff, CLAUDE.md conventions)
  • Review correctness & logic
  • Review security (auth/authz)
  • Review concurrency
  • Review error handling
  • Review test coverage
  • Post inline findings
  • Submit verdict

Reviewed git diff cdee2bfb666b026227119fa6d02c3bc4c1a438d0...69a77dd926087b352b0a3324d21db2e061383cc5 against internal/api/oauth_upstream.go, its test file, docs/principals.md, and README.md.

Findings: 0 P1, 0 P2, 1 P3 (posted inline on internal/api/oauth_upstream.go).

  • P3: the file-level comment above OAuthUpstreamMode ("Nothing is linked or provisioned here") is now stale — the new principal-mode branch does provision a principal on first sight via signinPrincipal.

What I checked and found sound:

  • The new switch in handleOAuthZitadelCallback preserves the original linked-GitHub path (err == nil) unchanged, and the default (refusal) path is byte-for-byte the pre-existing refuseZitadelSignin behavior for every mode outside principal and for errors zitadelOnly doesn't recognize.
  • zitadelOnly correctly gates on the store's documented sentinel errors (ErrNotFound, ErrNoGitHubIdentity, ErrGitHubLoginUnknown, ErrAmbiguousGitHubIdentity), matching principals.Store.LinkedGitHub's actual contract (verified ErrNotFound always returns nil, nil, ErrNotFound in internal/principals/signin.go).
  • The store/resolver disagreement guard (p != nil && principalID != p.ID) correctly refuses to guess and returns 503 store_unavailable, matching the "resolver disagrees" test case.
  • login is left empty in the principal-only path; confirmed IssueJWT (pre-existing, unchanged) falls back subject = principalID when login == "", so sub == pid as documented.
  • Groups are skipped in principal mode via the pre-existing TenantAuthzMode != TenantAuthzPrincipal guard, unchanged by this diff.
  • TestZitadelSigninPrincipalMode's 12 cases and TestZitadelOnlyPrincipal_SigninToTenantRole's end-to-end flow line up with the described behavior (linked, provisioned, own-principal variants, resolver disagreement, no/-down resolver, disabled, revoked, store down, and the "refusal without a principal" contract-violation case).
  • Docs (docs/principals.md, README.md) accurately describe the new behavior and result codes.

Not run: go build/go vet/go test/gofmt — the sandbox required approval for all go/gofmt invocations in this run and none was granted, so these commands could not execute. This review is based on manual static reading of the diff and cross-referencing the called functions' contracts. If this recurs, allow go test, go vet, and gofmt in --allowedTools for this job.

Posted: 1 inline P3 comment, and a verdict of APPROVE bound to commit 69a77dd926087b352b0a3324d21db2e061383cc5.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 69a77dd.

No significant issues found.

login, principalID = gh.Display, p.ID
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.

claude[bot]
claude Bot previously approved these changes Oct 12, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (1 P3). Good to merge.

@mashkovd
mashkovd changed the base branch from feat/377-principal-mode-2 to main October 12, 2026 23:33
@mashkovd
mashkovd dismissed claude[bot]’s stale review October 12, 2026 23:33

The base branch was changed.

Review P3s from #598 and #600. checkProviderContract now refuses a
token-principal claim from any provider but the local OAuth one,
symmetric with the reserved principal namespace. The switch criterion
gains the zero-mismatch condition and the pid-coverage check, and the
Metrics table lists the new counter and sign-in results. Two stale or
misplaced comments are corrected.

Claude-Session: https://claude.ai/code/session_0151SmHa5Uwfv9sm6G2XwDnE
@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 6m 46s —— View job


Review complete — PR #600

  • Gather context (pinned diff, CLAUDE.md conventions)
  • Review correctness & logic
  • Review security (auth/authz)
  • Review concurrency
  • Review error handling
  • Review test coverage
  • Post inline findings
  • Submit verdict

Reviewed the pinned range git diff 0098678...c90fe4f (24 files, +1311/-174) — the whole PR this time, not just the top two commits, since the pinned base is main.

Findings: 0 P1, 0 P2, 3 P3 — approved, bound to c90fe4f.

P3s (non-blocking, all posted inline)

  1. docs/principals.md:353 — the newly written /whoami contract covers admins/isAdmin but omits the one field this slice changes: a ZITADEL-only principal's token has no login, so /whoami answers "id": "prn_01J…" (internal/api/handlers_read.go:69) instead of a GitHub handle.
  2. internal/auth/oauth_server.go:1594 — refreshablePrincipal guards the pid-less session but not the login-less one. Roll principal mode back off and a ZITADEL-only refresh token still rotates successfully into a sub = pid token that ValidateJWT then always refuses — a 401 loop instead of invalid_grant. TestRefresh_CarriesThePrincipal already drives that exact combination but asserts only payload.PID.
  3. internal/api/oauth_signin_principal.go:60 — an empty principal with a nil error is taken as success. oauth_upstream.go handles the mirror-image store contract violation explicitly (with a test case); the resolver's version slips through to a 500 from IssueJWT at /oauth/token, after the code was handed to the client.

What I checked and found sound

  • Fail-closed on every check failure. checkTokenPrincipal clears principalID and returns the error; attachPrincipal degrades, PrincipalAuthz.Apply then returns ErrPrincipalAuthzUnavailable → 503, never "member of nothing". Disabled → 403, nonexistent → 401, store down → 503. A nil resolver is a nil interface in main.go (declared as auth.PrincipalResolver, only ever assigned a non-nil *principals.Resolver), so pr.(PrincipalChecker) fails rather than dereferencing a typed nil.
  • The claim is never trusted unchecked. ProviderPrincipal is in reservedProviderNames, and checkProviderContract confines both the principal namespace (principalTokenOf) and Claims.TokenPrincipal to *localOAuthProvider. finish is the single exit from Registry.Verify, so no path reaches userFromVerified without the contract check.
  • sub stays unambiguous. prn_ contains _, which a GitHub login cannot, so a principal-only sub can never pass for a login — in either mode or an older build. Outside principal mode such a token is refused outright; sub != pid is refused inside it.
  • Cache keys don't collide. CheckPrincipal uses principal|<prn> vs ResolvePrincipal's <provider>|<issuer>|<subject> and github-login|<login>; the double-pipe keeps even a hypothetical principal-provider identity distinct. Entries cache principal: p.ID, so Forget(prn) drops the CheckPrincipal answers too, and the gen guard still prevents a read that began before a Forget from writing back.
  • cachedLookup refactor is behaviour-preserving: refusals returned verbatim and never cached, other failures cached for FailureTTL as ErrUnresolved, answer caching gated on gen. Store.Get returns (nil, ErrNotFound) on no rows, so the p.ID deref after fetch cannot be nil.
  • zitadelOnly matches LinkedGitHub's actual contract (verified in internal/principals/signin.go:106-120): ErrNotFound ⇒ p == nil; the three "no single login" sentinels always return p. A refusal that should name a principal and doesn't falls to refuseZitadelSignin → 503, and the store/resolver disagreement guard refuses to guess.
  • Refresh carries the principal through every path, grace-window replay included, and Rotate's new Session return is threaded through all call sites and both store implementations (PostgresStore, the test fakeRefreshStore). The switch-day consequence (rotate-then-refuse trips reuse detection on retry) is written down in docs/principals.md rather than left to be discovered.
  • No new concurrency surface: the resolver's mutex discipline is unchanged, and nothing new is shared across goroutines.
  • Docs/README accurately describe the behaviour, including the unlink gap deferred to 2d and the /whoami admin contract (which matches handlers_read.go:63-66).

Not run

go build, go vet, go test, gofmt — every go/gofmt invocation required approval in this sandbox and none was granted, so this review is static reading plus cross-referencing the called functions' contracts. This is the second run in a row that could not compile the diff; please allow go test, go vet and gofmt in this job's --allowedTools.
· branch feat/377-zitadel-only

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit c90fe4f.

No significant issues found.

Comment thread docs/principals.md
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.

Comment thread internal/auth/oauth_server.go Outdated
// access tokens would be refused anyway, and re-deriving the principal from
// the login is what principal mode removes. The holder signs in again. The
// error reads as an invalid grant, which is what tells a client to do that.
func (s *OAuthServer) refreshablePrincipal(principalID string) error {

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 guards the pid-less session but not the login-less one. If principal mode is ever rolled back off (the mode is a config flag, and 2d explicitly plans a switch), a ZITADEL-only session still holds a valid refresh token with Login == "" and a good PrincipalID. RefreshAccessToken then succeeds, IssueJWT("", pid) mints sub = pid, and ValidateJWT (now PrincipalMode == false) refuses that very token with "this token is for a principal without a GitHub login, which is accepted only when tenant authorization is by principal" — a 401 loop rather than the invalid_grant that would tell the client to sign in again. TestRefresh_CarriesThePrincipal already drives this exact combination ({"", pzZitadel} with principalMode=false) but only asserts payload.PID, so nothing pins the outcome. Refusing a login-less session outside principal mode, next to the pid-less one, would make the rollback symmetric with the switch.

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: refreshablePrincipal(login, principalID) now also refuses a login-less session outside principal mode (invalid_grant, so the client signs in again). TestRefresh_CarriesThePrincipal asserts the refusal for {"", pzZitadel} with principalMode=false; mutation-verified (dropping the clause fails it).

prn, err := h.opts.Principals.ResolvePrincipal(r.Context(), id)
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.

claude[bot]
claude Bot previously approved these changes Oct 12, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (3 P3). Good to merge.

The principal binding is fail-closed everywhere I could follow it: checkTokenPrincipal clears principalID on any check failure, PrincipalAuthz.Apply then answers 503 rather than "member of nothing", a nil resolver is a nil interface so the PrincipalChecker assertion fails rather than panicking, and checkProviderContract confines both the principal namespace and the TokenPrincipal claim to the local-OAuth provider. prn_ cannot collide with a GitHub login, so sub stays unambiguous across modes and older builds.

P3s (all non-blocking): the new /whoami contract omits that id becomes a prn_ for a ZITADEL-only principal; refreshablePrincipal guards the pid-less session but not the login-less one, so a rollback out of principal mode refreshes ZITADEL-only sessions into tokens ValidateJWT always refuses; and signinPrincipal takes (\"\", nil) from the resolver as success, which surfaces as a 500 at /oauth/token instead of the documented 503.

Not run: go build/go vet/go test/gofmt — every go/gofmt invocation required approval in this sandbox and none was granted, so this is static reading plus cross-checking the called contracts (Store.LinkedGitHub, Store.Get, Resolver.Forget, PrincipalAuthz.Apply, attachPrincipal). Please allow go test, go vet and gofmt in this job's --allowedTools; this is the second run in a row that could not compile the diff.

@mashkovd
mashkovd dismissed claude[bot]’s stale review October 12, 2026 23:42

The merge-base changed after approval.

Review round on #600:
- refreshablePrincipal also refuses a session with no login when not in
  principal mode. ValidateJWT refuses its access tokens there, so a
  refresh after a rollback from principal mode would only mint tokens
  that answer 401 and leave the client looping instead of signing in.
- signinPrincipal treats a resolver answering no error but no valid
  principal id as a failed lookup (503 principal_unavailable in principal
  mode) rather than going on with an empty principal.
- docs: /whoami id is the prn for a ZITADEL-only principal.
- lint: mark the test fixture principal id as not a credential (G101).

Claude-Session: https://claude.ai/code/session_0151SmHa5Uwfv9sm6G2XwDnE
@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 5m 36s —— View job


Review complete — PR #600

  • Gather context (pinned diff 0098678...d58da55, CLAUDE.md conventions)
  • Review correctness & logic
  • Review security (auth/authz)
  • Review concurrency
  • Review error handling
  • Review test coverage
  • Build / vet / test — could not run (see below)
  • Post inline findings
  • Submit verdict

Reviewed the pinned range git diff 0098678...d58da55 (24 files, +1336/-174), with particular attention to the new head commit d58da55, which answers two of the three P3s from the previous round.

Findings: 0 P1, 0 P2, 2 P3 — approved, bound to d58da55.

The fixes in d58da55 hold up

  • refreshablePrincipal(login, principalID) (internal/auth/oauth_server.go:1596) now refuses the login-less session outside principal mode as well as the pid-less one inside it, on both refresh paths — the refreshstore path after Rotate and the in-memory path — and TestRefresh_CarriesThePrincipal drives store ∈ {false,true} × principalMode ∈ {false,true}, with the continue correctly scoped to the tc loop so no assertion is silently skipped. The bare errors.New("invalid or expired refresh token") is the same literal the in-memory path already used, and handleOAuthToken maps anything that is not auth.ErrServerError to invalid_grant, so the 401 loop becomes the sign-in-again signal as intended.
  • No legitimate caller is stranded by that refusal: a login-less refresh row can only be created by the principal-mode ZITADEL branch (IssueCode is reached with login == "" only under TenantAuthzMode == TenantAuthzPrincipal), so outside the mode such a row exists only after a rollback — exactly the case being refused.
  • signinPrincipal (internal/api/oauth_signin_principal.go:59) now treats (prn, nil) with a non-prn_ answer as a failed lookup, so the resolver's contract violation becomes the documented 503 principal_unavailable at sign-in instead of a 500 from IssueJWT after the code was handed to the client — symmetric with how oauth_upstream.go already handles the store's mirror-image violation. The three new table rows cover empty, empty-in-principal-mode and malformed.
  • /whoami id is now documented as a prn_… for a ZITADEL-only principal, matching handlers_read.go:69.

What else I checked and found sound

  • Call-site completeness. Every IssueJWT, IssueCode and IssueRefreshToken caller (production and test) carries the new principalID argument, and both refreshstore.Store implementations plus the test fake return the new Session. The principal survives all three rotation paths including the grace-window replay (TestRotateCarriesThePrincipal), and principal_id is added with ADD COLUMN IF NOT EXISTS … DEFAULT '' plus a partial index for the 2d revocation work.
  • The two mode flags cannot disagree. oauthServer.PrincipalMode (cmd/api/main.go:185) and Options.TenantAuthzMode (:943) both come from cfg.tenantAuthzMode(), at the single OAuthServer construction site — so there is no configuration in which sign-in mints a sub == pid token that ValidateJWT then refuses.
  • The claim is never trusted unchecked. ProviderPrincipal is in reservedProviderNames; checkProviderContract confines both the principal namespace (via principalTokenOf) and Claims.TokenPrincipal to *localOAuthProvider. Claims.TokenPID is read only in the ProviderGitHub arm of userFromVerified, which no configured provider can reach (reserved name + exact Name() match), and its only effect is the mismatch counter. checkTokenPrincipal clears principalID on every failure, including a resolver that is not a PrincipalChecker (a nil interface, so no typed-nil deref), leaving PrincipalAuthz.Apply to answer 503 rather than "member of nothing".
  • Sign-in refusals are complete on the ZITADEL path. LinkedGitHub returns ErrPrincipalDisabled / ErrIdentityRefused before any zitadelOnly sentinel, so the linked path still 403s a disabled principal without going through signinPrincipal; zitadelOnly matches the store's documented contract exactly (ErrNotFound ⇒ p == nil; the three "no single login" sentinels always name a principal), a refusal that should name one and doesn't falls through to 503, and the store/resolver disagreement guard refuses to guess. The provision-then-resolve round trip is self-consistent: the second sign-in of a provisioned identity resolves the same principal, so the guard cannot fire spuriously.
  • sub stays unambiguous (prn_ contains _, which a GitHub login cannot), groups are skipped in principal mode on every mint and refresh path, and IssueCode/IssueJWT both validate a non-empty principalID.
  • No new concurrency surface. The cachedLookup refactor preserves the mutex discipline, the gen guard against a read that began before a Forget, the refusal-never-cached rule and the FailureTTL caching; CheckPrincipal's principal|<prn> key cannot collide with ResolvePrincipal's, and its entries record principal: p.ID so Forget drops them too.
  • Metric accounting is single-counted on both callbacks (the GitHub handler Incs inline and returns; the ZITADEL handler sets result for its deferred counter).

Not run

go build, go vet, go test, gofmt — every go and gofmt invocation required approval in this sandbox and none was granted, so this review is static reading plus cross-referencing the called contracts (Store.LinkedGitHub, refreshstore.Store, Resolver.resolve/Forget, the /oauth/token error mapping). This is the third run in a row that could not compile the diff; please allow go test, go vet and gofmt in this job's --allowedTools.
· branch feat/377-zitadel-only

Comment thread docs/principals.md Outdated

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 `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: the sentence is inverted — it should be a session that records no pid. As written ("a live session minted before the release that records `pid` is refused at the switch") it says the opposite of the behaviour, and of the paragraph 50 lines above ("sessions minted before this release carry no `pid`, so their holders sign in again") and of ValidateJWT, which refuses exactly the token whose pid is absent. This is a switch-criterion checklist an operator reads before flipping TENANT_AUTHZ_MODE, so the inversion is worth fixing even though the following sentence ("count the live refresh rows with an empty `principal_id`") makes the intent recoverable.

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 901fa08: "records no pid".

Comment thread docs/principals.md Outdated
- 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.

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 bullet is the mode-by-mode token contract, and it now carries only half of the symmetry refreshablePrincipal gained in d58da55. Principal mode gets both its rules written down (a pid-less token is 401, a pid-less session refresh is invalid_grant), but outside it only the access-token rule is ("a principal-only token is refused") — the matching refresh rule, that a login-less session is no longer refreshable outside principal mode either, is not. That is the rollback-day counterpart of the switch-day paragraph just below, with the same observable consequence (rotate-then-refuse, so a retry past the grace window trips reuse detection and logs oauth refresh token reuse detected; family revoked for ZITADEL-only sessions rather than stolen tokens). One clause here plus a sentence in that paragraph would make a rollback as pre-documented as the switch already is.

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 901fa08: the contract bullet now states the outside-principal-mode refresh rule (login-less session → invalid_grant), and the switch-day paragraph notes its rollback-day twin, including the same reuse-detection warnings.

claude[bot]
claude Bot previously approved these changes Oct 12, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (2 P3). Good to merge.

Both P3s from the previous round are addressed in d58da55 and the fixes hold up: refreshablePrincipal now refuses the login-less session outside principal mode (both the store and the in-memory path, with TestRefresh_CarriesThePrincipal driving each), and signinPrincipal rejects a resolver answer that is not a principal id, turning the former 500-at-/oauth/token into the documented 503 at sign-in. Every IssueJWT/IssueCode/IssueRefreshToken and refreshstore call site is threaded through; PrincipalMode and TenantAuthzMode both derive from cfg.tenantAuthzMode() at the one construction site, so the two cannot disagree. The pid claim stays confined to *localOAuthProvider (checkProviderContract plus ProviderPrincipal in reservedProviderNames), and no login-less refresh session can exist outside principal mode in the first place, so the new refusal cannot strand a legitimate client.

The two P3s are documentation: the switch-criterion sentence at docs/principals.md:442 is inverted ("records `pid`" should be "records no `pid`"), and the mode-by-mode contract at :387 writes down the principal-mode refresh refusal but not its new rollback-day twin.

Not run: go build/go vet/go test/gofmt — every go and gofmt invocation required approval in this sandbox and none was granted, so this is static reading plus cross-checking the called contracts (Store.LinkedGitHub, refreshstore.Store, Resolver.resolve/Forget, the /oauth/token error mapping). This is the third run that could not compile the diff; please allow go test, go vet and gofmt in this job's --allowedTools.

@mashkovd
mashkovd dismissed claude[bot]’s stale review October 12, 2026 23:53

The merge-base changed after approval.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit d58da55.

Findings

[P3] Missing test coverage for refreshablePrincipal in OAuthServer.RefreshAccessToken

  • File: internal/auth/oauth_server.go (approx. line 1590)
  • Anchor: +func (s *OAuthServer) refreshablePrincipal(login, principalID string) error {
  • Concrete failure scenario: When OAuthServer.RefreshAccessToken rotates a token, it calls refreshablePrincipal to refuse refresh requests when PrincipalMode == true and principalID == "" (pre-feat(unified-identity): authorize tenants by canonical principal instead of GitHub login #377 legacy sessions) or when !PrincipalMode and login == "" (principal-only sessions after a rollback). While the underlying database store methods test carrying the principal in internal/auth/refreshstore/postgres_test.go, in the modified test files internal/auth/oauth_server_test.go and internal/auth/oauth_server_groups_test.go, OAuthServer.RefreshAccessToken is only exercised under default settings with valid logins and empty principal IDs. No test visible in this diff verifies that OAuthServer.RefreshAccessToken rejects sessions with missing principal IDs when PrincipalMode = true or missing logins when PrincipalMode = false.
  • Proposed fix: In internal/auth/oauth_server_test.go, add unit test cases for RefreshAccessToken asserting that it returns "invalid or expired refresh token" when server.PrincipalMode = true and principalID is empty, as well as when server.PrincipalMode = false and login is empty.

@mashkovd

Copy link
Copy Markdown
Member Author

agy P3 on d58da55 (no test for refreshablePrincipal): refuted. TestRefresh_CarriesThePrincipal (internal/auth/token_principal_test.go) drives RefreshAccessToken over store∈{false,true} × principalMode∈{false,true} and asserts both refusals: the pid-less session in principal mode and the login-less session outside it. Both were mutation-verified (removing either clause fails the test).

@claude

claude Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 5m 44s —— View job


Review complete — PR #600

  • Gather context (pinned diff 0098678...901fa08, CLAUDE.md conventions)
  • Review correctness & logic
  • Review security (auth/authz)
  • Review concurrency
  • Review error handling
  • Review test coverage
  • Build / vet / test — could not run (see below)
  • Post inline findings
  • Submit verdict

Reviewed the pinned range git diff 0098678...901fa08 (24 files, +1340/-174). The new head commit 901fa08 is docs-only and answers both P3s from the previous round.

Findings: 0 P1, 0 P2, 1 P3 — approved, bound to 901fa08.

The docs fixes land

  • The mode-by-mode token contract (docs/principals.md:384-388) now carries the rollback-day refresh rule next to the switch-day one: outside principal mode a principal-only token is 401 and a login-less session's refresh answers invalid_grant.
  • The switch-criterion paragraph (:396-399) now says a rollback does to ZITADEL-only principals what the switch does to pre-feat(unified-identity): authorize tenants by canonical principal instead of GitHub login #377 sessions — refresh refused, same oauth refresh token reuse detected; family revoked warnings — so the rollback is as pre-documented as the switch.

The one P3 (posted inline, docs/principals.md:446)

The pid-coverage sentence is still ambiguous, now in the mirror direction. that records no pid sits next to the release, so it parses either as "the release that records no pid" (nonsense) or as "a live session ... that records no pid" (the intent). My own finding on this line last round was itself a mis-parse — the previous wording was correct under one reading and wrong under the other, and this one is the inverse. Hoisting the clause ("a live session that records no pid — one minted before this release — is refused…") settles it either way. Non-blocking.

Correcting an informational finding above

The agy P3 claiming nothing tests refreshablePrincipal through RefreshAccessToken is mistaken — it looked only at oauth_server_test.go. TestRefresh_CarriesThePrincipal (internal/auth/token_principal_test.go:243) drives store ∈ {false,true} × principalMode ∈ {false,true} and asserts the refusal for both clauses: the login-less session outside principal mode (:256-263) and the pid-less one inside it (:282-289).

What I re-verified directly this round

  • refreshablePrincipal cannot strand a legitimate client. IssueCode is reached with an empty login only under TenantAuthzPrincipal (both callbacks), so outside the mode a login-less refresh row exists only after a rollback — exactly the case refused. Both refresh paths guard before minting; the error is the same literal the in-memory path already used, which handleOAuthToken maps to invalid_grant.
  • zitadelOnly matches LinkedGitHub's real contract. Read internal/principals/signin.go:104-120: ErrIdentityRefused and ErrPrincipalDisabled return p == nil and come before the three no-single-login sentinels, so a disabled principal still 403s on the linked path without reaching signinPrincipal, and a refusal that must name a principal and doesn't falls to 503 store_unavailable. The store/resolver disagreement guard refuses to guess.
  • The new principal identity shape never reaches provisioning. All three other User.Identity() consumers (internal/auth/principal_authz.go:100, internal/api/tenant_role.go:111, internal/api/tenant_authz_shadow.go:130) read only ok and Kind; AttachPrincipal short-circuits to checkTokenPrincipal before ResolvePrincipal, so resolve() is never handed a ProviderPrincipal identity to Provision.
  • The claim is never trusted unchecked. ProviderPrincipal is in reservedProviderNames, and checkProviderContract confines both the namespace (principalTokenOf) and Claims.TokenPrincipal to *localOAuthProvider — whose own Name() is github, so no MCTL_OIDC_PROVIDERS entry can declare principal. checkTokenPrincipal clears principalID on every failure (including a resolver that is not a PrincipalChecker, a nil interface so no typed-nil deref), leaving PrincipalAuthz.Apply to answer 503 rather than "member of nothing".
  • sub stays unambiguous: ValidPrincipalID is prn_ plus 26 Crockford base32 (principal_authz.go:37), which no GitHub login can match, in either mode or an older build.
  • Call-site completeness: every IssueCode/IssueJWT/IssueRefreshToken caller is the two callbacks plus ExchangeCode/RefreshAccessToken, all threaded through; GitHubValidator is a concrete type whose Validate is a thin wrapper over the new ValidateIdentity, so the callback's error behaviour is unchanged. oauthServer.PrincipalMode (cmd/api/main.go:185) and Options.TenantAuthzMode (:943) both come from cfg.tenantAuthzMode(), so the two flags cannot disagree.
  • No new concurrency surface: the cachedLookup refactor preserves the mutex discipline, the gen guard, the refusal-never-cached rule and FailureTTL; CheckPrincipal's principal|<prn> key cannot collide with ResolvePrincipal's, and its entries record principal: p.ID so Forget drops them too. The append(logAttrs, …) is on a fresh variadic slice per call.
  • Metrics single-counted on both callbacks (GitHub Incs inline and returns; ZITADEL sets result for its deferred counter), and the store-path rotate-then-refuse consequence is written down rather than left to be discovered.

Not run

go build, go vet, go test, gofmt — every go and gofmt invocation required approval in this sandbox and none was granted (including as a bare command with no pipe), so this review is static reading plus cross-checking the called contracts. This is the fourth run in a row that could not compile the diff; please allow go test, go vet and gofmt in this job's --allowedTools.
· branch feat/377-zitadel-only

Comment thread docs/principals.md

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.

claude[bot]
claude Bot previously approved these changes Oct 13, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (1 P3). Good to merge.

The head commit 901fa08 is docs-only and lands both P3s from the previous round: the mode-by-mode token contract now carries the rollback-day refresh rule alongside the switch-day one, and the switch-criterion paragraph spells out that a rollback does to ZITADEL-only sessions what the switch does to pre-#377 ones. The remaining P3 is that the pid-coverage sentence is still ambiguous in the mirror direction — my own prior finding on that line was a mis-parse, which I say so inline.

Re-verified the code this round rather than resting on the earlier passes. refreshablePrincipal(login, principalID) guards both directions on both refresh paths; TestRefresh_CarriesThePrincipal (internal/auth/token_principal_test.go:243) drives store x principalMode and asserts the refusal for the login-less session outside principal mode and the pid-less one inside it — so the informational P3 above claiming no test covers RefreshAccessToken is mistaken; it looked only at oauth_server_test.go. No legitimate caller is stranded: IssueCode is reached with an empty login only under TenantAuthzPrincipal, so a login-less refresh row exists outside the mode only after a rollback.

Also confirmed directly: LinkedGitHub (internal/principals/signin.go:104-120) returns ErrIdentityRefused/ErrPrincipalDisabled with p == nil and ahead of the three no-single-login sentinels, so zitadelOnly matches its contract exactly and a disabled principal still 403s on the linked path. The new ProviderPrincipal identity shape never reaches provisioning: all three other User.Identity() consumers (principal_authz.go:100, tenant_role.go:111, tenant_authz_shadow.go:130) read only ok and Kind, and AttachPrincipal short-circuits to checkTokenPrincipal before ResolvePrincipal. checkProviderContract confines both the principal namespace and Claims.TokenPrincipal to *localOAuthProvider, and ValidPrincipalID is prn_ plus 26 Crockford base32, which no GitHub login can match. Every IssueCode/IssueJWT/IssueRefreshToken and refreshstore call site is threaded through, the two mode flags both derive from cfg.tenantAuthzMode() at the one construction site, the cachedLookup refactor preserves the mutex/gen/refusal-never-cached discipline, and the sign-in metric is single-counted on both callbacks. The store-path rotate-then-refuse consequence is written down rather than left to be discovered.

Not run: go build, go vet, go test, gofmt — every go and gofmt invocation required approval in this sandbox and none was granted, so this is static reading plus cross-checking the called contracts. This is the fourth run in a row that could not compile the diff; please allow go test, go vet and gofmt in this job's --allowedTools.

@mashkovd
mashkovd dismissed claude[bot]’s stale review October 13, 2026 00:00

The merge-base changed after approval.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 901fa08.

Findings

Finding 1

  • Severity: P3
  • File: internal/auth/oauth_flowstore_test.go:93
  • Anchor: + code, err := a.IssueCode(context.Background(), "dmitrii", "", "client-1", "https://client.example/callback",
  • Concrete Failure Scenario: IssueCode was updated across the codebase to take a principalID parameter and store it in authCodeEntry.PrincipalID. In TestFlowStoreCodeCrossesReplicas, IssueCode is called with an empty principal ID (""). No test visible in this diff verifies that a non-empty principalID stored in FlowStore on one replica is correctly preserved and minted into the token's pid claim when ExchangeCode is called on a different replica. If authCodeEntry.PrincipalID were corrupted, dropped, or misencoded during serialization across replicas, cross-replica code exchanges would yield tokens without a pid claim, which are rejected under TenantAuthzPrincipal mode.
  • Proposed Fix: In TestFlowStoreCodeCrossesReplicas (or an adjacent test case in oauth_flowstore_test.go), pass a valid principal ID (e.g., "prn_01JTESTAAAAAAAAAAAAAAAAAAA") to a.IssueCode, exchange the code using b.ExchangeCode, and verify that the issued access token carries the expected pid claim.

@mashkovd

Copy link
Copy Markdown
Member Author

Remaining P3s on 901fa08 deferred to slice 2d (gate is 0 P1/P2): the switch-criterion sentence wording at docs/principals.md:446 (reword to put "that records no pid" next to "session"), and agy's cross-replica pid round-trip test for the flow store (authCodeEntry.PrincipalID is an exported field serialized with the rest of the entry, so the risk is low).

@claude

claude Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 6m 9s —— View job


Review complete — PR #600

  • Gather context (pinned diff efb2a10...ebdbeb5, CLAUDE.md conventions)
  • Review correctness & logic
  • Review security (auth/authz)
  • Review concurrency
  • Review error handling
  • Review test coverage
  • Build / vet / test — could not run (see below)
  • Post inline findings
  • Submit verdict

Reviewed git diff efb2a10...ebdbeb5 (9 files, +274/-36). The pinned base is now the #598 merge, so this range is the slice itself.

Findings: 0 P1, 0 P2, 1 P3 — approved, bound to ebdbeb5.

The earlier rounds' P3s are answered, and the fixes hold up

  • refreshablePrincipal(login, principalID) refuses the login-less session outside principal mode as well as the pid-less one inside it, on both refresh paths. TestRefresh_CarriesThePrincipal drives store ∈ {false,true} × principalMode ∈ {false,true}, and the continue is scoped to the tc loop, so the pid-less assertion below it is not silently skipped.
  • signinPrincipal (internal/api/oauth_signin_principal.go:59) treats a resolver answer that is not a prn_ as a failed lookup, so the contract violation becomes the documented 503 at sign-in instead of a broken token after the code was handed over.
  • checkProviderContract (internal/auth/federation.go:369) now confines Claims.TokenPrincipal to *localOAuthProvider, alongside the principal namespace it already confined, with a table case driving both.
  • Docs carry the rollback-day refresh rule next to the switch-day one, and the /whoami id contract (a prn_…, not a GitHub login).

The one P3 (posted inline, internal/api/oauth_upstream.go:331)

The ZITADEL linked path is now the only sign-in that can mint a pid-less token in principal mode. signinPrincipal was just hardened against a resolver answering ("", nil); this line takes the mirror-image answer from the other boundary — Store.LinkedGitHub, an interface in OAuthZitadelOptions — on trust. An err == nil with an empty p.ID passes IssueCode (which only rejects a non-empty malformed id) and IssueJWT (which only errors when both login and principal are empty, and the login is non-empty here), so the holder gets a code, a successful exchange, and a session ValidateJWT refuses on every request. DB-sourced in practice, hence P3. The branch right below already states the rule in a comment and has a test for the refusal side; the success side has no equivalent.

What I verified directly this round

  • zitadelOnly matches LinkedGitHub's real contract (internal/principals/signin.go:106-119): ErrNotFound ⇒ p == nil; ErrIdentityRefused and ErrPrincipalDisabled come ahead of the three no-single-login sentinels and also return p == nil, so a disabled principal still 403s on the linked path without reaching signinPrincipal, and a refusal that must name a principal and doesn't falls to 503 store_unavailable. The store/resolver disagreement guard refuses to guess rather than minting for either.
  • The principal branch cannot exit with an empty prn. signinPrincipal reads the same h.opts, so inside that branch principalMode is necessarily true and every failure stops the sign-in; ("", "") is unreachable there.
  • The two mode flags cannot disagree. oauthServer.PrincipalMode (cmd/api/main.go:185) and Options.TenantAuthzMode (:943) both come from cfg.tenantAuthzMode(), and 185 is unconditional inside the one OAuthServer construction site.
  • The new login-less refresh refusal strands no legitimate client. The only IssueCode call reachable with an empty login is the principal-mode ZITADEL branch (the GitHub callback's login comes from ValidateIdentity), so outside the mode such a row exists only after a rollback — exactly the case refused. handleOAuthToken maps the error to invalid_grant, which is the sign-in-again signal.
  • Claims.TokenPID needs no equivalent guard: it is read only in the ProviderGitHub arm of userFromVerified, a reserved namespace exempt only for *localOAuthProvider and the GitHub Actions provider (whose Name() must be github-actions), and its sole effect is oauth_token_principal_mismatch_total.
  • sub stays unambiguous (ValidPrincipalID is prn_ + 26 Crockford base32, which no GitHub login can match), groups are skipped on every principal-mode mint and refresh path, and the sign-in metric is single-counted on both callbacks (GitHub Incs inline, ZITADEL sets result for its deferred counter).
  • The slice is inert: ParseTenantAuthzMode still refuses principal. The legacy ZITADEL tests (TestZitadelSigninOfAnUnlinkedUser, TestZitadelSigninRefusals, …) are untouched, which is what pins "nothing changes outside principal mode".
  • No new concurrency surface: nothing new is shared across goroutines, and the resolver's mutex/gen/failure-TTL discipline is not touched by this range.
  • Resolver provisioning on first sight adds no new authenticated-but-unauthorized surface: the ZITADEL bearer path already provisions via AttachPrincipal → ResolvePrincipal, and such a token carries no groups and no tenants.

Not run

go build, go vet, go test, gofmt — every go and gofmt invocation required approval in this sandbox and none was granted, so this review is static reading plus cross-checking the called contracts (Store.LinkedGitHub, refreshstore.Store, Resolver.cachedLookup/answer, IssueCode/IssueJWT, the /oauth/token error mapping). This is the fifth run in a row that could not compile the diff; please allow go test, go vet and gofmt in this job's --allowedTools.
· branch feat/377-zitadel-only

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit ebdbeb5.

No significant issues found.

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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (1 P3). Good to merge.

The pinned base is now the #598 merge, so this range is the slice itself (9 files, +274/-36). All three P3s from the earlier rounds are answered in it and the fixes hold up: refreshablePrincipal(login, principalID) refuses the login-less session outside principal mode on both refresh paths (TestRefresh_CarriesThePrincipal drives store x principalMode, with the continue scoped to the tc loop so no assertion is skipped); signinPrincipal treats a resolver answer that is not a prn_ as a failed lookup; checkProviderContract now confines Claims.TokenPrincipal to *localOAuthProvider alongside the principal namespace, with a table case for it; and the docs carry the rollback-day refresh rule and the /whoami id contract.

Verified this round rather than carried over: zitadelOnly matches LinkedGitHub's real contract (internal/principals/signin.go:106-119 — ErrNotFound with p == nil, ErrIdentityRefused/ErrPrincipalDisabled ahead of the three no-single-login sentinels, so a disabled principal still 403s without reaching signinPrincipal); the principal branch can only exit with a valid prn, since signinPrincipal sees the same h.opts and so stops on every failure in principal mode; PrincipalMode (cmd/api/main.go:185) and TenantAuthzMode (:943) both derive from cfg.tenantAuthzMode() at the one construction site, so sign-in cannot mint what ValidateJWT refuses; the new login-less refresh refusal strands no legitimate client, since the only IssueCode call reached with an empty login is the principal-mode ZITADEL branch; Claims.TokenPID stays harmless because it is read only in the ProviderGitHub arm of userFromVerified (a reserved namespace, exempt only for *localOAuthProvider) and only feeds the mismatch counter; and ParseTenantAuthzMode still refuses principal, so the slice is inert. No new concurrency surface.

P3 (posted inline, internal/api/oauth_upstream.go:331): the ZITADEL linked path is now the only sign-in that can mint a pid-less token in principal mode. It takes p.ID from the LinkedGitHub interface on trust, and an empty one passes IssueCode and IssueJWT (the login is non-empty), so the holder gets a code and a token that ValidateJWT then refuses forever — the mirror image of the resolver violation signinPrincipal just started catching.

The two P3s deferred to slice 2d (the pid-coverage wording at docs/principals.md:446 and agy's cross-replica pid round-trip test) are unchanged here and not re-raised.

Not run: go build, go vet, go test, gofmt — every go and gofmt invocation required approval in this sandbox and none was granted, so this is static reading plus cross-checking the called contracts. This is the fifth run in a row that could not compile the diff; please allow go test, go vet and gofmt in this job's --allowedTools.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants