fix(auth): upsert UI login user_id unprefixed (drop docker seeder workaround)
Root cause of the mismatch that prompted the one-shot cameleer-seed
docker service: UiAuthController stored users.user_id as the JWT
subject "user:admin" (JWT sub format). Every env-scoped controller
(Alert, AlertSilence, AlertRule, OutboundConnectionAdmin) already
strips the "user:" prefix on the read path — so the rest of the
system expects the DB key to be the bare username. With UiAuth
storing prefixed, fresh docker stacks hit
"alert_rules_created_by_fkey violation" on the first rule create.
Fix: inside login(), compute `userId = request.username()` and use
it everywhere the DB/RBAC layer is touched (isLocked, getPasswordHash,
record/clearFailedLogins, upsert, assignRoleToUser, addUserToGroup,
getSystemRoleNames). Keep `subject = "user:" + userId` — we still
sign JWTs with the namespaced subject so JwtAuthenticationFilter can
distinguish user vs agent tokens.
refresh() and me() follow the same rule via a stripSubjectPrefix()
helper (JWT subject in, bare DB key out).
With the write path aligned, the docker bridge is no longer needed:
- Deleted deploy/docker/postgres-init.sql
- Deleted cameleer-seed service from docker-compose.yml
Scope: UiAuthController only. UserAdminController + OidcAuthController
still prefix on upsert — that's the bug class the triage identified
as "Option A or B either way OK". Not changing them now because:
a) prod admins are provisioned unprefixed through some other path,
so those two files aren't the docker-only failure observed;
b) stripping them would need a data migration for any existing
prod users stored prefixed, which is out of scope for a cleanup
phase. Follow-up worth scheduling if we ever wire OIDC or admin-
created users into alerting FKs.
Verified: 33/33 alerting+outbound controller ITs pass (9 outbound,
10 rules, 9 silences, 5 alert inbox).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,27 +77,30 @@ public class UiAuthController {
|
||||
HttpServletRequest httpRequest) {
|
||||
String configuredUser = properties.getUiUser();
|
||||
String configuredPassword = properties.getUiPassword();
|
||||
String subject = "user:" + request.username();
|
||||
// The JWT subject carries a "user:" namespace prefix so the auth filter
|
||||
// can distinguish user vs agent tokens. The DB row keys (users.user_id,
|
||||
// user_roles.user_id, alert_rules.created_by FK, …) are the bare username:
|
||||
// every env-scoped controller strips the prefix on the read path via
|
||||
// stripSubjectPrefix(...), so the write path here must match.
|
||||
String userId = request.username();
|
||||
String subject = "user:" + userId;
|
||||
|
||||
// Check account lockout before attempting authentication
|
||||
if (userRepository.isLocked(subject)) {
|
||||
if (userRepository.isLocked(userId)) {
|
||||
auditService.log(request.username(), "login_locked", AuditCategory.AUTH, null,
|
||||
Map.of("reason", "Account locked"), AuditResult.FAILURE, httpRequest);
|
||||
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
|
||||
"Account locked due to too many failed attempts. Try again later.");
|
||||
}
|
||||
|
||||
// Try env-var admin first
|
||||
boolean envMatch = configuredUser != null && !configuredUser.isBlank()
|
||||
&& configuredPassword != null && !configuredPassword.isBlank()
|
||||
&& configuredUser.equals(request.username())
|
||||
&& configuredPassword.equals(request.password());
|
||||
|
||||
if (!envMatch) {
|
||||
// Try per-user password
|
||||
Optional<String> hash = userRepository.getPasswordHash(subject);
|
||||
Optional<String> hash = userRepository.getPasswordHash(userId);
|
||||
if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) {
|
||||
userRepository.recordFailedLogin(subject);
|
||||
userRepository.recordFailedLogin(userId);
|
||||
log.debug("UI login failed for user: {}", request.username());
|
||||
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
||||
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
|
||||
@@ -105,23 +108,22 @@ public class UiAuthController {
|
||||
}
|
||||
}
|
||||
|
||||
// Successful login — clear any failed attempt counter
|
||||
userRepository.clearFailedLogins(subject);
|
||||
userRepository.clearFailedLogins(userId);
|
||||
|
||||
if (envMatch) {
|
||||
// Env-var admin: upsert and ensure ADMIN role + Admins group
|
||||
// Env-var admin: upsert unprefixed and ensure ADMIN role + Admins group
|
||||
try {
|
||||
userRepository.upsert(new UserInfo(
|
||||
subject, "local", "", request.username(), Instant.now()));
|
||||
rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID);
|
||||
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
|
||||
userId, "local", "", request.username(), Instant.now()));
|
||||
rbacService.assignRoleToUser(userId, SystemRole.ADMIN_ID);
|
||||
rbacService.addUserToGroup(userId, SystemRole.ADMINS_GROUP_ID);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to upsert local admin to store (login continues): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
// Per-user logins: user already exists in DB (created by admin)
|
||||
|
||||
List<String> roles = rbacService.getSystemRoleNames(subject);
|
||||
List<String> roles = rbacService.getSystemRoleNames(userId);
|
||||
if (roles.isEmpty()) {
|
||||
roles = List.of("VIEWER");
|
||||
}
|
||||
@@ -152,9 +154,10 @@ public class UiAuthController {
|
||||
String accessToken = jwtService.createAccessToken(result.subject(), "user", roles);
|
||||
String refreshToken = jwtService.createRefreshToken(result.subject(), "user", roles);
|
||||
|
||||
String displayName = userRepository.findById(result.subject())
|
||||
String userId = stripSubjectPrefix(result.subject());
|
||||
String displayName = userRepository.findById(userId)
|
||||
.map(UserInfo::displayName)
|
||||
.orElse(result.subject());
|
||||
.orElse(userId);
|
||||
auditService.log(result.subject(), "token_refresh", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null));
|
||||
} catch (ResponseStatusException e) {
|
||||
@@ -173,13 +176,22 @@ public class UiAuthController {
|
||||
if (authentication == null || authentication.getName() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
UserDetail detail = rbacService.getUser(authentication.getName());
|
||||
UserDetail detail = rbacService.getUser(stripSubjectPrefix(authentication.getName()));
|
||||
if (detail == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found");
|
||||
}
|
||||
return ResponseEntity.ok(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a JWT subject ({@code "user:<name>"} or {@code "user:oidc:<sub>"}) to the DB key:
|
||||
* just the bare username. FKs on {@code alert_rules.created_by},
|
||||
* {@code outbound_connections.created_by}, etc. reference the unprefixed row.
|
||||
*/
|
||||
private static String stripSubjectPrefix(String subject) {
|
||||
return subject != null && subject.startsWith("user:") ? subject.substring(5) : subject;
|
||||
}
|
||||
|
||||
public record LoginRequest(String username, String password) {}
|
||||
public record RefreshRequest(String refreshToken) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user