Repository navigation
feat(identity): tenant hint, eligible set and tenant switch exchange - #908
Conversation
There was a problem hiding this comment.
Walkthrough
Adds tenant_hint and an eligible tenant set to federated sign-in. Adds a tenant switch exchange that revokes the replaced session, with CLI and guide updates.
| Severity | Finding | Location |
|---|---|---|
| Switch can leave two live sessions | backend/internal/identity/service.go:577 |
Review effort: Medium
Risk: High, touches session issuance and revocation
Coverage: 9 of 9 files reviewed
Where to focus
| Area | Why it needs a person |
|---|---|
identity/service.go |
Decide ordering of session creation and revocation on a switch. |
scripts/auth-device.sh |
Confirm the error body fields .code and .message match the API. |
Changes by area
| Area | Files | Change |
|---|---|---|
backend/internal/identity |
7 | Tenant hint, eligible set, switch exchange, tests |
docs/guides, scripts |
2 | Guide and CLI updates |
Checked
Tenant binding, error mapping, switch denial checks.
Visual WalkthroughThe change adds a tenant hint and eligible set to ID token sign-in, and a tenant switch commits the new session and consumes the replaced token in one transaction. Tenant switch exchangeA switch is denied at each refusal, and a committed switch returns the eligible tenants. sequenceDiagram
participant C as Client
participant H as Controller
participant S as Service
participant D as Session store
participant B as Token blocklist
C->>H: exchange id_token with hint and replaced token
H->>S: ExchangeIDToken
S->>S: bind tenant from claims and hint
alt claims cannot be read
S-->>H: error
H-->>C: error response
else no match, or several the hint does not pick, without switch
S-->>H: claim mismatch or multiple match
H-->>C: 403 not a member or 409 with eligible set
else switch with no match, several, or tenant not the hint
S-->>H: tenant switch denied with eligible set
H-->>C: 403 tenant_switch_denied
else switch with bound tenant the hint
S->>D: look up replaced token and session
alt token or session not live, or wrong subject
S-->>H: tenant switch denied
H-->>C: 403 tenant_switch_denied
else live and owned by the subject
S->>D: commit switch in one transaction
alt token consumed or session revoked at commit
D-->>S: not found, nothing written
S-->>H: tenant switch denied
H-->>C: 403 tenant_switch_denied
else write fails
D-->>S: error
S-->>H: persist error
H-->>C: error response
else committed
D-->>S: committed
S->>B: block replaced access token
Note over S,B: blocklist failure is logged only
S-->>H: session response with eligible tenants
H-->>C: session response
end
end
end
Tenant resolution and error mappingA bound tenant that is not the hint, or a replaced token that is no longer live, denies the switch, and each denial returns the eligible set. flowchart TB
subgraph svc["service.go"]
bind["bindTenant binds one tenant from claims"]:::changed
eligible["eligible set of matching tenants"]:::added
live["replacedSession checks live token and subject"]:::changed
deny["denySwitch returns tenant switch denied"]:::added
end
subgraph store["session_repository.go"]
commit["SwitchSession revokes token and session"]:::changed
end
subgraph api["handlers.go"]
mapper["handleSessionError maps refusal to 403 or 409"]:::changed
body["eligibleDetails adds tenants to error details"]:::added
end
subgraph cli["scripts/auth-device.sh"]
script["script lists tenants and warns on issued tenant"]:::changed
end
bind --> eligible
eligible -->|"switch refused"| deny
eligible -->|"hint matches"| live
eligible -->|"sign-in refused"| mapper
live -->|"not live"| deny
live --> commit
commit -->|"consumed or revoked"| deny
deny --> mapper
mapper --> body
body --> script
classDef added fill:#1f883d,stroke:#1a7f37,stroke-width:2px,color:#ffffff
classDef changed fill:#bf8700,stroke:#9a6700,stroke-width:2px,color:#ffffff
classDef removed fill:#cf222e,stroke:#a40e26,stroke-width:2px,stroke-dasharray:5,color:#ffffff
classDef context fill:transparent,stroke:#8c959f,stroke-width:1px,stroke-dasharray:3,color:#8c959f
linkStyle default stroke:#8c959f,stroke-width:2px
style svc fill:transparent,stroke:#8c959f
style store fill:transparent,stroke:#8c959f
style api fill:transparent,stroke:#8c959f
style cli fill:transparent,stroke:#8c959f
Legend: green = added, amber = changed, red = removed, dashed grey = context
|
There was a problem hiding this comment.
🟡 Changes recommended
Critical races can leave usable sessions in the old tenant after a switch, and failed access-token revocation can leave the replaced token valid until expiry.
2 open issues
What changed in this PR
Implements the identity backend for tenant switching: users can select among tenants matched by verified claims, and switch exchanges replace their existing session without expanding tenant eligibility.
Changes:
- Adds tenant hints, eligible tenant display names, and structured selection and denial errors.
- Adds transactional session replacement, access-token blocklisting, and switch logging.
- Adds unit and PostgreSQL tests, CLI tenant selection, and usage documentation.
| File | Description |
|---|---|
scripts/auth-device.sh |
Sends tenant hints and displays tenant selection errors. |
docs/guides/tenants.md |
Documents tenant selection, switching, and CLI usage. |
backend/internal/identity/tenant_switch_test.go |
Tests hints, switch outcomes, errors, and device authentication. |
backend/internal/identity/tenant_registry.go |
Retains tenant display names for eligible sets. |
backend/internal/identity/span_errors.go |
Updates tracing error sentinels. |
backend/internal/identity/session_repository.go |
Adds transactional session replacement. |
backend/internal/identity/session_postgres_test.go |
Tests transaction commits, rollback, and concurrent switches. |
backend/internal/identity/session_authoring_test.go |
Adapts tests to the session creation signature. |
backend/internal/identity/service.go |
Implements tenant selection, switch validation, and replacement. |
backend/internal/identity/oauth_service.go |
Adapts OAuth session creation calls. |
backend/internal/identity/models.go |
Adds switch request fields and eligible tenant response types. |
backend/internal/identity/mocks_test.go |
Adds switch behavior to repository mocks. |
backend/internal/identity/handlers.go |
Returns eligible tenants in selection and denial errors. |
backend/internal/identity/claim_binding_test.go |
Updates fixtures and binding error assertions. |
AGENTS.md |
Documents the identity PostgreSQL test suite. |
Review effort: Balanced
There was a problem hiding this comment.
Walkthrough
The new commits move tenant switch writes into one Postgres transaction that consumes the replaced refresh token. The CLI now warns when it signs in to a different tenant than requested.
No new findings.
Fixed since the last review: Switch can leave two live sessions.
Review effort: Medium
Risk: High, touches session issuance and revocation
Coverage: 10 of 10 files reviewed
Where to focus
| Area | Why it needs a person |
|---|---|
identity/session_repository.go |
Confirm the SwitchSession guards exclude concurrent switches and refreshes. |
identity/service.go |
Confirm logging only on blocklist failure is acceptable after commit. |
Changes by area
| Area | Files | Change |
|---|---|---|
backend/internal/identity |
8 | Transactional switch, claims helper, tests |
scripts, AGENTS.md |
2 | CLI tenant warning, test table row |
Checked
Switch transaction, session creation refactor, CLI warning.
aad990d to
375b272
Compare
Implements docs/adr/2026-11-04-tenant-switching.md for identity and the device script. bindTenant takes a tenant_hint that chooses among several matching tenants, and the federated session response carries the eligible set. The 409 multiple_tenants_match and 403 tenant_switch_denied responses list the matching tenants with display names. An oidc_id_token exchange with replaces_refresh_token is a switch: identity checks the bound tenant and the replaced session before any write, then creates the new session, revokes the replaced one and logs the switch. auth-device.sh sends --tenant-id as the hint and lists the matches on 409. Signed-off-by: Justin Wilkin <justin@runkora.io>
On a switch, a binding mismatch or several matches is denied in ExchangeIDToken, so replacedSession only checks the hint and the replaced session, and a claims extraction error keeps the binding span sentinel. Denied switch tests now also assert that nothing is written to the JTI blocklist. auth-device.sh omits the parentheses for a tenant without a display name. Signed-off-by: Justin Wilkin <justin@runkora.io>
The guide now says a browser user picks a tenant at sign-in and switches in the dashboard, and that the CLI prints the matches and asks for a re-run with --tenant-id. The 409 field is named display_name as in the API. Signed-off-by: Justin Wilkin <justin@runkora.io>
The new session and refresh token and the revocation of the replaced session and its refresh tokens now commit together through SessionRepository's SwitchSession, so a failure partway leaves the person signed in to the old tenant with no orphan session. The conditional revoke also lets only one of several concurrent switches from a session commit. The replaced access token is blocklisted after commit; a blocklist failure is logged and the switch still succeeds. The switch log entry is written after commit. ExchangeIDToken and PollDeviceAuth share one idTokenClaims type for the principal they build. Signed-off-by: Justin Wilkin <justin@runkora.io>
auth-device.sh decodes kora.tenant_id from the issued token and, when it differs from --tenant-id, prints the issued tenant with a warning. An account that matches one tenant is signed in there whatever the hint names. Signed-off-by: Justin Wilkin <justin@runkora.io>
Refresh rotation creates a new session row and leaves the replaced one live, so a refresh that consumed the replaced token after the switch checks still let the switch commit, leaving a live session in the old tenant. The switch transaction now consumes the replaced refresh token with the guard rotation uses, unrevoked and unexpired. A token already consumed or expired is ErrSessionNotFound, which the exchange returns as tenant_switch_denied, and nothing is written. Signed-off-by: Justin Wilkin <justin@runkora.io>
… the switch Signed-off-by: Justin Wilkin <justin@runkora.io>
Signed-off-by: Justin Wilkin <justin@runkora.io>
Signed-off-by: Justin Wilkin <justin@runkora.io>
e26a658 to
651f292
Compare
There was a problem hiding this comment.
Walkthrough
The new commits share session and refresh token statements between the repositories and the switch transaction. They also merge the refresh liveness checks into one helper and simplify the CLI tenant warning.
| Severity | Finding | Location |
|---|---|---|
| Limit switch denial to switches | backend/internal/identity/service.go:549 |
Review effort: Medium
Risk: High, touches session issuance and revocation
Coverage: 9 of 9 files reviewed
Where to focus
| Area | Why it needs a person |
|---|---|
identity/service.go |
Confirm refreshNotLive mapping applies only to switches. |
identity/refresh_token_repository.go |
Switch no longer re-checks token expiry inside the transaction. |
Checked
Shared statements, liveRefreshSession, denySwitch, CLI warning.

What and why
Implements the identity half of Tenant Switching.
bindTenanttakes atenant_hintthat chooses among several matching tenants. A hint never adds a tenant the claims do not match.409 multiple_tenants_matchand403 tenant_switch_deniedlist the matching tenants.oidc_id_tokenexchange withreplaces_refresh_tokenis a switch. Identity checks the bound tenant and the replaced session before any write. On success it creates the new session, revokes the replaced one and logstenant switched.scripts/auth-device.shsends--tenant-idas the hint and lists the matches on 409.docs/guides/tenants.mddescribes the picker, the switcher and the CLI re-run.How it was tested
--tenant-id.prompt=noneswitch is untested locally (local Dex ignores it). Entra is the first real test.Screenshots
From the local cluster on an earlier frontend build. The picker after a
409 multiple_tenants_match, the dashboard after a switch to the other tenant, and the consent page refusing a decision from a tab opened before the switch:Checklist
make checkpasses.