diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md index eb878ea4..d5bece79 100644 --- a/.claude/rules/app-classes.md +++ b/.claude/rules/app-classes.md @@ -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:` 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:", provider="oidc:", 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:` form. diff --git a/cameleer-server-app/src/main/java/io/cameleer/server/app/security/JwtAuthenticationFilter.java b/cameleer-server-app/src/main/java/io/cameleer/server/app/security/JwtAuthenticationFilter.java index aa8f4af0..1d687f99 100644 --- a/cameleer-server-app/src/main/java/io/cameleer/server/app/security/JwtAuthenticationFilter.java +++ b/cameleer-server-app/src/main/java/io/cameleer/server/app/security/JwtAuthenticationFilter.java @@ -24,6 +24,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.time.Instant; import java.util.List; +import java.util.Optional; /** * JWT authentication filter that extracts and validates JWT tokens from @@ -47,17 +48,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtDecoder oidcDecoder; private final UserRepository userRepository; private final ServerMetrics serverMetrics; + private final OidcAccountSyncService oidcAccountSync; public JwtAuthenticationFilter(JwtService jwtService, AgentRegistryService agentRegistryService, JwtDecoder oidcDecoder, UserRepository userRepository, - ServerMetrics serverMetrics) { + ServerMetrics serverMetrics, + OidcAccountSyncService oidcAccountSync) { this.jwtService = jwtService; this.agentRegistryService = agentRegistryService; this.oidcDecoder = oidcDecoder; this.userRepository = userRepository; this.serverMetrics = serverMetrics; + this.oidcAccountSync = oidcAccountSync; } @Override @@ -117,11 +121,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private void tryOidcToken(String token, HttpServletRequest request) { try { Jwt jwt = oidcDecoder.decode(token); + // Provision the user into the `users` table on first contact so any later + // FK insert (deployments.created_by, alert_rules.created_by, etc.) resolves. + // The interactive /auth/oidc/callback path upserts here too; without this the + // resource-server flow leaves the principal authenticated but un-persisted. + Optional userId = oidcAccountSync.ensureProvisioned(jwt); + if (userId.isEmpty()) { + serverMetrics.recordAuthFailure("oidc_rejected"); + return; + } List roles = extractRolesFromScopes(jwt); List authorities = toAuthorities(roles); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( - "oidc:" + jwt.getSubject(), null, authorities); + userId.get(), null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } catch (Exception e) { log.debug("OIDC token validation failed: {}", e.getMessage()); diff --git a/cameleer-server-app/src/main/java/io/cameleer/server/app/security/OidcAccountSyncService.java b/cameleer-server-app/src/main/java/io/cameleer/server/app/security/OidcAccountSyncService.java new file mode 100644 index 00000000..48306445 --- /dev/null +++ b/cameleer-server-app/src/main/java/io/cameleer/server/app/security/OidcAccountSyncService.java @@ -0,0 +1,96 @@ +package io.cameleer.server.app.security; + +import io.cameleer.server.app.license.LicenseEnforcer; +import io.cameleer.server.core.security.OidcConfig; +import io.cameleer.server.core.security.OidcConfigRepository; +import io.cameleer.server.core.security.UserInfo; +import io.cameleer.server.core.security.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.Optional; + +/** + * Provisions OIDC users into the {@code users} table on first contact through the + * resource-server (Logto/external IdP) flow. Mirrors the upsert path in + * {@link OidcAuthController} but runs from {@link JwtAuthenticationFilter} where only + * a parsed {@link Jwt} is available -- there is no token-exchange round-trip and no + * {@code OidcUserInfo} record. + * + *

Returns {@link Optional#empty()} (rather than throwing) when the token represents + * a user who must not be auto-provisioned, so the filter can fall through to anonymous + * and Spring returns 401 on protected endpoints rather than a 5xx FK violation later. + * + *

OIDC config row may be absent when OIDC is configured purely via env var + * ({@code CAMELEER_SERVER_SECURITY_OIDCISSUERURI}). In that case autoSignup defaults to + * true, since accepting external tokens at boot already implies admin opt-in. + */ +@Component +public class OidcAccountSyncService { + + private static final Logger log = LoggerFactory.getLogger(OidcAccountSyncService.class); + + private final UserRepository userRepository; + private final OidcConfigRepository oidcConfigRepository; + private final LicenseEnforcer licenseEnforcer; + + public OidcAccountSyncService(UserRepository userRepository, + OidcConfigRepository oidcConfigRepository, + LicenseEnforcer licenseEnforcer) { + this.userRepository = userRepository; + this.oidcConfigRepository = oidcConfigRepository; + this.licenseEnforcer = licenseEnforcer; + } + + /** + * Ensures a {@code users} row exists for the OIDC subject in {@code jwt}. + * Returns the bare {@code users.user_id} (e.g. {@code "oidc:r6uzeamz0wuy"}) on + * success, or empty if provisioning was refused. + */ + public Optional ensureProvisioned(Jwt jwt) { + String userId = "oidc:" + jwt.getSubject(); + if (userRepository.findById(userId).isPresent()) { + return Optional.of(userId); + } + + boolean autoSignup = oidcConfigRepository.find() + .map(OidcConfig::autoSignup) + .orElse(true); + if (!autoSignup) { + log.warn("OIDC user {} not provisioned and autoSignup is disabled; rejecting token.", userId); + return Optional.empty(); + } + + try { + licenseEnforcer.assertWithinCap("max_users", userRepository.count(), 1); + } catch (RuntimeException e) { + log.warn("OIDC user {} not provisioned: {}.", userId, e.getMessage()); + return Optional.empty(); + } + + String issuerHost = jwt.getIssuer() != null ? jwt.getIssuer().getHost() : "unknown"; + String email = jwt.getClaimAsString("email"); + String name = firstNonBlank( + jwt.getClaimAsString("name"), + jwt.getClaimAsString("preferred_username"), + email); + userRepository.upsert(new UserInfo( + userId, + "oidc:" + issuerHost, + email != null ? email : "", + name != null ? name : "", + Instant.now())); + log.info("Provisioned OIDC user via resource-server flow: userId={}, issuer={}", userId, issuerHost); + return Optional.of(userId); + } + + private static String firstNonBlank(String... values) { + for (String v : values) { + if (v != null && !v.isBlank()) return v; + } + return ""; + } +} diff --git a/cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityConfig.java b/cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityConfig.java index 44640a34..ac5631e5 100644 --- a/cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityConfig.java +++ b/cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityConfig.java @@ -63,7 +63,8 @@ public class SecurityConfig { SecurityProperties securityProperties, CorsConfigurationSource corsConfigurationSource, UserRepository userRepository, - ServerMetrics serverMetrics) throws Exception { + ServerMetrics serverMetrics, + OidcAccountSyncService oidcAccountSync) throws Exception { JwtDecoder oidcDecoder = null; String issuer = securityProperties.getOidc().getIssuerUri(); if (issuer != null && !issuer.isBlank()) { @@ -198,7 +199,7 @@ public class SecurityConfig { .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) ) .addFilterBefore( - new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder, userRepository, serverMetrics), + new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder, userRepository, serverMetrics, oidcAccountSync), UsernamePasswordAuthenticationFilter.class ); diff --git a/cameleer-server-app/src/test/java/io/cameleer/server/app/security/OidcAccountSyncServiceTest.java b/cameleer-server-app/src/test/java/io/cameleer/server/app/security/OidcAccountSyncServiceTest.java new file mode 100644 index 00000000..7ae75db2 --- /dev/null +++ b/cameleer-server-app/src/test/java/io/cameleer/server/app/security/OidcAccountSyncServiceTest.java @@ -0,0 +1,155 @@ +package io.cameleer.server.app.security; + +import io.cameleer.server.app.license.LicenseCapExceededException; +import io.cameleer.server.app.license.LicenseEnforcer; +import io.cameleer.server.core.security.OidcConfig; +import io.cameleer.server.core.security.OidcConfigRepository; +import io.cameleer.server.core.security.UserInfo; +import io.cameleer.server.core.security.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OidcAccountSyncServiceTest { + + private UserRepository userRepository; + private OidcConfigRepository oidcConfigRepository; + private LicenseEnforcer licenseEnforcer; + private OidcAccountSyncService service; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + oidcConfigRepository = mock(OidcConfigRepository.class); + licenseEnforcer = mock(LicenseEnforcer.class); + service = new OidcAccountSyncService(userRepository, oidcConfigRepository, licenseEnforcer); + } + + @Test + void existingUser_returnsExistingId_skipsUpsert() { + when(userRepository.findById("oidc:abc")).thenReturn(Optional.of(existingUser("oidc:abc"))); + + Optional result = service.ensureProvisioned(jwt("abc", "https://logto.example", "x@y.com", "Display")); + + assertThat(result).contains("oidc:abc"); + verify(userRepository, never()).upsert(any()); + verify(licenseEnforcer, never()).assertWithinCap(any(), anyLong(), anyLong()); + } + + @Test + void newUser_autoSignupTrue_upsertsBareUserId() { + when(userRepository.findById("oidc:abc")).thenReturn(Optional.empty()); + when(oidcConfigRepository.find()).thenReturn(Optional.of(oidcConfig(true))); + when(userRepository.count()).thenReturn(0L); + + Optional result = service.ensureProvisioned( + jwt("abc", "https://logto.example", "x@y.com", "Display")); + + assertThat(result).contains("oidc:abc"); + verify(licenseEnforcer).assertWithinCap("max_users", 0L, 1L); + verify(userRepository).upsert(argThat(u -> + "oidc:abc".equals(u.userId()) + && "oidc:logto.example".equals(u.provider()) + && "x@y.com".equals(u.email()) + && "Display".equals(u.displayName()) + )); + } + + @Test + void newUser_oidcConfigAbsent_defaultsToAutoSignupTrue() { + when(userRepository.findById("oidc:abc")).thenReturn(Optional.empty()); + when(oidcConfigRepository.find()).thenReturn(Optional.empty()); + when(userRepository.count()).thenReturn(0L); + + Optional result = service.ensureProvisioned( + jwt("abc", "https://logto.example", null, null)); + + assertThat(result).contains("oidc:abc"); + verify(userRepository).upsert(argThat(u -> + "oidc:abc".equals(u.userId()) && u.email().isEmpty() && u.displayName().isEmpty())); + } + + @Test + void newUser_autoSignupFalse_returnsEmpty_noUpsert() { + when(userRepository.findById("oidc:abc")).thenReturn(Optional.empty()); + when(oidcConfigRepository.find()).thenReturn(Optional.of(oidcConfig(false))); + + Optional result = service.ensureProvisioned( + jwt("abc", "https://logto.example", "x@y.com", "Display")); + + assertThat(result).isEmpty(); + verify(userRepository, never()).upsert(any()); + verify(licenseEnforcer, never()).assertWithinCap(any(), anyLong(), anyLong()); + } + + @Test + void newUser_capExceeded_returnsEmpty_noUpsert() { + when(userRepository.findById("oidc:abc")).thenReturn(Optional.empty()); + when(oidcConfigRepository.find()).thenReturn(Optional.of(oidcConfig(true))); + when(userRepository.count()).thenReturn(10L); + doThrow(new LicenseCapExceededException("max_users", 10, 5)) + .when(licenseEnforcer).assertWithinCap(eq("max_users"), eq(10L), eq(1L)); + + Optional result = service.ensureProvisioned( + jwt("abc", "https://logto.example", "x@y.com", "Display")); + + assertThat(result).isEmpty(); + verify(userRepository, never()).upsert(any()); + } + + @Test + void newUser_displayNameFallback_prefersPreferredUsernameOverEmail() { + when(userRepository.findById("oidc:abc")).thenReturn(Optional.empty()); + when(oidcConfigRepository.find()).thenReturn(Optional.of(oidcConfig(true))); + when(userRepository.count()).thenReturn(0L); + + Jwt jwt = Jwt.withTokenValue("t") + .header("alg", "ES384") + .subject("abc") + .issuer("https://logto.example") + .claim("preferred_username", "alice42") + .claim("email", "alice@example.com") + .build(); + + service.ensureProvisioned(jwt); + + verify(userRepository).upsert(argThat(u -> + "alice42".equals(u.displayName()) && "alice@example.com".equals(u.email()))); + } + + private OidcConfig oidcConfig(boolean autoSignup) { + return new OidcConfig( + true, "https://logto.example", "client", "secret", + "roles", List.of("VIEWER"), autoSignup, + "name", "sub", null, null); + } + + private UserInfo existingUser(String userId) { + return new UserInfo(userId, "oidc:logto.example", "", "", Instant.now()); + } + + private Jwt jwt(String sub, String issuer, String email, String name) { + Jwt.Builder b = Jwt.withTokenValue("t") + .header("alg", "ES384") + .subject(sub) + .issuer(issuer); + if (email != null) b.claim("email", email); + if (name != null) b.claim("name", name); + return b.build(); + } +}