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) {
|
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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 $$;
|
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user