From ae6473635da07fb458c04e2ba98849864dd428f5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:44:17 +0200 Subject: [PATCH] fix(auth): OidcAuthController + UserAdminController upsert unprefixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the UiAuthController fix: every write path that puts a row into users/user_roles/user_groups must use the bare DB key, because the env-scoped controllers (Alert, AlertRule, AlertSilence, Outbound) strip "user:" before using the name as an FK. If the write path stores prefixed, first-time alerting/outbound writes fail with alert_rules_created_by_fkey violation. UiAuthController shipped the model in the prior commit (bare userId for all DB/RBAC calls, "user:"-namespaced subject for JWT signing). Bringing the other two write paths in line: - OidcAuthController.callback: userId = "oidc:" + oidcUser.subject() // DB key, no "user:" subject = "user:" + userId // JWT subject (namespaced) All userRepository / rbacService / applyClaimMappings calls use userId. Tokens still carry the namespaced subject so JwtAuthenticationFilter can distinguish user vs agent tokens. - UserAdminController.createUser: userId = request.username() (bare). resetPassword: dropped the "user:"-strip fallback that was only needed because create used to prefix — now dead. No migration. Greenfield alpha product — any pre-existing prefixed rows in a dev DB will become orphans on next login (login upserts the unprefixed row, old prefixed row is harmless but unused). Operators doing a clean re-index can wipe the DB. Read-path controllers still strip — harmless for bare DB rows, and OIDC humans (JWT sub "user:oidc:") still resolve correctly to the new DB key "oidc:" after stripping. Verified: 45/45 alerting + outbound ITs pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/app/controller/UserAdminController.java | 8 ++++---- .../server/app/security/OidcAuthController.java | 13 +++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java index 501e4522..063219b1 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java @@ -93,7 +93,9 @@ public class UserAdminController { return ResponseEntity.badRequest() .body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO.")); } - String userId = "user:" + request.username(); + // DB key is the bare username (matches alert_rules.created_by FK shape used by + // the env-scoped read-path controllers, which strip "user:" from JWT subjects). + String userId = request.username(); UserInfo user = new UserInfo(userId, "local", request.email() != null ? request.email() : "", request.displayName() != null ? request.displayName() : request.username(), @@ -215,9 +217,7 @@ public class UserAdminController { return ResponseEntity.badRequest().build(); } } - // Extract bare username from "user:username" format for policy check - String username = userId.startsWith("user:") ? userId.substring(5) : userId; - List violations = PasswordPolicyValidator.validate(request.password(), username); + List violations = PasswordPolicyValidator.validate(request.password(), userId); if (!violations.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Password policy violation: " + String.join("; ", violations)); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java index f37feca1..72d8298d 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java @@ -140,28 +140,29 @@ public class OidcAuthController { OidcTokenExchanger.OidcUserInfo oidcUser = tokenExchanger.exchange(request.code(), request.redirectUri()); - String userId = "user:oidc:" + oidcUser.subject(); + // DB key is unprefixed (matches alert_rules.created_by FK shape used by the + // env-scoped read-path controllers). JWT subject keeps the "user:" namespace + // so JwtAuthenticationFilter can still distinguish user vs agent tokens. + String userId = "oidc:" + oidcUser.subject(); + String subject = "user:" + userId; String issuerHost = URI.create(config.get().issuerUri()).getHost(); String provider = "oidc:" + issuerHost; - // Check auto-signup gate: if disabled, user must already exist Optional existingUser = userRepository.findById(userId); if (!config.get().autoSignup() && existingUser.isEmpty()) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Account not provisioned. Contact your administrator."); } - // Upsert user (without roles -- roles are in user_roles table) userRepository.upsert(new UserInfo( userId, provider, oidcUser.email(), oidcUser.name(), Instant.now())); - // Apply claim mapping rules to assign managed roles/groups from JWT claims applyClaimMappings(userId, oidcUser.allClaims(), oidcUser.roles(), config.get()); List roles = rbacService.getSystemRoleNames(userId); - String accessToken = jwtService.createAccessToken(userId, "user", roles); - String refreshToken = jwtService.createRefreshToken(userId, "user", roles); + String accessToken = jwtService.createAccessToken(subject, "user", roles); + String refreshToken = jwtService.createRefreshToken(subject, "user", roles); String displayName = oidcUser.name() != null && !oidcUser.name().isBlank() ? oidcUser.name() : oidcUser.email();