Skip to content

feat(identity): tenant hint, eligible set and tenant switch exchange - #908

Merged
justinwilkin merged 9 commits into
docs/tenant-switchingfrom
feat/tenant-switching-identity
Nov 4, 2026
Merged

justinwilkin merged 9 commits into
docs/tenant-switchingfrom
feat/tenant-switching-identity

Conversation

@justinwilkin

@justinwilkin justinwilkin commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

What and why

Implements the identity half of Tenant Switching.

  • bindTenant takes a tenant_hint that chooses among several matching tenants. A hint never adds a tenant the claims do not match.
  • The federated session response carries the eligible set with display names.
  • 409 multiple_tenants_match and 403 tenant_switch_denied list the matching tenants.
  • An oidc_id_token exchange with replaces_refresh_token is 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 logs tenant switched.
  • scripts/auth-device.sh sends --tenant-id as the hint and lists the matches on 409.
  • docs/guides/tenants.md describes the picker, the switcher and the CLI re-run.

How it was tested

  • Unit tests cover the binding rules, every switch denial path (nothing is written) and the revoke order.
  • On the local cluster with two tenants bound to different groups:
    • A user in both groups reaches the picker and signs in to each tenant.
    • A switch revokes the old session and writes the log line.
    • A refused switch keeps the session.
    • The device flow returns 409 with the matches and succeeds with --tenant-id.
  • The silent prompt=none switch 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:

Picker on the cluster After switch Stale consent refused

Checklist

@justinwilkin
justinwilkin requested a review from a team as a code owner November 4, 2026 03:41
@justinwilkin
justinwilkin added this pull request to stack #910 November 4, 2026 03:42

@runkora runkora Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread backend/internal/identity/service.go Resolved
@runkora

runkora Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Visual Walkthrough

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

A 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
Loading
Node Code Symbols
Controller handlers.go handleSessionError, eligibleDetails
Service service.go ExchangeIDToken, replacedSession, createSession
Session store session_repository.go SwitchSession, revokeSession, insertSession
Token blocklist service.go finishSwitch
Tenant resolution and error mapping

A 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
Loading

Legend: green = added, amber = changed, red = removed, dashed grey = context

Node Code Symbols
Tenant binding service.go bindTenant, idTokenClaims
Eligible set models.go EligibleTenant, TenantMatchError
Live token check service.go replacedSession, liveRefreshSession, refreshNotLive
Denial service.go denySwitch, ErrTenantSwitchDenied
Switch commit session_repository.go SwitchSession, revokeSession
Error mapping handlers.go handleSessionError
Error details handlers.go eligibleDetails
Script auth-device.sh ISSUED_TENANT

Copilot AI balanced review requested due to automatic review settings November 4, 2026 04:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment thread backend/internal/identity/service.go Resolved

@runkora runkora Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@justinwilkin
justinwilkin force-pushed the feat/tenant-switching-identity branch from aad990d to 375b272 Compare November 4, 2026 05:14
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>
@justinwilkin
justinwilkin force-pushed the feat/tenant-switching-identity branch from e26a658 to 651f292 Compare November 4, 2026 06:04
@justinwilkin
justinwilkin requested review from kora-agent and removed request for kora-agent November 4, 2026 06:05

@runkora runkora Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread backend/internal/identity/service.go Resolved
@justinwilkin
justinwilkin added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit a5edfb6 Nov 4, 2026
44 checks passed
@justinwilkin
justinwilkin deleted the feat/tenant-switching-identity branch November 4, 2026 06:30
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.

3 participants