From 629466d42e94389cf46aa102dbce63660f04c0be Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 12 May 2026 13:11:37 -0400 Subject: [PATCH 1/3] spec(security): add SSO authentication specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define desired state for migrating from OpenShift OAuth proxy to direct SSO/JWT authentication. Key decisions: - BFF pattern: Next.js as OIDC confidential client, browser gets session cookie - K8s impersonation: backend SA + Impersonate-User/Group preserves RBAC - Dual-path auth: JWT first, TokenReview fallback for API keys - Feature-flagged migration for incremental rollout - Supersedes ADR-0002 (raw token passthrough → impersonation) Includes migration workflow with consumer impact map and implementation notes. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/security/sso-authentication.spec.md | 414 +++++++++++++++++++ workflows/security/sso-migration.workflow.md | 155 +++++++ 2 files changed, 569 insertions(+) create mode 100644 specs/security/sso-authentication.spec.md create mode 100644 workflows/security/sso-migration.workflow.md diff --git a/specs/security/sso-authentication.spec.md b/specs/security/sso-authentication.spec.md new file mode 100644 index 000000000..a85a45e01 --- /dev/null +++ b/specs/security/sso-authentication.spec.md @@ -0,0 +1,414 @@ +# SSO Authentication Specification + +## Purpose + +The platform SHALL authenticate all human users via OpenID Connect (OIDC) with Red Hat +SSO and represent user identity as signed JWTs throughout the stack. This +replaces the current model where an OpenShift OAuth proxy sidecar produces opaque tokens +that are forwarded to backends. + +The migration unifies the authentication model: every component that needs to know "who +is this user?" validates a JWT against the SSO issuer's JWKS endpoint — no component +relies on opaque tokens, OAuth proxy headers, or Kubernetes TokenReview for human user +identity. + +## Identity Flow + +``` +Browser ──OIDC session cookie──▸ Next.js (BFF) ──JWT──▸ Backend / API Server + │ │ + │ ├─ Validate JWT (JWKS) + │ ├─ Extract identity (claims) + │ └─ K8s client: SA token + │ + Impersonate-User + │ + Impersonate-Group + ▼ │ + Red Hat SSO K8s API Server + (confidential client) (RBAC as impersonated user) +``` + +## Requirements + +### Requirement: BFF OIDC Session Model + +The frontend SHALL act as an OIDC confidential client using the Authorization Code Flow. +The browser SHALL receive an opaque, httpOnly, secure, SameSite OIDC session cookie — +never a raw JWT. The frontend server SHALL exchange the OIDC session for a JWT when +proxying requests to backend services. + +#### Scenario: User login + +- GIVEN a user navigates to the platform +- WHEN they are not authenticated +- THEN the frontend redirects to the SSO authorization endpoint +- AND the SSO login page is displayed + +#### Scenario: OIDC callback + +- GIVEN the user completes SSO authentication +- WHEN SSO redirects to the frontend callback URL +- THEN the frontend exchanges the authorization code for tokens +- AND stores the OIDC session server-side +- AND sets an httpOnly, secure, SameSite cookie on the browser + +#### Scenario: Authenticated API request + +- GIVEN a user with a valid OIDC session cookie +- WHEN the browser makes an API request to the frontend +- THEN the frontend extracts the JWT from the server-side OIDC session +- AND forwards it as `Authorization: Bearer ` to the upstream backend + +#### Scenario: Token refresh + +- GIVEN a user's access token has expired but the refresh token is valid +- WHEN the user makes a request +- THEN the frontend refreshes the access token using the refresh token +- AND the OIDC session is updated transparently + +#### Scenario: Logout + +- GIVEN a user clicks logout +- WHEN the logout request is processed +- THEN the frontend destroys the server-side OIDC session +- AND clears the OIDC session cookie +- AND redirects to the SSO logout endpoint for single sign-out + +### Requirement: JWT Validation + +Every backend service that receives a user request SHALL validate the JWT before +processing. Validation SHALL verify: signature against the SSO issuer's JWKS endpoint, +`exp` (expiration), `iss` (issuer), and `aud` (audience). Services MUST reject tokens +that fail any check with HTTP 401. + +#### Scenario: Valid JWT accepted + +- GIVEN a request with a valid, unexpired JWT signed by the SSO issuer +- WHEN the backend receives the request +- THEN the request is processed normally +- AND user identity is extracted from standard OIDC claims (`sub`, `email`, `preferred_username`, `groups`) + +#### Scenario: Expired JWT rejected + +- GIVEN a request with an expired JWT +- WHEN the backend receives the request +- THEN the backend returns 401 Unauthorized + +#### Scenario: Wrong audience rejected + +- GIVEN a JWT with an `aud` claim that does not match the service's expected audience +- WHEN the backend receives the request +- THEN the backend returns 401 Unauthorized + +#### Scenario: Tampered JWT rejected + +- GIVEN a JWT with a modified payload but original signature +- WHEN the backend receives the request +- THEN signature verification fails +- AND the backend returns 401 Unauthorized + +#### Scenario: JWKS key rotation + +- GIVEN the SSO issuer rotates its signing keys +- WHEN a JWT signed with the new key is received +- THEN the backend fetches the updated JWKS +- AND validates the JWT against the new key + +### Requirement: K8s Authorization via Impersonation + +The legacy backend SHALL use its own ServiceAccount token for all Kubernetes API calls +and SHALL set impersonation headers to represent the authenticated user's identity. +K8s RBAC SHALL evaluate permissions as the impersonated user, preserving all existing +per-user RoleBindings and SelfSubjectAccessReview checks. + +The backend ServiceAccount SHALL have a ClusterRole granting the `impersonate` verb on +`users`, `groups`, and `serviceaccounts` resources. The `serviceaccounts` resource is +required because API key tokens represent K8s ServiceAccount identities. + +#### Scenario: List resources respects user RBAC + +- GIVEN a user with access to Project A but not Project B +- WHEN the user lists AgenticSessions +- THEN the backend sets `Impersonate-User` to the user's identity from JWT claims +- AND K8s returns only AgenticSessions in Project A +- AND AgenticSessions in Project B are not visible + +#### Scenario: Create resource with RBAC check + +- GIVEN a user with `create` permission for AgenticSessions in a Project +- WHEN the user creates an AgenticSession +- THEN the backend validates the JWT +- AND sets impersonation headers on the K8s client +- AND the SSAR succeeds because the user has the required RoleBinding +- AND the backend creates the resource using its SA (existing pattern) + +#### Scenario: Unauthorized create rejected + +- GIVEN a user without `create` permission for AgenticSessions in a Project +- WHEN the user attempts to create an AgenticSession +- THEN the backend sets impersonation headers on the K8s client +- AND the SSAR fails +- AND the backend returns 403 Forbidden + +#### Scenario: Audit trail preserved + +- GIVEN a user performs an operation via impersonation +- WHEN K8s audit logging records the API call +- THEN the audit log entry includes the impersonated user identity +- AND the acting ServiceAccount identity + +#### Scenario: Impersonation RBAC enforced + +- GIVEN the backend ServiceAccount +- WHEN the SA attempts to impersonate a user +- THEN K8s verifies the SA has the `impersonate` verb on the appropriate resource +- AND the impersonation succeeds only if the RBAC binding exists + +### Requirement: SSAR Compatibility + +SelfSubjectAccessReview (SSAR) calls SHALL work identically under impersonation. The +backend SHALL issue SSARs via K8s clients configured with impersonation headers so that +K8s evaluates the impersonated user's permissions, not the ServiceAccount's permissions. + +The SSAR result cache SHALL include the impersonated user identity in the cache key. +Under impersonation, the bearer token is the backend ServiceAccount's token (shared +across all requests), so caching by token alone would cause cross-user authorization +leaks. + +#### Scenario: SSAR with impersonation + +- GIVEN a user authenticated via JWT with email `user@example.com` +- WHEN the backend performs an SSAR to check if the user can list AgenticSessions in namespace `project-a` +- THEN the K8s client is configured with `Impersonate-User: user@example.com` +- AND K8s evaluates the SSAR against `user@example.com`'s RoleBindings +- AND the result reflects the user's actual permissions + +#### Scenario: SSAR cache isolation + +- GIVEN user A and user B both make requests +- WHEN the backend caches SSAR results +- THEN user A's cached result is NOT returned for user B +- AND cache keys include the impersonated identity + +### Requirement: API Key Authentication + +API keys (K8s ServiceAccount tokens) SHALL continue to be accepted as an alternative +to SSO JWTs. When the backend receives a bearer token that is not a valid JWT (fails +JWT parsing), it SHALL fall back to Kubernetes TokenReview to validate the token as a +ServiceAccount token. API key identity SHALL be resolved from the ServiceAccount's +annotations (existing pattern). + +This dual-path authentication is required because API keys are minted as K8s +ServiceAccount tokens and cannot be replaced with SSO JWTs. + +#### Scenario: API key accepted + +- GIVEN a request with a valid K8s ServiceAccount token (API key) +- WHEN the backend receives the request +- THEN JWT validation fails (token is not a JWT) +- AND the backend falls back to TokenReview +- AND the token is validated as a K8s ServiceAccount +- AND user identity is resolved from the ServiceAccount's annotations + +#### Scenario: API key impersonation + +- GIVEN a validated API key with a resolved user identity +- WHEN the backend makes K8s API calls +- THEN impersonation headers reflect the API key's associated user +- AND RBAC is enforced for that user + +#### Scenario: Invalid token rejected + +- GIVEN a token that is neither a valid JWT nor a valid K8s ServiceAccount token +- WHEN the backend receives the request +- THEN both JWT validation and TokenReview fail +- AND the backend returns 401 Unauthorized + +### Requirement: Identity Claim Mapping + +User identity SHALL be derived from JWT claims. The following standard OIDC claims +SHALL be used: + +| Claim | Maps to | Used for | +|-------|---------|----------| +| `sub` | User ID | Unique identifier, RoleBinding subjects | +| `email` | User email | Display, notifications, RoleBinding subjects | +| `preferred_username` | Username | Display, audit logs | +| `groups` | Group membership | Group-based RBAC, impersonation groups | + +The platform SHALL support configuring which claim is used for the K8s `Impersonate-User` +value. The default SHALL be `email` to match existing RoleBinding subjects that use +email addresses. + +#### Scenario: Identity extracted from JWT + +- GIVEN a JWT with claims `{"sub": "f:abc:jsell", "email": "jsell@redhat.com", "preferred_username": "jsell", "groups": ["team-ambient"]}` +- WHEN the backend processes the request +- THEN `Impersonate-User` is set to `jsell@redhat.com` +- AND `Impersonate-Group` is set to `["team-ambient"]` + +### Requirement: Runner Token Propagation + +The runner SHALL continue to receive the human user's token as `caller_token` via the +`x-caller-token` header on AG-UI interactions. With SSO authentication, `caller_token` +is a JWT. The runner uses `caller_token` only for API server HTTP calls (credential +fetches, feedback), never for direct K8s API calls. The runner's own K8s access SHALL +continue to use its per-session ServiceAccount bot token. + +#### Scenario: caller_token is a JWT + +- GIVEN a user interacts with a running session via AG-UI +- WHEN the frontend proxies the interaction to the runner +- THEN the `x-caller-token` header contains the user's SSO JWT +- AND the runner uses it for credential fetch calls +- AND the runner falls back to `BOT_TOKEN` if the caller token is expired + +### Requirement: CLI Authentication + +The CLI SHALL authenticate via OIDC Authorization Code Flow with PKCE against the SSO +issuer. The CLI SHALL store the refresh token for automatic token renewal. The CLI +is a public client (it cannot hold a client secret). + +#### Scenario: CLI login + +- GIVEN a user runs the CLI login command +- WHEN the CLI initiates the OIDC flow +- THEN it opens the user's browser to the SSO authorization endpoint with PKCE challenge +- AND listens for the callback on a local port +- AND exchanges the authorization code for tokens +- AND persists the access token and refresh token + +#### Scenario: CLI token refresh + +- GIVEN a user's CLI access token has expired +- WHEN the user runs any CLI command +- THEN the CLI refreshes the token using the stored refresh token +- AND updates the stored tokens + +### Requirement: Local Development Authentication + +The platform SHALL support local development on Kind clusters without requiring an +SSO instance. A development mode SHALL allow authentication via: + +1. A static JWT token generated from a local JWKS (for automated testing) +2. A mock identity mode that bypasses JWT validation (for rapid iteration) + +Mock identity mode MUST NOT be available in production deployments. + +#### Scenario: Kind cluster with test JWT + +- GIVEN a Kind cluster with the backend configured with a local JWKS +- WHEN a developer generates a test JWT signed by the local JWKS key +- THEN the backend validates it against the local JWKS +- AND impersonation works with the claims in the test JWT + +#### Scenario: Mock identity mode + +- GIVEN `DISABLE_AUTH=true` is set +- WHEN a request arrives without a JWT +- THEN the backend uses a configurable mock identity +- AND impersonation is set to the mock user +- AND this mode MUST NOT be available in production deployments + +### Requirement: E2E Test Authentication + +End-to-end tests SHALL authenticate without requiring interactive SSO login. The +platform SHALL support a non-interactive authentication path for test automation. + +#### Scenario: E2E test with client_credentials grant + +- GIVEN an E2E test environment with an SSO service account (client_credentials client) +- WHEN the test suite starts +- THEN it obtains a JWT via the client_credentials grant +- AND uses the JWT for all API requests during the test run + +#### Scenario: E2E test with pre-generated JWT + +- GIVEN a test environment with a local JWKS +- WHEN the test suite starts +- THEN it uses a pre-generated JWT signed by the local JWKS key +- AND the backend validates it normally + +#### Scenario: E2E token not exposed to browser + +- GIVEN the E2E test authentication token +- WHEN the test framework injects the token +- THEN the token SHALL be injected server-side (via cookie or API route) +- AND SHALL NOT be exposed as a browser-accessible environment variable + +### Requirement: Feature-Flagged Migration + +The transition from OAuth proxy to SSO authentication SHALL be gated behind a feature +flag. During migration, the platform SHALL support both authentication modes +simultaneously. The feature flag SHALL control which authentication path is active +per deployment. + +#### Scenario: Legacy mode (flag off) + +- GIVEN the SSO auth feature flag is disabled +- WHEN a request arrives with an OAuth proxy header +- THEN the backend uses the existing OAuth proxy flow +- AND K8s calls use the opaque token directly as a bearer token + +#### Scenario: SSO mode (flag on) + +- GIVEN the SSO auth feature flag is enabled +- WHEN a request arrives with `Authorization: Bearer ` +- THEN the backend validates the JWT against the JWKS endpoint +- AND K8s calls use impersonation + +#### Scenario: Flag removal + +- GIVEN the SSO auth migration is complete across all environments +- WHEN the feature flag is removed +- THEN all OAuth proxy code paths, forwarded header handling, and opaque token + support SHALL be removed +- AND the OAuth proxy sidecar manifests SHALL be deleted + +### Requirement: Manifest Changes + +The deployment manifests SHALL be updated to support the new authentication model. + +#### Scenario: OAuth proxy sidecar removed + +- GIVEN a production deployment with SSO auth enabled +- WHEN the frontend is deployed +- THEN no OAuth proxy sidecar container is present +- AND the frontend Service routes traffic directly to the Next.js container port + +#### Scenario: SSO client credentials provisioned + +- GIVEN a deployment with SSO auth enabled +- WHEN the frontend pod starts +- THEN a K8s Secret containing `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET`, and `SSO_ISSUER_URL` + is mounted into the frontend container + +#### Scenario: Backend impersonation RBAC provisioned + +- GIVEN a deployment with SSO auth enabled +- WHEN the backend pod starts +- THEN the backend ServiceAccount has a ClusterRoleBinding granting `impersonate` verb + on `users`, `groups`, and `serviceaccounts` resources + +## Design Decisions + +| Decision | Rationale | +|----------|-----------| +| BFF with confidential client (not public client in browser) | IETF recommendation for web apps. Tokens never reach the browser, eliminating XSS-based token theft. Next.js already acts as a proxy, making BFF natural. | +| K8s impersonation (not cluster OIDC federation) | Platform MUST work on any K8s cluster (Kind, ROSA classic, ROSA HCP) without cluster-level OIDC configuration. Impersonation is a standard K8s feature available everywhere. | +| `email` claim as default impersonation identity | Existing RoleBindings use email addresses as subject names. Using `email` preserves all existing RBAC bindings without migration. | +| Feature-flagged migration (not big-bang cutover) | Enables incremental rollout, environment-by-environment. Legacy OAuth proxy path remains available as fallback. | +| Supersede ADR-0002 (not amend) | ADR-0002's core assumption — the auth token is a K8s-native opaque token — is no longer true. The security contract (user operations use user permissions) is preserved; only the mechanism changes. | +| CLI remains a public client with PKCE | CLIs cannot securely store client secrets. PKCE provides equivalent security for native apps per RFC 7636. | +| Dual-path auth (JWT + TokenReview) | API keys are K8s ServiceAccount tokens that cannot be replaced with SSO JWTs. The backend tries JWT first, falls back to TokenReview, preserving both authentication paths. | +| SSAR cache includes impersonated identity | Under impersonation, the bearer token is shared (backend SA). Caching by token alone would leak authorization decisions across users. | +| E2E tokens injected server-side | Browser-exposed test tokens (via `NEXT_PUBLIC_*` env vars) are an XSS risk. Server-side injection via cookies or API routes prevents accidental token exposure. | + +## References + +- [Security Specification](security.spec.md) — identity boundaries, token propagation +- [K8s Client Usage Patterns](../standards/backend/k8s-client.spec.md) — user-scoped vs. SA client patterns +- [Security Standards](../standards/security/security.spec.md) — token handling, RBAC enforcement +- [ADR-0002](../../docs/internal/adr/0002-user-token-authentication.md) — superseded by this spec +- [OAuth 2.0 for Browser-Based Applications](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps) — BFF recommendation +- [K8s User Impersonation](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation) +- Migration workflow: `workflows/security/sso-migration.workflow.md` diff --git a/workflows/security/sso-migration.workflow.md b/workflows/security/sso-migration.workflow.md new file mode 100644 index 000000000..ee295ad64 --- /dev/null +++ b/workflows/security/sso-migration.workflow.md @@ -0,0 +1,155 @@ +# SSO Authentication Migration Workflow + +**Spec:** `specs/security/sso-authentication.spec.md` + +## Consumer Migration Map + +Every component that touches user authentication and what changes for each. + +| Consumer | Current behavior | New behavior | Key files | +|----------|-----------------|--------------|-----------| +| Frontend OAuth proxy sidecar | Injects `X-Forwarded-Access-Token`, `X-Forwarded-User`, etc. | Removed; Next.js handles OIDC directly | `manifests/components/oauth-proxy/` | +| Frontend `buildForwardHeadersAsync` | Reads `X-Forwarded-Access-Token` from request, forwards to upstream | Reads JWT from OIDC session, sets `Authorization: Bearer` | `src/lib/auth.ts` | +| Frontend logout | Redirects to `/oauth/sign_out` (OAuth proxy endpoint) | Redirects to Next.js signout → SSO logout | `src/components/navigation.tsx`, `src/app/projects/[name]/layout.tsx` | +| Backend `forwardedIdentityMiddleware` | Reads `X-Forwarded-User/Email/Groups` headers | Reads identity from validated JWT claims | `server/server.go` | +| Backend `GetK8sClientsForRequest` | Uses raw token as `cfg.BearerToken` | Validates JWT, uses SA token + impersonation | `handlers/middleware.go`, `handlers/k8s_clients_for_request_prod.go` | +| Backend SSAR cache | Keyed by `SHA256(token)` | Keyed by `SHA256(token) + impersonated-user` | `handlers/ssar_cache.go` | +| Backend API key auth | TokenReview on SA token | Unchanged — TokenReview is the fallback when JWT parsing fails | `handlers/middleware.go` | +| API server `forwarded_token.go` | Converts `X-Forwarded-Access-Token` to `Authorization` header | Passthrough — JWT arrives in `Authorization` already | `pkg/middleware/forwarded_token.go` | +| Public API `extractToken` | Falls back to `X-Forwarded-Access-Token` | `Authorization: Bearer` only | `handlers/middleware.go` | +| CLI `acpctl login` | OIDC auth code + PKCE against SSO, client ID `ocm-cli` | Same flow, dedicated client ID | `cmd/acpctl/login/cmd.go` | +| SDK (Go, Python) | Accepts token string, sets `Authorization: Bearer` | No change — token format is opaque to SDK | None | +| Runner `caller_token` | Receives opaque token or JWT via `x-caller-token` | Receives JWT via `x-caller-token` | No change — runner treats it as opaque bearer | +| Runner K8s access | Per-session SA bot token | Per-session SA bot token (unchanged) | None | +| E2E tests | Inject SA token via `NEXT_PUBLIC_E2E_TOKEN` (browser-exposed) | Inject test JWT server-side (cookie or API route) | `e2e/cypress/support/commands.ts`, `src/services/api/client.ts` | +| Per-user RoleBindings | `subjects[].name = "user@email.com"` | Same — impersonation uses same email string | None | + +## RBAC Changes + +### Backend ServiceAccount — new impersonation ClusterRole + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: backend-api-impersonator +rules: + - apiGroups: [""] + resources: ["users", "groups", "serviceaccounts"] + verbs: ["impersonate"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: backend-api-impersonator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: backend-api-impersonator +subjects: + - kind: ServiceAccount + name: backend-api + namespace: ambient-code +``` + +## Backend Implementation Notes + +### Dual-path auth flow + +``` +Token received + │ + ├─ Try JWT validation (JWKS) + │ ├─ Success → extract claims → impersonate user + │ └─ Fail (not a JWT) ─┐ + │ │ + └─────────────────────────┤ + │ + Try K8s TokenReview + ├─ Success → resolve SA identity → impersonate + └─ Fail → 401 Unauthorized +``` + +### SSAR cache key change + +Current: `SHA256(token)[:8]:namespace:verb:group:resource` + +With impersonation, `token` is always the backend SA token (same for all requests). +New key must include impersonated identity: + +`SHA256(token)[:8]:impersonated-user:namespace:verb:group:resource` + +### GetK8sClientsForRequest — impersonation config + +The function signature stays the same: `(c *gin.Context) → (kubernetes.Interface, dynamic.Interface)`. +Internally, instead of `cfg.BearerToken = userToken`, use: + +```go +cfg.BearerToken = backendSAToken +cfg.Impersonate = rest.ImpersonationConfig{ + UserName: emailFromJWT, + Groups: groupsFromJWT, +} +``` + +All 142+ callers are unaffected — they receive a K8s client and don't know how it was built. + +### Dual-client pattern preserved + +Some handlers use both user-scoped client (RBAC check) and backend SA client (writes): +- User-scoped: SA token + impersonation (RBAC checked by K8s as impersonated user) +- Backend SA: SA token without impersonation (elevated for writes after RBAC validation) + +The nil-check on `GetK8sClientsForRequest` changes semantics: the SA client never +returns nil (unlike user token clients that return nil on invalid tokens). JWT validation +failures should return 401 before reaching the client construction. + +## Frontend Implementation Notes + +### OIDC session layer + +The frontend needs an OIDC client library that supports: +- Authorization Code Flow with confidential client +- Server-side session storage +- Token refresh +- JWKS validation +- Single sign-out + +`buildForwardHeadersAsync` changes from reading `X-Forwarded-Access-Token` to +extracting the JWT from the OIDC session. The function signature and all 97+ consumers +are unaffected — they call `buildForwardHeadersAsync(request)` and get back headers. + +### Environment variables + +Remove: `OC_TOKEN`, `OC_USER`, `OC_EMAIL`, `ENABLE_OC_WHOAMI` +Add: `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET`, `SSO_ISSUER_URL` +Keep: `DISABLE_AUTH` (mock mode for local dev) + +## Manifest Changes + +### Remove +- `components/oauth-proxy/` (kustomization, deployment patch, service patch) +- `overlays/production/frontend-oauth-patch.yaml` +- All overlay `kustomization.yaml` references to `oauth-proxy` component + +### Add +- K8s Secret for SSO client credentials (mounted into frontend pod) +- Impersonation ClusterRole + ClusterRoleBinding (above) + +### Update +- Frontend Service: route to port 3000 (Next.js) instead of 8443 (OAuth proxy) +- Frontend Deployment: remove OAuth proxy sidecar container +- E2E overlay: test JWT generation instead of SA token + +## ADR-0002 Supersedence + +ADR-0002 chose "User token for all operations" (raw token passthrough) over impersonation +because the token was a K8s-native opaque token — passthrough was the simplest and most +direct approach. With the move to SSO JWTs, the core assumption changes: + +- **ADR-0002 context:** token is K8s-native → passthrough works +- **New context:** token is SSO JWT → passthrough requires cluster OIDC federation + +The security contract from ADR-0002 is preserved: user operations use user permissions, +RBAC is enforced by K8s, audit logs reflect the actual user. Only the mechanism changes +from raw token passthrough to impersonation. From 86ac6f937add6d5741b9d77504e3636484d75420 Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 12 May 2026 15:05:26 -0400 Subject: [PATCH 2/3] spec(security): add IAM consolidation roadmap from PR #1466 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference the IAM consolidation proposal (PR #1466) as the long-term direction. This spec is Phase 1; future phases cover API keys → SSO service accounts, runner → OIDC token exchange, DB RBAC reconciler, and credential consolidation. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/security/sso-authentication.spec.md | 19 +++++++ workflows/security/sso-migration.workflow.md | 60 ++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/specs/security/sso-authentication.spec.md b/specs/security/sso-authentication.spec.md index a85a45e01..2dfde5361 100644 --- a/specs/security/sso-authentication.spec.md +++ b/specs/security/sso-authentication.spec.md @@ -389,6 +389,24 @@ The deployment manifests SHALL be updated to support the new authentication mode - THEN the backend ServiceAccount has a ClusterRoleBinding granting `impersonate` verb on `users`, `groups`, and `serviceaccounts` resources +## Roadmap + +This spec covers **Phase 1** of a broader IAM consolidation. The full roadmap, informed +by the [IAM consolidation proposal](../../docs/internal/proposals/iam-consolidation-plan.md) +(PR #1466), is: + +| Phase | Scope | Depends on | +|-------|-------|------------| +| **1. SSO user auth + impersonation** (this spec) | Frontend BFF, backend JWT validation, K8s impersonation. API keys and runner auth unchanged. | SSO confidential client registration | +| **2. API keys → SSO service accounts** | Replace K8s SA-based API keys with Keycloak confidential clients. Eliminates TokenReview fallback, K8s SA creation, and `last-used-at` annotation patching. | Keycloak Admin API access (`manage-clients` realm role) | +| **3. Runner auth → OIDC token exchange** | Replace RSA keypair exchange with RFC 8693 token exchange. Runner exchanges projected K8s SA token for an SSO-issued JWT. Eliminates CP token server, RSA bootstrap, and operator 45-min refresh loop. | SSO token exchange enabled; SSO trusts cluster JWKS as identity provider | +| **4. DB RBAC reconciler** | DB `role_bindings` table becomes single write plane. Reconciler syncs K8s RoleBindings from DB state. Eliminates dual-grant problem (K8s RBAC + DB RBAC). | Phases 1-2 complete | +| **5. Credential consolidation** | Move per-user OAuth integration tokens (GitLab, Google, Jira, Gerrit, CodeRabbit) from K8s Secrets to the `credentials` table. Single audit trail and access control. | Phase 4 (DB RBAC) | + +Phase 1 is designed to be independently shippable. Each subsequent phase removes a +category of K8s-managed identity state and moves it to SSO or the database, converging +toward a single IAM plane. + ## Design Decisions | Decision | Rationale | @@ -412,3 +430,4 @@ The deployment manifests SHALL be updated to support the new authentication mode - [OAuth 2.0 for Browser-Based Applications](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps) — BFF recommendation - [K8s User Impersonation](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation) - Migration workflow: `workflows/security/sso-migration.workflow.md` +- [IAM consolidation proposal](../../docs/internal/proposals/iam-consolidation-plan.md) (PR #1466) — full IAM audit and long-term consolidation plan diff --git a/workflows/security/sso-migration.workflow.md b/workflows/security/sso-migration.workflow.md index ee295ad64..490211ff6 100644 --- a/workflows/security/sso-migration.workflow.md +++ b/workflows/security/sso-migration.workflow.md @@ -141,6 +141,66 @@ Keep: `DISABLE_AUTH` (mock mode for local dev) - Frontend Deployment: remove OAuth proxy sidecar container - E2E overlay: test JWT generation instead of SA token +## Future Phases (from IAM Consolidation Proposal) + +This workflow covers **Phase 1** only. The following phases are defined in +`docs/internal/proposals/iam-consolidation-plan.md` (PR #1466) and should be specced +separately when ready. + +### Phase 2: API keys → SSO service accounts + +Replace `CreateProjectKey()` (which creates K8s SAs + TokenRequest) with Keycloak Admin +API calls to create confidential clients. Users receive `client_id`/`client_secret` +instead of a K8s SA JWT. + +**What goes away:** +- `ambient-key-*` ServiceAccount creation in `handlers/permissions.go` +- `ambient-key-*` RoleBinding creation +- TokenRequest minting for access keys +- `updateAccessKeyLastUsedAnnotation()` (SA annotation patching) +- TokenReview fallback in the auth middleware (all tokens become SSO JWTs) + +**What's new:** +- Keycloak Admin API client in the backend +- `SSO_ADMIN_CLIENT_ID` / `SSO_ADMIN_CLIENT_SECRET` credentials +- Keycloak client roles mapping to `project:admin/edit/view` + +**Prerequisite:** Keycloak Admin API access with `manage-clients` realm role. + +### Phase 3: Runner auth → OIDC token exchange (RFC 8693) + +Replace the RSA keypair exchange between runner and control plane with standard OIDC +token exchange. The runner exchanges its projected K8s SA token for an SSO-issued JWT. + +**What goes away:** +- Operator: SA creation for `ambient-session-*`, TokenRequest minting, 45-min refresh loop +- Operator: Secret `ambient-runner-token-*` creation +- Control plane: entire `internal/tokenserver/` and `internal/keypair/` packages +- Control plane: Secret `ambient-cp-token-keypair` + +**What's new:** +- Runner: OIDC token exchange on startup (exchange K8s SA token → SSO JWT) +- SSO: `ambient-runner-exchange` client with token exchange permission +- SSO: cluster JWKS registered as identity provider (so SSO can validate K8s SA tokens) + +**Prerequisite:** SSO token exchange enabled; SSO trusts cluster JWKS. + +### Phase 4: DB RBAC reconciler + +Make the DB `role_bindings` table the single write plane for permissions. A reconciler +in the control plane watches DB changes and syncs K8s RoleBindings. + +**Role mapping:** `project:owner` → `ambient-project-admin`, `project:editor` → +`ambient-project-edit`, `project:viewer` → `ambient-project-view`. + +Fine-grained permissions (`credential:token-reader`, etc.) remain DB-only — K8s RBAC +enforces the coarse gate (project access), DB RBAC enforces fine-grained actions. + +### Phase 5: Credential consolidation + +Move per-user OAuth integration tokens from K8s Secrets to the `credentials` table. +Add `user_id` and `scope` columns. New routes: `GET/POST/DELETE /users/me/credentials`. + ## ADR-0002 Supersedence ADR-0002 chose "User token for all operations" (raw token passthrough) over impersonation From 2f0f7753a54fd926e07187dce0463ad4b8d9ebd9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 13 May 2026 12:21:42 -0400 Subject: [PATCH 3/3] spec(security): add OIDC callback route coexistence and client config requirements - OIDC callback must coexist with existing integration auth routes - SSO client configuration requirements (one per environment, audience isolation) - Post-logout redirect URI and web origins specified Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/security/sso-authentication.spec.md | 40 ++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/specs/security/sso-authentication.spec.md b/specs/security/sso-authentication.spec.md index 2dfde5361..9f009f313 100644 --- a/specs/security/sso-authentication.spec.md +++ b/specs/security/sso-authentication.spec.md @@ -36,6 +36,10 @@ The browser SHALL receive an opaque, httpOnly, secure, SameSite OIDC session coo never a raw JWT. The frontend server SHALL exchange the OIDC session for a JWT when proxying requests to backend services. +The OIDC callback route SHALL coexist with existing integration auth routes under +`/api/auth/` (GitHub, GitLab, Jira, Google, Gerrit, CodeRabbit). The OIDC callback +MUST NOT conflict with or disrupt those routes. + #### Scenario: User login - GIVEN a user navigates to the platform @@ -46,11 +50,18 @@ proxying requests to backend services. #### Scenario: OIDC callback - GIVEN the user completes SSO authentication -- WHEN SSO redirects to the frontend callback URL +- WHEN SSO redirects to the frontend OIDC callback route - THEN the frontend exchanges the authorization code for tokens - AND stores the OIDC session server-side - AND sets an httpOnly, secure, SameSite cookie on the browser +#### Scenario: OIDC routes coexist with integration auth routes + +- GIVEN existing integration auth routes at `/api/auth/{provider}/connect`, `/api/auth/{provider}/status`, etc. +- WHEN the OIDC callback route is added +- THEN integration auth routes continue to function unchanged +- AND the OIDC route does not shadow or intercept integration auth requests + #### Scenario: Authenticated API request - GIVEN a user with a valid OIDC session cookie @@ -382,6 +393,33 @@ The deployment manifests SHALL be updated to support the new authentication mode - THEN a K8s Secret containing `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET`, and `SSO_ISSUER_URL` is mounted into the frontend container +### Requirement: SSO Client Configuration + +Each deployed environment SHALL have its own OIDC confidential client registered in +Red Hat SSO. The client SHALL be configured with: + +- Client authentication enabled (confidential) +- Authorization Code grant type +- Valid redirect URI pointing to the frontend OIDC callback route +- Valid post-logout redirect URI pointing to the frontend root +- Web origins matching the frontend host (for CORS on the token endpoint) + +Local development environments (Kind, local-dev) SHALL NOT require an SSO client. + +#### Scenario: One client per environment + +- GIVEN stage and production deployments +- WHEN SSO clients are registered +- THEN each environment has its own client with its own secret +- AND a compromised secret in one environment does not affect others + +#### Scenario: Audience isolation + +- GIVEN separate clients for stage and production +- WHEN a JWT is minted for the stage client +- THEN the `aud` claim contains the stage client ID +- AND the production backend rejects it because the audience does not match + #### Scenario: Backend impersonation RBAC provisioned - GIVEN a deployment with SSO auth enabled