fix(auth): OidcAuthController + UserAdminController upsert unprefixed

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:<s>") still resolve correctly to
the new DB key "oidc:<s>" after stripping.

Verified: 45/45 alerting + outbound ITs pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-20 18:44:17 +02:00
parent 6b5aefd4c2
commit ae6473635d
2 changed files with 11 additions and 10 deletions

View File

@@ -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<String> violations = PasswordPolicyValidator.validate(request.password(), username);
List<String> violations = PasswordPolicyValidator.validate(request.password(), userId);
if (!violations.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Password policy violation: " + String.join("; ", violations));

View File

@@ -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<UserInfo> 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<String> 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();