fix(auth): provision OIDC users on first contact via resource-server flow
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 3m15s
CI / docker (push) Successful in 3m38s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m4s

JwtAuthenticationFilter.tryOidcToken validated external access tokens
against the IdP's JWKS but never upserted the subject into `users`. Any
later write that FKs `users(user_id)` (deployments.created_by,
alert_rules.created_by, outbound_connections.created_by, ...) blew up
with a foreign-key violation — the interactive /auth/oidc/callback path
upserts here, the resource-server path silently skipped it.

Add OidcAccountSyncService: short-circuits when the user already exists,
otherwise honours OidcConfig.autoSignup (defaulting to true when no DB
row, since OIDC-via-env-var implies admin opt-in), enforces the
max_users license cap, and persists UserInfo with provider, email, and
displayName drawn from the JWT claims. The filter falls through to
anonymous (Spring → 401) on refusal instead of authenticating an
un-persisted principal that would 5xx on the next FK insert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-28 18:41:24 +02:00
parent f47cd7ebf2
commit 99d9d193ac
5 changed files with 271 additions and 5 deletions

View File

@@ -173,7 +173,8 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
## security/ — Spring Security
- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only. Alerting matchers: GET `/environments/*/alerts/**` VIEWER+; POST/PUT/DELETE rules and silences OPERATOR+; ack/read/bulk-read VIEWER+; POST `/alerts/notifications/*/retry` OPERATOR+.
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens. Tries internal HMAC first; on failure (and when `oidcDecoder != null`) falls back to external-IdP validation. Resource-server path delegates to `OidcAccountSyncService.ensureProvisioned(jwt)` to upsert the user into `users` on first contact — without it, every later FK-to-`users(user_id)` insert (`deployments.created_by`, `alert_rules.created_by`, …) would 500 with a foreign-key violation. Sets `principal.name` to the bare `oidc:<sub>` so the env-scoped strip-`"user:"` convention is a no-op (still produces the correct FK value).
- `OidcAccountSyncService` — provisions OIDC users into `users` from the `JwtAuthenticationFilter` resource-server path. `ensureProvisioned(Jwt)` short-circuits when the user exists; otherwise reads `OidcConfigRepository` (defaults `autoSignup=true` when no row — i.e., OIDC configured purely via env var), enforces the `max_users` cap via `LicenseEnforcer`, then upserts `UserInfo(userId="oidc:<sub>", provider="oidc:<issuer-host>", email, displayName, createdAt)`. Returns `Optional.empty()` on refusal so the filter falls through to anonymous (Spring → 401), never throws.
- `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE)
- `UiAuthController``/api/v1/auth` (login, refresh, me, logout). Upserts `users.user_id = request.username()` (bare); signs JWTs with `subject = "user:" + userId`. `refresh`/`me`/`logout` strip the `"user:"` prefix from incoming subjects via `stripSubjectPrefix()` before any DB/RBAC lookup. `logout` revokes outstanding tokens by writing `users.token_revoked_before` and audits under `AuditCategory.AUTH / logout`.
- `OidcAuthController``/api/v1/auth/oidc` (login-uri, token-exchange, logout). Upserts `users.user_id = "oidc:" + oidcUser.subject()` (no `user:` prefix); signs JWTs with `subject = "user:oidc:" + oidcUser.subject()`. `applyClaimMappings` + `getSystemRoleNames` calls all use the bare `oidc:<sub>` form.