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:
hsiegeln
2026-04-20 18:26:03 +02:00
parent 09b49f096c
commit 1ea0258393
3 changed files with 29 additions and 77 deletions

View File

@@ -77,27 +77,30 @@ public class UiAuthController {
HttpServletRequest httpRequest) { HttpServletRequest httpRequest) {
String configuredUser = properties.getUiUser(); String configuredUser = properties.getUiUser();
String configuredPassword = properties.getUiPassword(); 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(userId)) {
if (userRepository.isLocked(subject)) {
auditService.log(request.username(), "login_locked", AuditCategory.AUTH, null, auditService.log(request.username(), "login_locked", AuditCategory.AUTH, null,
Map.of("reason", "Account locked"), AuditResult.FAILURE, httpRequest); Map.of("reason", "Account locked"), AuditResult.FAILURE, httpRequest);
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Account locked due to too many failed attempts. Try again later."); "Account locked due to too many failed attempts. Try again later.");
} }
// Try env-var admin first
boolean envMatch = configuredUser != null && !configuredUser.isBlank() boolean envMatch = configuredUser != null && !configuredUser.isBlank()
&& configuredPassword != null && !configuredPassword.isBlank() && configuredPassword != null && !configuredPassword.isBlank()
&& configuredUser.equals(request.username()) && configuredUser.equals(request.username())
&& configuredPassword.equals(request.password()); && configuredPassword.equals(request.password());
if (!envMatch) { if (!envMatch) {
// Try per-user password Optional<String> hash = userRepository.getPasswordHash(userId);
Optional<String> hash = userRepository.getPasswordHash(subject);
if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) { if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) {
userRepository.recordFailedLogin(subject); userRepository.recordFailedLogin(userId);
log.debug("UI login failed for user: {}", request.username()); log.debug("UI login failed for user: {}", request.username());
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null, auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest); Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
@@ -105,23 +108,22 @@ public class UiAuthController {
} }
} }
// Successful login — clear any failed attempt counter userRepository.clearFailedLogins(userId);
userRepository.clearFailedLogins(subject);
if (envMatch) { if (envMatch) {
// Env-var admin: upsert and ensure ADMIN role + Admins group // Env-var admin: upsert unprefixed and ensure ADMIN role + Admins group
try { try {
userRepository.upsert(new UserInfo( userRepository.upsert(new UserInfo(
subject, "local", "", request.username(), Instant.now())); userId, "local", "", request.username(), Instant.now()));
rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID); rbacService.assignRoleToUser(userId, SystemRole.ADMIN_ID);
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID); rbacService.addUserToGroup(userId, SystemRole.ADMINS_GROUP_ID);
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to upsert local admin to store (login continues): {}", e.getMessage()); log.warn("Failed to upsert local admin to store (login continues): {}", e.getMessage());
} }
} }
// Per-user logins: user already exists in DB (created by admin) // 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()) { if (roles.isEmpty()) {
roles = List.of("VIEWER"); roles = List.of("VIEWER");
} }
@@ -152,9 +154,10 @@ public class UiAuthController {
String accessToken = jwtService.createAccessToken(result.subject(), "user", roles); String accessToken = jwtService.createAccessToken(result.subject(), "user", roles);
String refreshToken = jwtService.createRefreshToken(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) .map(UserInfo::displayName)
.orElse(result.subject()); .orElse(userId);
auditService.log(result.subject(), "token_refresh", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest); auditService.log(result.subject(), "token_refresh", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null)); return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null));
} catch (ResponseStatusException e) { } catch (ResponseStatusException e) {
@@ -173,13 +176,22 @@ public class UiAuthController {
if (authentication == null || authentication.getName() == null) { if (authentication == null || authentication.getName() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
} }
UserDetail detail = rbacService.getUser(authentication.getName()); UserDetail detail = rbacService.getUser(stripSubjectPrefix(authentication.getName()));
if (detail == null) { if (detail == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found");
} }
return ResponseEntity.ok(detail); 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 LoginRequest(String username, String password) {}
public record RefreshRequest(String refreshToken) {} public record RefreshRequest(String refreshToken) {}
} }

View File

@@ -1,41 +0,0 @@
-- Dev-stack seed: pre-create the `admin` user row without the `user:` prefix.
--
-- Why: the UI login controller stores the local admin as `user_id='user:admin'`
-- (JWT `sub` format), but the alerting + outbound controllers resolve the FK
-- via `authentication.name` with the `user:` prefix stripped, i.e. `admin`.
-- In k8s these controllers happily insert `admin` because production admins are
-- provisioned through the admin API with unprefixed user_ids. In the local
-- docker stack there's no such provisioning step, so the FK check fails with
-- "alert_rules_created_by_fkey violation" on the first rule create.
--
-- Seeding a row with `user_id='admin'` here bridges the gap so E2E smokes,
-- API probes, and manual dev sessions can create alerting rows straight away.
-- Flyway owns the schema in tenant_default; this script only INSERTs idempotently
-- and is gated on the schema existing.
DO $$
DECLARE
schema_exists bool;
table_exists bool;
BEGIN
SELECT EXISTS(
SELECT 1 FROM information_schema.schemata WHERE schema_name = 'tenant_default'
) INTO schema_exists;
IF NOT schema_exists THEN
RAISE NOTICE 'tenant_default schema not yet migrated — skipping admin seed (Flyway will run on server start)';
RETURN;
END IF;
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'tenant_default' AND table_name = 'users'
) INTO table_exists;
IF NOT table_exists THEN
RAISE NOTICE 'tenant_default.users not yet migrated — skipping admin seed';
RETURN;
END IF;
INSERT INTO tenant_default.users (user_id, provider, email, display_name)
VALUES ('admin', 'local', '', 'admin')
ON CONFLICT (user_id) DO NOTHING;
END $$;

View File

@@ -130,25 +130,6 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
# Run-once seeder: waits for the server to be healthy (i.e. Flyway migrations
# finished) and inserts a `user_id='admin'` row (without the `user:` prefix)
# so alerting-controller FKs succeed. See deploy/docker/postgres-init.sql for
# the full rationale. Idempotent — exits 0 if the row already exists.
cameleer-seed:
image: postgres:16
container_name: cameleer-seed
depends_on:
cameleer-server:
condition: service_healthy
environment:
PGPASSWORD: cameleer_dev
volumes:
- ./deploy/docker/postgres-init.sql:/seed.sql:ro
entrypoint: ["sh", "-c"]
command:
- "psql -h cameleer-postgres -U cameleer -d cameleer -v ON_ERROR_STOP=1 -f /seed.sql"
restart: "no"
volumes: volumes:
cameleer-pgdata: cameleer-pgdata:
cameleer-chdata: cameleer-chdata: