diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java index 92f34943..9a971357 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java @@ -1,5 +1,7 @@ package com.cameleer3.server.app.config; +import com.cameleer3.server.core.admin.AuditRepository; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.detail.DetailService; import com.cameleer3.server.core.indexing.SearchIndexer; import com.cameleer3.server.core.ingestion.IngestionService; @@ -25,6 +27,11 @@ public class StorageBeanConfig { return new SearchIndexer(executionStore, searchIndex, debounceMs, queueSize); } + @Bean + public AuditService auditService(AuditRepository auditRepository) { + return new AuditService(auditRepository); + } + @Bean public IngestionService ingestionService(ExecutionStore executionStore, DiagramStore diagramStore, diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java index de0d4a7c..1fbd445c 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java @@ -5,8 +5,12 @@ import com.cameleer3.server.app.dto.OidcAdminConfigRequest; import com.cameleer3.server.app.dto.OidcAdminConfigResponse; import com.cameleer3.server.app.dto.OidcTestResult; import com.cameleer3.server.app.security.OidcTokenExchanger; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfigRepository; +import jakarta.servlet.http.HttpServletRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -16,6 +20,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -26,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -35,17 +41,21 @@ import java.util.Optional; @RestController @RequestMapping("/api/v1/admin/oidc") @Tag(name = "OIDC Config Admin", description = "OIDC provider configuration (ADMIN only)") +@PreAuthorize("hasRole('ADMIN')") public class OidcConfigAdminController { private static final Logger log = LoggerFactory.getLogger(OidcConfigAdminController.class); private final OidcConfigRepository configRepository; private final OidcTokenExchanger tokenExchanger; + private final AuditService auditService; public OidcConfigAdminController(OidcConfigRepository configRepository, - OidcTokenExchanger tokenExchanger) { + OidcTokenExchanger tokenExchanger, + AuditService auditService) { this.configRepository = configRepository; this.tokenExchanger = tokenExchanger; + this.auditService = auditService; } @GetMapping @@ -64,7 +74,8 @@ public class OidcConfigAdminController { @ApiResponse(responseCode = "200", description = "Configuration saved") @ApiResponse(responseCode = "400", description = "Invalid configuration", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - public ResponseEntity saveConfig(@RequestBody OidcAdminConfigRequest request) { + public ResponseEntity saveConfig(@RequestBody OidcAdminConfigRequest request, + HttpServletRequest httpRequest) { // Resolve client_secret: if masked or empty, preserve existing String clientSecret = request.clientSecret(); if (clientSecret == null || clientSecret.isBlank() || clientSecret.equals("********")) { @@ -95,6 +106,7 @@ public class OidcConfigAdminController { configRepository.save(config); tokenExchanger.invalidateCache(); + auditService.log("update_oidc", AuditCategory.CONFIG, "oidc", Map.of(), AuditResult.SUCCESS, httpRequest); log.info("OIDC configuration updated: enabled={}, issuer={}", config.enabled(), config.issuerUri()); return ResponseEntity.ok(OidcAdminConfigResponse.from(config)); } @@ -104,7 +116,7 @@ public class OidcConfigAdminController { @ApiResponse(responseCode = "200", description = "Provider reachable") @ApiResponse(responseCode = "400", description = "Provider unreachable or misconfigured", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - public ResponseEntity testConnection() { + public ResponseEntity testConnection(HttpServletRequest httpRequest) { Optional config = configRepository.find(); if (config.isEmpty() || !config.get().enabled()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, @@ -114,6 +126,7 @@ public class OidcConfigAdminController { try { tokenExchanger.invalidateCache(); String authEndpoint = tokenExchanger.getAuthorizationEndpoint(); + auditService.log("test_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest); return ResponseEntity.ok(new OidcTestResult("ok", authEndpoint)); } catch (Exception e) { log.warn("OIDC connectivity test failed: {}", e.getMessage()); @@ -125,9 +138,10 @@ public class OidcConfigAdminController { @DeleteMapping @Operation(summary = "Delete OIDC configuration") @ApiResponse(responseCode = "204", description = "Configuration deleted") - public ResponseEntity deleteConfig() { + public ResponseEntity deleteConfig(HttpServletRequest httpRequest) { configRepository.delete(); tokenExchanger.invalidateCache(); + auditService.log("delete_oidc", AuditCategory.CONFIG, "oidc", null, AuditResult.SUCCESS, httpRequest); log.info("OIDC configuration deleted"); return ResponseEntity.noContent().build(); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java index e0cfd1b3..2f837c86 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java @@ -1,11 +1,16 @@ package com.cameleer3.server.app.controller; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserRepository; +import jakarta.servlet.http.HttpServletRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -15,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; /** * Admin endpoints for user management. @@ -23,12 +29,15 @@ import java.util.List; @RestController @RequestMapping("/api/v1/admin/users") @Tag(name = "User Admin", description = "User management (ADMIN only)") +@PreAuthorize("hasRole('ADMIN')") public class UserAdminController { private final UserRepository userRepository; + private final AuditService auditService; - public UserAdminController(UserRepository userRepository) { + public UserAdminController(UserRepository userRepository, AuditService auditService) { this.userRepository = userRepository; + this.auditService = auditService; } @GetMapping @@ -53,19 +62,25 @@ public class UserAdminController { @ApiResponse(responseCode = "200", description = "Roles updated") @ApiResponse(responseCode = "404", description = "User not found") public ResponseEntity updateRoles(@PathVariable String userId, - @RequestBody RolesRequest request) { + @RequestBody RolesRequest request, + HttpServletRequest httpRequest) { if (userRepository.findById(userId).isEmpty()) { return ResponseEntity.notFound().build(); } userRepository.updateRoles(userId, request.roles()); + auditService.log("update_roles", AuditCategory.USER_MGMT, userId, + Map.of("roles", request.roles()), AuditResult.SUCCESS, httpRequest); return ResponseEntity.ok().build(); } @DeleteMapping("/{userId}") @Operation(summary = "Delete user") @ApiResponse(responseCode = "204", description = "User deleted") - public ResponseEntity deleteUser(@PathVariable String userId) { + public ResponseEntity deleteUser(@PathVariable String userId, + HttpServletRequest httpRequest) { userRepository.delete(userId); + auditService.log("delete_user", AuditCategory.USER_MGMT, userId, + null, AuditResult.SUCCESS, httpRequest); return ResponseEntity.noContent().build(); } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java index 81394eaa..db7e0a73 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -3,11 +3,15 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.app.dto.AuthTokenResponse; import com.cameleer3.server.app.dto.ErrorResponse; import com.cameleer3.server.app.dto.OidcPublicConfigResponse; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.security.JwtService; import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfigRepository; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserRepository; +import jakarta.servlet.http.HttpServletRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -27,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException; import java.net.URI; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -46,15 +51,18 @@ public class OidcAuthController { private final OidcConfigRepository configRepository; private final JwtService jwtService; private final UserRepository userRepository; + private final AuditService auditService; public OidcAuthController(OidcTokenExchanger tokenExchanger, OidcConfigRepository configRepository, JwtService jwtService, - UserRepository userRepository) { + UserRepository userRepository, + AuditService auditService) { this.tokenExchanger = tokenExchanger; this.configRepository = configRepository; this.jwtService = jwtService; this.userRepository = userRepository; + this.auditService = auditService; } /** @@ -100,7 +108,8 @@ public class OidcAuthController { @ApiResponse(responseCode = "403", description = "Account not provisioned", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) @ApiResponse(responseCode = "404", description = "OIDC not configured or disabled") - public ResponseEntity callback(@RequestBody CallbackRequest request) { + public ResponseEntity callback(@RequestBody CallbackRequest request, + HttpServletRequest httpRequest) { Optional config = configRepository.find(); if (config.isEmpty() || !config.get().enabled()) { return ResponseEntity.notFound().build(); @@ -132,6 +141,8 @@ public class OidcAuthController { String displayName = oidcUser.name() != null && !oidcUser.name().isBlank() ? oidcUser.name() : oidcUser.email(); + auditService.log(userId, "login_oidc", AuditCategory.AUTH, null, + Map.of("provider", config.get().issuerUri()), AuditResult.SUCCESS, httpRequest); return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, oidcUser.idToken())); } catch (ResponseStatusException e) { throw e; diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index 2a6f786b..3add6b7a 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -27,6 +28,7 @@ import java.util.List; */ @Configuration @EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { @Bean diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java index 50a56486..6fd1805d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java @@ -2,7 +2,11 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.app.dto.AuthTokenResponse; import com.cameleer3.server.app.dto.ErrorResponse; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.security.JwtService; +import jakarta.servlet.http.HttpServletRequest; import com.cameleer3.server.core.security.JwtService.JwtValidationResult; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.server.core.security.UserRepository; @@ -23,6 +27,7 @@ import org.springframework.web.server.ResponseStatusException; import java.time.Instant; import java.util.List; +import java.util.Map; /** * Authentication endpoints for the UI (local credentials). @@ -41,12 +46,14 @@ public class UiAuthController { private final JwtService jwtService; private final SecurityProperties properties; private final UserRepository userRepository; + private final AuditService auditService; public UiAuthController(JwtService jwtService, SecurityProperties properties, - UserRepository userRepository) { + UserRepository userRepository, AuditService auditService) { this.jwtService = jwtService; this.properties = properties; this.userRepository = userRepository; + this.auditService = auditService; } @PostMapping("/login") @@ -54,19 +61,24 @@ public class UiAuthController { @ApiResponse(responseCode = "200", description = "Login successful") @ApiResponse(responseCode = "401", description = "Invalid credentials", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - public ResponseEntity login(@RequestBody LoginRequest request) { + public ResponseEntity login(@RequestBody LoginRequest request, + HttpServletRequest httpRequest) { String configuredUser = properties.getUiUser(); String configuredPassword = properties.getUiPassword(); if (configuredUser == null || configuredUser.isBlank() || configuredPassword == null || configuredPassword.isBlank()) { log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured"); + auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null, + Map.of("reason", "UI authentication not configured"), AuditResult.FAILURE, httpRequest); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured"); } if (!configuredUser.equals(request.username()) || !configuredPassword.equals(request.password())) { 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); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials"); } @@ -84,6 +96,7 @@ public class UiAuthController { String accessToken = jwtService.createAccessToken(subject, "user", roles); String refreshToken = jwtService.createRefreshToken(subject, "user", roles); + auditService.log(request.username(), "login", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest); log.info("UI user logged in: {}", request.username()); return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, request.username(), null)); }