diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java index e49540af..8413c9f3 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java @@ -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 hash = userRepository.getPasswordHash(subject); + Optional 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 roles = rbacService.getSystemRoleNames(subject); + List 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:"} or {@code "user:oidc:"}) 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) {} } diff --git a/deploy/docker/postgres-init.sql b/deploy/docker/postgres-init.sql deleted file mode 100644 index 6bfc53fb..00000000 --- a/deploy/docker/postgres-init.sql +++ /dev/null @@ -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 $$; diff --git a/docker-compose.yml b/docker-compose.yml index 6f4ff657..2b4420da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -130,25 +130,6 @@ services: retries: 10 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: cameleer-pgdata: cameleer-chdata: