From c2ba1e292f0af99f4eac2339e3025abe8c938d46 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Tue, 29 Sep 2026 10:09:55 +0200 Subject: [PATCH 01/11] feat(core): Global RBAC Initial commit --- docs/src/concepts/authentication.md | 42 +++- .../PrincipalNotAuthorizedException.java | 22 ++ .../authorization/AuthorizationMode.java | 6 + .../authorization/AuthorizationPolicy.java | 15 ++ .../authorization/AuthorizationRequest.java | 19 ++ .../GlobalAuthorizationService.java | 56 +++++ .../api/auth/GlobalAuthorizationFilter.java | 84 ++++++++ .../AuthorizationConfiguration.java | 40 ++++ .../AuthorizationProperties.java | 22 ++ .../security/chains/JwtFilterChainConfig.java | 7 +- .../chains/MockFilterChainConfig.java | 10 +- .../api/handler/ApiExceptionHandler.java | 13 +- src/main/resources/application.yml | 4 + .../GlobalAuthorizationServiceTest.java | 99 +++++++++ .../auth/GlobalAuthorizationFilterTest.java | 134 ++++++++++++ .../AuthorizationConfigurationTest.java | 43 ++++ .../chains/JwtFilterChainConfigTest.java | 22 ++ .../chains/MockFilterChainConfigTest.java | 23 +- .../api/controller/AuditControllerTest.java | 21 +- .../EntityDynamicMappingControllerTest.java | 41 ++-- .../EntityTemplateControllerTest.java | 43 +++- ...nboundWebhookManagementControllerTest.java | 4 +- .../api/handler/ApiExceptionHandlerTest.java | 201 ++++++++++-------- .../R__6_insert_principal_user_for_test.sql | 52 +++++ 24 files changed, 888 insertions(+), 135 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/authorization/PrincipalNotAuthorizedException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationMode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationPolicy.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationProperties.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java create mode 100644 src/test/resources/db/test/R__6_insert_principal_user_for_test.sql diff --git a/docs/src/concepts/authentication.md b/docs/src/concepts/authentication.md index 15de6cf7..00c37fb9 100644 --- a/docs/src/concepts/authentication.md +++ b/docs/src/concepts/authentication.md @@ -9,7 +9,7 @@ custom authentication methods. ## Architecture Overview -The authentication system consists of four main components working together: +The authentication system consists of five main components working together: ```mermaid graph LR @@ -17,14 +17,16 @@ graph LR B --> C["Authentication Filter
JWT/API Key/Mock"] C --> D["PrincipalExtractor
Strategy Pattern"] D --> E["JitProvisioningFilter
Auto-Provisioning"] - E --> F["Domain Service
Authorization"] - F --> G["Controller
Business Logic"] + E --> F["GlobalAuthorizationFilter
Global Policy"] + F --> G["Domain Service
Authorization"] + G --> H["Controller
Business Logic"] ``` - **Security Filter Chain**: Routes requests through configured authentication mechanisms in order - **Authentication Filters**: Extract credentials (JWT tokens, API keys, etc.) and create `Authentication` objects - **Principal Extractor**: Converts authentication tokens into domain `PrincipalInfo` objects - **JIT Provisioning Filter**: Automatically creates principals in the database on first authentication +- **Global Authorization Filter**: Enforces the global authorization policy in JWT and mock chains - **Domain Services**: Enforce business rules and authorization checks ## Filter Chain Architecture @@ -95,7 +97,8 @@ When JWT authentication is enabled, only these two pieces of information are mandatory: - spring.security.oauth2.resourceserver.jwt.jwk-set-uri required for signature validation. -- A valid JWT signature and standard sub claim, sub is effectively required for JIT provisioning because it is the final identifier fallback. +- A valid JWT signature and standard sub claim, sub is effectively required for JIT provisioning because it is the final + identifier fallback. When `app.security.authentication.jwt.enabled` is `false`, the JWT decoder and JWT filter chain are not created, so `OAUTH_JWK_URI` is not required. Swagger @@ -323,8 +326,8 @@ If none of these conditions is met, the token is classified as a `HUMAN_USER`. - Will be deprecated in future versions > [!WARNING] -> Migrate from legacy to strict mode is really encouraged for the systems meeting the prerequisites. Legacy mode is only recommended for existing systems -that cannot immediately reconfigure their Identity Provider configuration. +> Migrate from legacy to strict mode is really encouraged for the systems meeting the prerequisites. Legacy mode is only +recommended for existing systems ## Principal Extraction @@ -404,6 +407,33 @@ app: > Keep the JIT excluded paths in sync with the `PublicFilterChainConfig` permission mechanism. If you add a public path to one, add it to both. +## Global Authorization + +The JWT and mock filter chains apply a global authorization policy after JIT provisioning. The API key chain does not +use this policy. In `GLOBAL` mode, the service evaluates requests in this order: + +| Order | Condition | Result | +|-------|----------------------------------------------------------------------|-----------------| +| 1 | The principal identifier matches a configured break-glass identifier | Allow | +| 2 | The principal catalog entity has `is_admin: true` | Allow | +| 3 | The principal is a `SERVICE_ACCOUNT` | Allow | +| 4 | The request uses `GET`, `HEAD`, or `OPTIONS` | Allow | +| 5 | None of the conditions above match | Return HTTP 403 | + +The principal identifier uses `preferred_username` when available and falls back to the JWT `sub` claim. Configure one +or both break-glass identifiers as environment variables: + +```yaml +app: + security: + authorization: + mode: GLOBAL + global-principal-identifiers: ${IDP_SUPER_ADMIN_UUID:},${IDP_PLATFORM_ADMIN_UUID:} +``` + +The `RBAC` and `CATALOG` modes are reserved for future authorization policies. Requests fail closed when either +unimplemented mode is configured. Use `GLOBAL` until those modes are implemented. + ## API Key Authentication For webhook receivers and service-to-service calls that don't use OAuth2, IDP-Core supports simple API key diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/authorization/PrincipalNotAuthorizedException.java b/src/main/java/com/decathlon/idp_core/domain/exception/authorization/PrincipalNotAuthorizedException.java new file mode 100644 index 00000000..011f1427 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/authorization/PrincipalNotAuthorizedException.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.domain.exception.authorization; + +/// Indicates that a principal is not permitted to perform the requested operation. +public class PrincipalNotAuthorizedException extends RuntimeException { + + private final String identifier; + + /// Creates an authorization failure for the specified principal. + /// + /// @param identifier the identifier of the principal denied access + public PrincipalNotAuthorizedException(String identifier) { + super("Principal '" + identifier + "' is not authorized to perform this operation"); + this.identifier = identifier; + } + + /// Returns the identifier of the principal denied access. + /// + /// @return the principal identifier + public String getIdentifier() { + return identifier; + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationMode.java b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationMode.java new file mode 100644 index 00000000..747769c8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationMode.java @@ -0,0 +1,6 @@ +package com.decathlon.idp_core.domain.model.authorization; + +/// Authorization strategies supported by the platform. +public enum AuthorizationMode { + GLOBAL, RBAC, ABAC +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationPolicy.java b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationPolicy.java new file mode 100644 index 00000000..8a89cddf --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationPolicy.java @@ -0,0 +1,15 @@ +package com.decathlon.idp_core.domain.model.authorization; + +import java.util.Objects; +import java.util.Set; + +/// Immutable authorization configuration used by domain authorization services. +public record AuthorizationPolicy(AuthorizationMode mode, Set globalPrincipalIdentifiers) { + + public AuthorizationPolicy { + Objects.requireNonNull(mode, "mode must not be null"); + globalPrincipalIdentifiers = globalPrincipalIdentifiers != null + ? Set.copyOf(globalPrincipalIdentifiers) + : Set.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java new file mode 100644 index 00000000..a6906722 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java @@ -0,0 +1,19 @@ +package com.decathlon.idp_core.domain.model.authorization; + +import java.util.Objects; +import java.util.Optional; + +import jakarta.validation.constraints.NotNull; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; + +/// Domain input describing a principal's request without infrastructure types. +public record AuthorizationRequest(@NotNull PrincipalInfo principal, + @NotNull Optional principalEntity, boolean readOperation) { + + public AuthorizationRequest { + Objects.requireNonNull(principal, "principal must not be null"); + Objects.requireNonNull(principalEntity, "principalEntity must not be null"); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java b/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java new file mode 100644 index 00000000..84941fba --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java @@ -0,0 +1,56 @@ +package com.decathlon.idp_core.domain.service.authorization; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationMode; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.principal.PrincipalKind; + +/// Applies the global authorization policy to a principal request. +@Service +public class GlobalAuthorizationService { + + private static final String IS_ADMIN_PROPERTY = "is_admin"; + private static final String PRINCIPAL_TEMPLATE_IDENTIFIER = "principal"; + + /// Authorizes break-glass principals, catalog administrators, service accounts, + /// and reads. + /// + /// @param request the principal and operation being authorized + /// @param policy the configured global authorization policy + /// @throws PrincipalNotAuthorizedException when the request is not permitted + public void authorize(AuthorizationRequest request, AuthorizationPolicy policy) { + String identifier = request.principal().identifier(); + if (policy.mode() != AuthorizationMode.GLOBAL) { + throw new PrincipalNotAuthorizedException(identifier); + } + + if (policy.globalPrincipalIdentifiers().contains(identifier)) { + return; + } + + if (request.principalEntity().filter(this::isCatalogAdministrator).isPresent()) { + return; + } + + if (request.principal().kind() == PrincipalKind.SERVICE_ACCOUNT) { + return; + } + + if (request.readOperation()) { + return; + } + + throw new PrincipalNotAuthorizedException(identifier); + } + + private boolean isCatalogAdministrator(Entity principalEntity) { + return PRINCIPAL_TEMPLATE_IDENTIFIER.equals(principalEntity.templateIdentifier()) + && principalEntity.properties().stream() + .anyMatch(property -> IS_ADMIN_PROPERTY.equals(property.name()) + && Boolean.parseBoolean(property.value())); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java new file mode 100644 index 00000000..7d37aaeb --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java @@ -0,0 +1,84 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import java.io.IOException; +import java.util.Set; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; +import com.decathlon.idp_core.domain.service.authorization.GlobalAuthorizationService; +import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; +import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; + +import lombok.extern.slf4j.Slf4j; + +/// Enforces the global authorization policy for authenticated JWT and mock requests. +@Slf4j +@Component +public class GlobalAuthorizationFilter extends OncePerRequestFilter { + + private static final Set READ_METHODS = Set.of("GET", "HEAD", "OPTIONS"); + + private final PrincipalExtractor principalExtractor; + private final PrincipalProvisioningService provisioningService; + private final GlobalAuthorizationService authorizationService; + private final AuthorizationPolicy authorizationPolicy; + + /// Creates a filter backed by the domain authorization service and configured + /// policy. + /// + /// @param principalExtractor converts Spring authentication into a domain + /// principal + /// @param provisioningService retrieves the principal's catalog entity + /// @param authorizationService evaluates the authorization policy + /// @param authorizationPolicy configured policy for authenticated requests + public GlobalAuthorizationFilter(PrincipalExtractor principalExtractor, + PrincipalProvisioningService provisioningService, + GlobalAuthorizationService authorizationService, AuthorizationPolicy authorizationPolicy) { + this.principalExtractor = principalExtractor; + this.provisioningService = provisioningService; + this.authorizationService = authorizationService; + this.authorizationPolicy = authorizationPolicy; + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) + throws ServletException, IOException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + filterChain.doFilter(request, response); + return; + } + + PrincipalInfo principal = principalExtractor.extractPrincipalInfo(authentication); + var principalEntity = provisioningService.getPrincipal(principal.identifier()); + AuthorizationRequest authorizationRequest = new AuthorizationRequest(principal, principalEntity, + READ_METHODS.contains(request.getMethod())); + + try { + authorizationService.authorize(authorizationRequest, authorizationPolicy); + } catch (PrincipalNotAuthorizedException exception) { + log.warn("Authorization denied for principal {} on {} {}", principal.identifier(), + request.getMethod(), request.getRequestURI()); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java new file mode 100644 index 00000000..fe10f10b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java @@ -0,0 +1,40 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import java.util.Set; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; + +/// Bridges external authorization properties into the pure domain policy. +@Configuration +@EnableConfigurationProperties(AuthorizationProperties.class) +public class AuthorizationConfiguration { + + /// Binds a disabled servlet registration so the filter only runs in selected + /// security chains. + /// + /// @param filter the filter instance used by the JWT and mock security chains + /// @return a disabled servlet-container registration + @Bean + public FilterRegistrationBean globalAuthorizationFilterRegistration( + GlobalAuthorizationFilter filter) { + var registration = new FilterRegistrationBean<>(filter); + registration.setEnabled(false); + return registration; + } + + /// Creates the pure domain policy from externalized authorization settings. + /// + /// @param properties external authorization configuration + /// @return an immutable domain authorization policy + @Bean + public AuthorizationPolicy authorizationPolicy(AuthorizationProperties properties) { + return new AuthorizationPolicy(properties.mode(), + Set.copyOf(properties.globalPrincipalIdentifiers())); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationProperties.java new file mode 100644 index 00000000..81f46548 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationProperties.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import com.decathlon.idp_core.domain.model.authorization.AuthorizationMode; + +/// Authorization settings bound from `app.security.authorization`. +@ConfigurationProperties(prefix = "app.security.authorization") +public record AuthorizationProperties(AuthorizationMode mode, + List globalPrincipalIdentifiers) { + + public AuthorizationProperties { + mode = mode != null ? mode : AuthorizationMode.GLOBAL; + globalPrincipalIdentifiers = globalPrincipalIdentifiers != null + ? globalPrincipalIdentifiers.stream() + .filter(identifier -> identifier != null && !identifier.isBlank()).map(String::trim) + .toList() + : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfig.java index 9c189c0a..9488ca17 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfig.java @@ -11,6 +11,7 @@ import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; @Configuration @@ -18,11 +19,14 @@ public class JwtFilterChainConfig { private final JitProvisioningFilter jitProvisioningFilter; + private final GlobalAuthorizationFilter globalAuthorizationFilter; private final JwtAuthenticationConverter jwtAuthenticationConverter; public JwtFilterChainConfig(JitProvisioningFilter jitProvisioningFilter, + GlobalAuthorizationFilter globalAuthorizationFilter, JwtAuthenticationConverter jwtAuthenticationConverter) { this.jitProvisioningFilter = jitProvisioningFilter; + this.globalAuthorizationFilter = globalAuthorizationFilter; this.jwtAuthenticationConverter = jwtAuthenticationConverter; } @@ -34,7 +38,8 @@ public SecurityFilterChain jwtSecurityFilterChain(HttpSecurity http) { .cors(withDefaults()) .oauth2ResourceServer( oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))) - .addFilterAfter(jitProvisioningFilter, BearerTokenAuthenticationFilter.class); + .addFilterAfter(jitProvisioningFilter, BearerTokenAuthenticationFilter.class) + .addFilterAfter(globalAuthorizationFilter, JitProvisioningFilter.class); return http.build(); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java index 076ae659..00889d9d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java @@ -31,6 +31,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import com.decathlon.idp_core.domain.exception.mock.MockSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; /// Local mock security configuration that mirrors OAuth2/JWT behavior for local development. @@ -51,9 +52,12 @@ public class MockFilterChainConfig { private final JitProvisioningFilter jitProvisioningFilter; + private final GlobalAuthorizationFilter globalAuthorizationFilter; - public MockFilterChainConfig(JitProvisioningFilter jitProvisioningFilter) { + public MockFilterChainConfig(JitProvisioningFilter jitProvisioningFilter, + GlobalAuthorizationFilter globalAuthorizationFilter) { this.jitProvisioningFilter = jitProvisioningFilter; + this.globalAuthorizationFilter = globalAuthorizationFilter; } /// Security filter chain for local mocking with JWT-like behavior. @@ -82,7 +86,9 @@ public SecurityFilterChain mockSecurityFilterChain(HttpSecurity http) { .addFilterBefore(new MockJwtAuthenticationFilter(), AnonymousAuthenticationFilter.class) // 2. Trigger JIT provisioning based on the fake token (tests production // behavior locally) - .addFilterAfter(jitProvisioningFilter, MockJwtAuthenticationFilter.class); + .addFilterAfter(jitProvisioningFilter, MockJwtAuthenticationFilter.class) + // 3. Apply the global authorization policy after the principal is provisioned + .addFilterAfter(globalAuthorizationFilter, JitProvisioningFilter.class); } catch (Exception e) { throw new MockSecurityConfigurationException("Failed to configure mock security filter chain", diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 7627cd64..685c0060 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -11,7 +11,6 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -22,6 +21,7 @@ import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.servlet.NoHandlerFoundException; +import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; @@ -423,6 +423,17 @@ public ResponseEntity handlePrincipalNotFoundException( return ResponseEntity.status(NOT_FOUND).body(errorResponse); } + /// Handles domain exception when a principal is not authorized. + /// + /// **HTTP mapping:** Maps PrincipalNotAuthorizedException to HTTP 403 + /// Forbidden. + @ExceptionHandler(PrincipalNotAuthorizedException.class) + public ResponseEntity handlePrincipalNotAuthorizedException( + PrincipalNotAuthorizedException ex) { + log.warn("Principal not authorized: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.FORBIDDEN, ex.getMessage()); + } + /// Handles Bean Validation constraint violations from domain model validation. /// /// **Error aggregation:** Combines multiple constraint violation messages into diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bd217e2e..e37e3fd5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -118,6 +118,10 @@ app: # Base URL of this service, used to build self-referencing links. idp-core-prefix-url: ${IDP_CORE_PREFIX_URL} security: + authorization: + mode: GLOBAL + # Comma-separated break-glass identifiers; each value may be supplied independently. + global-principal-identifiers: ${IDP_SUPER_ADMIN_UUID:},${IDP_PLATFORM_ADMIN_UUID:} roles: # Baseline role assigned to all authenticated principals during V1 phase. baseline-role: ${APP_SECURITY_ROLES_BASELINE_ROLE:*} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java new file mode 100644 index 00000000..4bce84ae --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java @@ -0,0 +1,99 @@ +package com.decathlon.idp_core.domain.service.authorization; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationMode; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; +import com.decathlon.idp_core.domain.model.principal.PrincipalKind; + +class GlobalAuthorizationServiceTest { + + private static final String PRINCIPAL_IDENTIFIER = "platform-user"; + + private final GlobalAuthorizationService authorizationService = new GlobalAuthorizationService(); + private AuthorizationPolicy policy; + private PrincipalInfo humanPrincipal; + + @BeforeEach + void setUp() { + policy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of("break-glass-user")); + humanPrincipal = principal(PRINCIPAL_IDENTIFIER, PrincipalKind.HUMAN); + } + + @Test + void shouldAllowBreakGlassPrincipalBeforeCheckingCatalogAdmin() { + var request = new AuthorizationRequest(principal("break-glass-user", PrincipalKind.HUMAN), + Optional.empty(), false); + + assertThatCode(() -> authorizationService.authorize(request, policy)) + .doesNotThrowAnyException(); + } + + @Test + void shouldAllowPrincipalWithIsAdminSetToTrue() { + var request = new AuthorizationRequest(humanPrincipal, Optional.of(principalEntity("true")), + false); + + assertThatCode(() -> authorizationService.authorize(request, policy)) + .doesNotThrowAnyException(); + } + + @Test + void shouldAllowServiceAccountForWriteOperations() { + var request = new AuthorizationRequest( + principal(PRINCIPAL_IDENTIFIER, PrincipalKind.SERVICE_ACCOUNT), Optional.empty(), false); + + assertThatCode(() -> authorizationService.authorize(request, policy)) + .doesNotThrowAnyException(); + } + + @Test + void shouldAllowReadOperationsForNonAdminHumans() { + var request = new AuthorizationRequest(humanPrincipal, Optional.empty(), true); + + assertThatCode(() -> authorizationService.authorize(request, policy)) + .doesNotThrowAnyException(); + } + + @Test + void shouldRejectWriteOperationsForNonAdminHumans() { + var request = new AuthorizationRequest(humanPrincipal, Optional.of(principalEntity("false")), + false); + + assertThatThrownBy(() -> authorizationService.authorize(request, policy)) + .isInstanceOf(PrincipalNotAuthorizedException.class) + .hasMessageContaining(PRINCIPAL_IDENTIFIER); + } + + @Test + void shouldRejectModesThatAreNotImplementedYet() { + var futureModePolicy = new AuthorizationPolicy(AuthorizationMode.RBAC, Set.of()); + var request = new AuthorizationRequest(humanPrincipal, Optional.empty(), true); + + assertThatThrownBy(() -> authorizationService.authorize(request, futureModePolicy)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + } + + private PrincipalInfo principal(String identifier, PrincipalKind kind) { + return new PrincipalInfo(identifier, kind, "Platform User", null, null); + } + + private Entity principalEntity(String isAdmin) { + return new Entity(UUID.randomUUID(), "principal", "Platform User", PRINCIPAL_IDENTIFIER, + List.of(new Property(null, "is_admin", isAdmin)), List.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java new file mode 100644 index 00000000..c7bf4b16 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java @@ -0,0 +1,134 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationMode; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; +import com.decathlon.idp_core.domain.model.principal.PrincipalKind; +import com.decathlon.idp_core.domain.service.authorization.GlobalAuthorizationService; +import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; +import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; + +class GlobalAuthorizationFilterTest { + + private final PrincipalExtractor principalExtractor = mock(PrincipalExtractor.class); + private final PrincipalProvisioningService provisioningService = mock( + PrincipalProvisioningService.class); + private final GlobalAuthorizationService authorizationService = mock( + GlobalAuthorizationService.class); + private final PrincipalInfo principal = new PrincipalInfo("platform-user", PrincipalKind.HUMAN, + "Platform User", Map.of(), List.of()); + private GlobalAuthorizationFilter filter; + private Authentication authentication; + + @BeforeEach + void setUp() { + var policy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of()); + filter = new GlobalAuthorizationFilter(principalExtractor, provisioningService, + authorizationService, policy); + authentication = mock(Authentication.class); + when(authentication.isAuthenticated()).thenReturn(true); + SecurityContextHolder.getContext().setAuthentication(authentication); + when(principalExtractor.extractPrincipalInfo(authentication)).thenReturn(principal); + when(provisioningService.getPrincipal("platform-user")).thenReturn(Optional.empty()); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @ParameterizedTest + @MethodSource("readMethods") + void shouldMarkReadMethodsAsReadAndContinue(String method) throws ServletException, IOException { + var request = new MockHttpServletRequest("GET", "/api/v1/entities"); + request.setMethod(method); + var response = new MockHttpServletResponse(); + FilterChain chain = (req, res) -> { + }; + + filter.doFilter(request, response, chain); + + var requestCaptor = org.mockito.ArgumentCaptor.forClass(AuthorizationRequest.class); + verify(authorizationService).authorize(requestCaptor.capture(), any(AuthorizationPolicy.class)); + assertThat(requestCaptor.getValue().readOperation()).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + void shouldReturnForbiddenAndStopChainWhenAuthorizationIsDenied() + throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/api/v1/entities"); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + doThrow(new PrincipalNotAuthorizedException("platform-user")).when(authorizationService) + .authorize(any(AuthorizationRequest.class), any(AuthorizationPolicy.class)); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(403); + verify(chain, never()).doFilter(any(), any()); + } + + @Test + void shouldTreatNonUppercaseReadMethodsAsWrites() throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/api/v1/entities"); + request.setMethod("get"); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + doThrow(new PrincipalNotAuthorizedException("platform-user")).when(authorizationService) + .authorize(any(AuthorizationRequest.class), any(AuthorizationPolicy.class)); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(403); + verify(chain, never()).doFilter(any(), any()); + } + + @Test + void shouldFailClosedWhenPrincipalLookupFails() throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/api/v1/entities"); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + when(provisioningService.getPrincipal("platform-user")) + .thenThrow(new IllegalStateException("catalog unavailable")); + + assertThatThrownBy(() -> filter.doFilter(request, response, chain)) + .isInstanceOf(IllegalStateException.class); + verify(chain, never()).doFilter(any(), any()); + } + + private static Stream readMethods() { + return Stream.of("GET", "HEAD", "OPTIONS"); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java new file mode 100644 index 00000000..15afae74 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java @@ -0,0 +1,43 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.web.servlet.FilterRegistrationBean; + +import com.decathlon.idp_core.domain.model.authorization.AuthorizationMode; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; + +class AuthorizationConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(AuthorizationConfiguration.class) + .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)); + + @Test + void shouldBridgeConfiguredAuthorizationPropertiesIntoDomainPolicy() { + contextRunner + .withPropertyValues("app.security.authorization.mode=GLOBAL", + "app.security.authorization.global-principal-identifiers=super-admin, platform-admin") + .run(context -> { + assertThat(context).hasSingleBean(AuthorizationProperties.class) + .hasSingleBean(AuthorizationPolicy.class); + assertThat(context.getBean(AuthorizationPolicy.class).mode()) + .isEqualTo(AuthorizationMode.GLOBAL); + assertThat(context.getBean(AuthorizationPolicy.class).globalPrincipalIdentifiers()) + .containsExactlyInAnyOrder("super-admin", "platform-admin"); + assertThat( + context.getBean("globalAuthorizationFilterRegistration", FilterRegistrationBean.class) + .isEnabled()).isFalse(); + }); + } + + @Test + void shouldDefaultToGlobalModeWhenModeIsNotConfigured() { + contextRunner.run(context -> assertThat(context.getBean(AuthorizationPolicy.class).mode()) + .isEqualTo(AuthorizationMode.GLOBAL)); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfigTest.java index acc3a441..53b321d7 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/JwtFilterChainConfigTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.DisplayName; @@ -16,6 +17,7 @@ import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.SecurityFilterChain; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; @DisplayName("JwtFilterChainConfigTest") @@ -28,6 +30,7 @@ class JwtFilterChainConfigTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(JwtFilterChainConfig.class)) .withBean(JitProvisioningFilter.class, () -> mock(JitProvisioningFilter.class)) + .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)) .withBean(JwtAuthenticationConverter.class, JwtAuthenticationConverter::new) .withBean(HttpSecurity.class, () -> httpSecurity()); @@ -45,9 +48,28 @@ void shouldNotCreateJwtFilterChainWhenDisabled() { .doesNotHaveBean(SecurityFilterChain.class)); } + @Test + void shouldRegisterGlobalAuthorizationAfterJitProvisioning() { + var jitFilter = mock(JitProvisioningFilter.class); + var authorizationFilter = mock(GlobalAuthorizationFilter.class); + var configuration = new JwtFilterChainConfig(jitFilter, authorizationFilter, + new JwtAuthenticationConverter()); + HttpSecurity http = httpSecurity(); + + configuration.jwtSecurityFilterChain(http); + + verify(http).addFilterAfter(authorizationFilter, JitProvisioningFilter.class); + } + private HttpSecurity httpSecurity() { HttpSecurity http = mock(HttpSecurity.class, org.mockito.Answers.RETURNS_DEEP_STUBS); when(http.build()).thenReturn(securityFilterChain); + when(http.authorizeHttpRequests(org.mockito.ArgumentMatchers.any())).thenReturn(http); + when(http.cors(org.mockito.ArgumentMatchers.any())).thenReturn(http); + when(http.oauth2ResourceServer(org.mockito.ArgumentMatchers.any())).thenReturn(http); + when( + http.addFilterAfter(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(http); return http; } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java index 2cb912c3..a877727e 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; @@ -32,6 +33,7 @@ import org.springframework.security.web.SecurityFilterChain; import com.decathlon.idp_core.domain.exception.mock.MockSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; /// Unit tests for MockFilterChainConfig and MockJwtAuthenticationFilter. @@ -78,11 +80,17 @@ void shouldInjectMockJwtAuthenticationAndClearAfterwards() throws ServletExcepti private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(MockFilterChainConfig.class)) .withBean(JitProvisioningFilter.class, () -> mock(JitProvisioningFilter.class)) + .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)) .withBean(HttpSecurity.class, this::httpSecurity); private HttpSecurity httpSecurity() { HttpSecurity http = mock(HttpSecurity.class, org.mockito.Answers.RETURNS_DEEP_STUBS); when(http.build()).thenReturn(securityFilterChain); + when(http.sessionManagement(any())).thenReturn(http); + when(http.cors(any())).thenReturn(http); + when(http.authorizeHttpRequests(any())).thenReturn(http); + when(http.addFilterBefore(any(), any())).thenReturn(http); + when(http.addFilterAfter(any(), any())).thenReturn(http); return http; } @@ -113,7 +121,8 @@ void shouldNotLoadConfigurationWhenPropertyIsMissing() { void shouldThrowMockFilterChainConfigExceptionWhenHttpConfigFails() { // Given JitProvisioningFilter jitProvisioningFilterMock = mock(JitProvisioningFilter.class); - MockFilterChainConfig configuration = new MockFilterChainConfig(jitProvisioningFilterMock); + MockFilterChainConfig configuration = new MockFilterChainConfig(jitProvisioningFilterMock, + mock(GlobalAuthorizationFilter.class)); HttpSecurity httpSecurityMock = mock(HttpSecurity.class); RuntimeException simulatedError = new RuntimeException("Internal simulated Error"); when(httpSecurityMock.sessionManagement(any())).thenThrow(simulatedError); @@ -128,6 +137,18 @@ void shouldThrowMockFilterChainConfigExceptionWhenHttpConfigFails() { @DisplayName("JWT Token Creation Tests") class JwtTokenCreationTests { + @Test + void shouldRegisterGlobalAuthorizationAfterJitProvisioning() { + var jitFilter = mock(JitProvisioningFilter.class); + var authorizationFilter = mock(GlobalAuthorizationFilter.class); + var configuration = new MockFilterChainConfig(jitFilter, authorizationFilter); + HttpSecurity http = httpSecurity(); + + configuration.mockSecurityFilterChain(http); + + verify(http).addFilterAfter(authorizationFilter, JitProvisioningFilter.class); + } + @Test @DisplayName("Should create mock JWT with correct subject") void shouldCreateMockJwtWithCorrectSubject() throws ServletException, IOException { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditControllerTest.java index db0c9c8c..22f6eb8a 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditControllerTest.java @@ -13,23 +13,24 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MockMvc; import com.decathlon.idp_core.AbstractIntegrationTest; @DisplayName("Audit Controller Integration Tests") +@Sql(scripts = { + "/db/test/R__6_insert_principal_user_for_test.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) class AuditControllerTest extends AbstractIntegrationTest { private static final String AUDIT_JSON_FILES_TEST_PATH = "integration_test/json/audit/v1/"; - - @Autowired - private MockMvc mockMvc; - private static final String AUDIT_BASE_PATH = "/api/v1/audit/entities"; private static final String ENTITY_BASE_PATH = "/api/v1/entities"; + @Autowired + private MockMvc mockMvc; @Test - @WithMockUser + @WithMockUser(username = "test-admin") @DisplayName("Should return audit history for existing entity") void getAuditHistory_shouldReturnAuditNotFound_whenEntityExistsBeforeAudit() throws Exception { String templateIdentifier = "web-service"; @@ -41,7 +42,7 @@ void getAuditHistory_shouldReturnAuditNotFound_whenEntityExistsBeforeAudit() thr } @Test - @WithMockUser + @WithMockUser(username = "test-admin") @DisplayName("Should return 404 when entity does not exist") void getAuditHistory_shouldReturn404_whenEntityDoesNotExist() throws Exception { String templateIdentifier = "non-existing-template"; @@ -62,7 +63,7 @@ void getAuditHistory_shouldReturn401_withoutAuthentication() throws Exception { } @Test - @WithMockUser(username = "test-user") + @WithMockUser(username = "test-admin") @DisplayName("Should track complete lifecycle (Create, Update, Delete) in audit history") void auditHistory_shouldTrackFullLifecycle() throws Exception { String templateIdentifier = "web-audited"; @@ -79,16 +80,16 @@ void auditHistory_shouldTrackFullLifecycle() throws Exception { // Latest action (DELETED) .andExpect(jsonPath("$[0].revision_type").value("DELETED")) - .andExpect(jsonPath("$[0].modified_by").value("test-user")) + .andExpect(jsonPath("$[0].modified_by").value("test-admin")) // Middle action (UPDATED) .andExpect(jsonPath("$[1].revision_type").value("UPDATED")) - .andExpect(jsonPath("$[1].modified_by").value("test-user")) + .andExpect(jsonPath("$[1].modified_by").value("test-admin")) .andExpect(jsonPath("$[1].snapshot.name").value("Audit Test Entity Updated")) // First action (CREATED) .andExpect(jsonPath("$[2].revision_type").value("CREATED")) - .andExpect(jsonPath("$[2].modified_by").value("test-user")) + .andExpect(jsonPath("$[2].modified_by").value("test-admin")) .andExpect(jsonPath("$[2].snapshot.name").value("Audit Test Entity")); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java index c2c18cf7..5dddd058 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java @@ -1,13 +1,21 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.*; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DYNAMIC_MAPPING_TEMPLATE_IDENTIFIER_MANDATORY; import static org.hamcrest.Matchers.containsString; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import org.junit.jupiter.api.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.jdbc.Sql; @@ -24,16 +32,15 @@ /// for all CRUD operations on entity dynamic mappings. @DisplayName("EntityDynamicMappingController Integration Tests") @TestClassOrder(ClassOrderer.OrderAnnotation.class) -@Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", - "/db/test/R__4_insert_webhook_test_data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", "/db/test/R__4_insert_webhook_test_data.sql", + "/db/test/R__6_insert_principal_user_for_test.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Slf4j class EntityDynamicMappingControllerTest extends AbstractIntegrationTest { + private static final String MAPPING_PATH = "/api/v1/entity_dynamic_mappings"; @Autowired private MockMvc mockMvc; - private static final String MAPPING_PATH = "/api/v1/entity_dynamic_mappings"; - /// Builds a valid entity dynamic mapping creation payload. private String buildCreatePayload(String mappingIdentifier) { return buildCreatePayload(mappingIdentifier, "UPDATE_ENTITY"); @@ -815,11 +822,11 @@ private String buildApimApiDryRunPayload() { "relations": [ { "name": "apim-api-consumed_by-component", - "target_entity_identifiers": [".relations.\\\"apim-api-consumed_by-component\\\""] + "target_entity_identifiers": [".relations.\\"apim-api-consumed_by-component\\""] }, { "name": "apim-api-provided_by-component", - "target_entity_identifiers": [".relations.\\\"apim-api-provided_by-component\\\""] + "target_entity_identifiers": [".relations.\\"apim-api-provided_by-component\\""] } ] } @@ -1353,17 +1360,17 @@ void dryRunMapping_422_with_expression_evaluation_failed_exception() throws Exce "mapping": { "identifier": "runtime-expression-error-test", "entity_template_identifier": "microservice", - "filter": ".action == \\\"pushed\\\"", + "filter": ".action == \\"pushed\\"", "action": "UPDATE_ENTITY", "name": "runtime expression error test", "description": "test", "entity": { - "identifier": "error(\\\"forced dry-run failure\\\")", + "identifier": "error(\\"forced dry-run failure\\")", "name": ".repository.name", "properties": { "applicationName": ".repository.name", "ownerEmail": ".sender.email", - "environment": "\\\"DEV\\\"", + "environment": "\\"DEV\\"", "version": ".ref", "port": "8080", "programmingLanguage": ".repository.language" @@ -1446,7 +1453,7 @@ void dryRunMapping_422_when_owner_email_is_not_extracted() throws Exception { "mapping": { "identifier": "github-commits-dry-run", "entity_template_identifier": "microservice", - "filter": ".action == \\\"pushed\\\"", + "filter": ".action == \\"pushed\\"", "action": "UPDATE_ENTITY", "name": "GitHub multi-commit dry-run", "description": "Generation d'une liste d'entites a partir des commits", @@ -1456,10 +1463,10 @@ void dryRunMapping_422_when_owner_email_is_not_extracted() throws Exception { "properties": { "applicationName": ".repository.name", "ownerEmail": ".email", - "port": "\\\"8080\\\"", + "port": "\\"8080\\"", "programmingLanguage": ".repository.language", - "version": "\\\"1.0.0\\\"", - "environment": "\\\"DEV\\\"" + "version": "\\"1.0.0\\"", + "environment": "\\"DEV\\"" }, "relations": [] } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index 8b4b52f2..3a7272ff 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.TestClassOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -40,15 +41,16 @@ @DisplayName("EntityTemplate Controller Integration Tests") @TestClassOrder(ClassOrderer.OrderAnnotation.class) +@Sql(scripts = { + "/db/test/R__6_insert_principal_user_for_test.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Slf4j class EntityTemplateControllerTest extends AbstractIntegrationTest { + private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; @Autowired private MockMvc mockMvc; - @Autowired private EntityTemplateRepositoryPort entityTemplateRepository; - private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; /// Test suite for the GET /api/v1/entity-templates endpoint, covering paginated /// retrieval of entity templates. @@ -75,6 +77,7 @@ class GetTemplatesPaginatedTests { /// - Default pagination settings are applied (page 0, size 20) /// - Template ordering is consistent (batch-job at index 1) /// - Pagination metadata is correctly populated + /// /// @throws Exception if the MockMvc request fails @Test @DisplayName("Should return paginated templates with default pagination") @@ -94,6 +97,7 @@ void getTemplates_paginated_200() throws Exception { /// Tests that accessing the /api/v1/entity-templates/ endpoint without /// authentication returns a 401 Unauthorized status. + /// /// @throws Exception if an error occurs during the request @Test @DisplayName("Should return 401 without authentication") @@ -106,6 +110,7 @@ void getTemplates_paginated_401_without_user_token() throws Exception { /// parameters. /// This test verifies that: /// - Custom pagination parameters are correctly applied (page=1, size=5, + /// /// @throws Exception if the MockMvc request fails @Test @DisplayName("Should return paginated templates with custom pagination") @@ -127,6 +132,7 @@ void getTemplates_paginated_200_custom() throws Exception { /// Tests the GET /api/v1/entity-templates/{identifier} endpoint for /// retrieving a specific template. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @DisplayName("Should return 200 even with invalid pagination parameters") @@ -141,6 +147,7 @@ void getTemplates_paginated_200_invalid_pagination() throws Exception { /// parameter. /// This test verifies that: /// - The endpoint returns HTTP 200 OK status when identifier parameter is + /// /// @throws Exception if the MockMvc request fails @Test @DisplayName("Should return 200 with valid identifier") @@ -162,6 +169,7 @@ class PostTemplateTests { /// Tests the POST /api/v1/entity-templates endpoint for successful template /// creation. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -177,6 +185,7 @@ void postTemplate_201() throws Exception { /// Tests the POST /api/v1/entity-templates endpoint without authentication. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @DisplayName("Should create template and return 401") @@ -194,6 +203,7 @@ void postTemplate_401_without_user_token() throws Exception { /// missing. /// This test verifies that: /// - Validation error message matches expected template identifier mandatory + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -209,6 +219,7 @@ void postTemplate_400_identifier_missing() throws Exception { /// blank. /// This test verifies that: /// - Validation error message matches expected template identifier mandatory + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -224,6 +235,7 @@ void postTemplate_400_identifier_blank() throws Exception { /// already exists. /// This test verifies that: /// - Validation error message contains expected template name already exists + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -239,6 +251,7 @@ void postTemplate_409_name_already_exists() throws Exception { /// missing. /// This test verifies that: /// - Validation error message matches expected template identifier mandatory + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -254,6 +267,7 @@ void postTemplate_400_name_missing() throws Exception { /// blank. /// This test verifies that: /// - Validation error message contains expected template name mandatory message + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -269,6 +283,7 @@ void postTemplate_400_name_blank() throws Exception { /// too long. /// This test verifies that: /// - Validation error message matches expected template name too long + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -284,6 +299,7 @@ void postTemplate_400_name_too_long() throws Exception { /// not respect regex pattern. /// This test verifies that: /// - Validation error message matches expected template name pattern + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -299,6 +315,7 @@ void postTemplate_400_name_invalid_pattern() throws Exception { /// empty. /// This test verifies that: /// - Validation error message indicates property definitions are + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -313,6 +330,7 @@ void postTemplate_400_property_name_missing() throws Exception { /// Tests the POST /api/v1/entity-templates endpoint when property name field is /// blank. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -327,6 +345,7 @@ void postTemplate_400_property_name_blank() throws Exception { /// Tests the POST /api/v1/entity-templates endpoint when property description /// field is missing. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -342,6 +361,7 @@ void postTemplate_400_property_description_missing() throws Exception { /// Tests the POST /api/v1/entity-templates endpoint when property description /// field is blank. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -356,6 +376,7 @@ void postTemplate_400_property_description_blank() throws Exception { /// Tests the POST /api/v1/entity-templates endpoint when property type field is /// missing. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -370,6 +391,7 @@ void postTemplate_400_property_type_missing() throws Exception { /// Tests the POST /api/v1/entity-templates endpoint when trying to create a /// template with duplicate identifier. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -390,6 +412,7 @@ void postTemplate_409_identifier_already_exists() throws Exception { /// Tests the POST /api/v1/entity-templates endpoint when property type contains /// an invalid enum value. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -404,6 +427,7 @@ void postTemplate_400_property_type_invalid_enum() throws Exception { /// Tests the POST /api/v1/entity-templates endpoint when property format /// contains an invalid enum value. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -418,6 +442,7 @@ void postTemplate_400_property_format_invalid_enum() throws Exception { /// Tests the POST /api/v1/entity-templates endpoint with invalid property /// rules. /// This test verifies that rules incompatible with property type are rejected. + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -434,6 +459,7 @@ void postTemplate_400_string_property_with_numeric_rules() throws Exception { /// This test verifies that: /// - Templates can be created without any properties /// - The endpoint returns HTTP 201 Created status + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -451,6 +477,7 @@ void postTemplate_201_without_properties() throws Exception { /// This test verifies that: /// - Templates can be created with an empty properties array /// - The endpoint returns HTTP 201 Created status + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -715,6 +742,7 @@ void putTemplate_200() throws Exception { /// This test verifies that: /// - Templates can be updated without any properties /// - The endpoint returns HTTP 200 OK status + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -735,6 +763,7 @@ void putTemplate_200_without_properties() throws Exception { /// This test verifies that: /// - Templates can be updated with an empty properties array /// - The endpoint returns HTTP 200 OK status + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -842,6 +871,7 @@ void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { /// missing. /// This test verifies that: /// - Validation error message matches expected template name mandatory + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -862,6 +892,7 @@ void putTemplate_400_name_missing() throws Exception { /// blank. /// This test verifies that: /// - Validation error message contains expected template name mandatory message + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -881,6 +912,7 @@ void putTemplate_400_name_blank() throws Exception { /// already exists. /// This test verifies that: /// - Validation error message contains expected template name already exists + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -900,6 +932,7 @@ void putTemplate_409_name_already_exists() throws Exception { /// too long. /// This test verifies that: /// - Validation error message matches expected template name too long + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -920,6 +953,7 @@ void putTemplate_400_name_too_long() throws Exception { /// not respect regex pattern. /// This test verifies that: /// - Validation error message matches expected template name pattern + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -939,6 +973,7 @@ void putTemplate_400_name_invalid_pattern() throws Exception { /// requests with an identifier field in the request body. /// **This test verifies that:** /// - The endpoint returns HTTP 400 Bad Request when identifier is in body + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -1009,6 +1044,7 @@ class DeleteTemplateTests { /// Tests the DELETE /api/v1/entity-templates/{id} endpoint for successful /// template deletion. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -1026,6 +1062,7 @@ void deleteTemplate_204() throws Exception { /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when template does /// not exist. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -1048,6 +1085,7 @@ void deleteTemplate_404_not_found() throws Exception { /// is still referenced as a relation target in another template. /// This test verifies that: /// - Returns HTTP 400 Bad Request with appropriate error message + /// /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @@ -1070,6 +1108,7 @@ void deleteTemplate_400_is_relation_target() throws Exception { /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication /// is missing. /// This test verifies that: + /// /// @throws Exception if the MockMvc request fails @Test @DisplayName("Should return 401 when deleting without user token") diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java index d2e62838..504ebce5 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java @@ -35,8 +35,8 @@ /// for all CRUD operations on inbound webhook connectors. @DisplayName("InboundWebhookManagementController Integration Tests") @TestClassOrder(ClassOrderer.OrderAnnotation.class) -@Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", - "/db/test/R__4_insert_webhook_test_data.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Sql(scripts = {"/db/test/R__1_Insert_test_data.sql", "/db/test/R__4_insert_webhook_test_data.sql", + "/db/test/R__6_insert_principal_user_for_test.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Slf4j class InboundWebhookManagementControllerTest extends AbstractIntegrationTest { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index f29c7213..577729f0 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -1,6 +1,8 @@ package com.decathlon.idp_core.infrastructure.adapters.api.handler; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -26,6 +28,7 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.exception.entity_dynamic_mapping.EntityDynamicMappingConfigurationException; @@ -59,6 +62,18 @@ void setUp() throws Exception { @DisplayName("Domain Exception Handling") class DomainExceptionTests { + @Test + void shouldHandlePrincipalNotAuthorizedExceptionWith403Status() { + var exception = new PrincipalNotAuthorizedException("platform-user"); + + ResponseEntity response = exceptionHandler + .handlePrincipalNotAuthorizedException(exception); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + assertEquals(HttpStatus.FORBIDDEN.name(), response.getBody().getError()); + assertEquals(exception.getMessage(), response.getBody().getErrorDescription()); + } + /// Tests the handling of [EntityTemplateNotFoundException] by the /// [ApiExceptionHandler]. /// @@ -161,6 +176,98 @@ void shouldHandleEntityValidationException() { assertEquals(exception.getMessage(), body.getErrorDescription()); } + /// Tests the handling of [ConstraintViolationException] with multiple + /// validation violations. + /// + /// **This test verifies that:** + /// - ConstraintViolationException with multiple violations is properly handled + /// - HTTP 400 Bad Request status is returned + /// - All violation messages are concatenated with comma separation + /// - Error response contains all validation error messages + @Test + @DisplayName("Should handle ConstraintViolationException with multiple violations") + void shouldHandleConstraintViolationExceptionMultipleViolations() { + // Given + ConstraintViolation violation1 = createMockConstraintViolation( + "Field1 must not be null"); + ConstraintViolation violation2 = createMockConstraintViolation( + "Field2 must not be blank"); + Set> violations = Set.of(violation1, violation2); + ConstraintViolationException exception = new ConstraintViolationException("Validation failed", + violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 must not be null")); + assertTrue(errorDescription.contains("Field2 must not be blank")); + assertTrue(errorDescription.contains(", ")); + } + + /// Tests the handling of [MethodArgumentNotValidException] with field + /// validation errors. + /// + /// **This test verifies that:** + /// - MethodArgumentNotValidException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Field error messages from binding result are extracted and concatenated + /// - All field validation errors are included in the response with comma + /// separation + /// + /// @throws Exception if reflection fails during test setup + @Test + @DisplayName("Should handle MethodArgumentNotValidException with field errors") + void shouldHandleMethodArgumentNotValidException() throws Exception { + // Given + Object target = new Object(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); + bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); + bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); + + // Create a proper MethodParameter mock with required methods + MethodParameter methodParameter = mock(MethodParameter.class); + when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException( + methodParameter, bindingResult); + + // When + ResponseEntity response = exceptionHandler + .handleMethodArgumentNotValidException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 is required")); + assertTrue(errorDescription.contains("Field2 must be valid")); + assertTrue(errorDescription.contains(", ")); + } + + // Helper method for mocking + public void testMethod() { + // Empty method for testing purposes + } + + @SuppressWarnings("unchecked") + private ConstraintViolation createMockConstraintViolation(String message) { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn(message); + return violation; + } + @Nested @DisplayName("Validation Exception Handling") class ValidationExceptionTests { @@ -382,98 +489,6 @@ private ConstraintViolation createMockConstraintViolation(String message return violation; } } - - /// Tests the handling of [ConstraintViolationException] with multiple - /// validation violations. - /// - /// **This test verifies that:** - /// - ConstraintViolationException with multiple violations is properly handled - /// - HTTP 400 Bad Request status is returned - /// - All violation messages are concatenated with comma separation - /// - Error response contains all validation error messages - @Test - @DisplayName("Should handle ConstraintViolationException with multiple violations") - void shouldHandleConstraintViolationExceptionMultipleViolations() { - // Given - ConstraintViolation violation1 = createMockConstraintViolation( - "Field1 must not be null"); - ConstraintViolation violation2 = createMockConstraintViolation( - "Field2 must not be blank"); - Set> violations = Set.of(violation1, violation2); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", - violations); - - // When - ResponseEntity response = exceptionHandler - .handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 must not be null")); - assertTrue(errorDescription.contains("Field2 must not be blank")); - assertTrue(errorDescription.contains(", ")); - } - - /// Tests the handling of [MethodArgumentNotValidException] with field - /// validation errors. - /// - /// **This test verifies that:** - /// - MethodArgumentNotValidException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Field error messages from binding result are extracted and concatenated - /// - All field validation errors are included in the response with comma - /// separation - /// - /// @throws Exception if reflection fails during test setup - @Test - @DisplayName("Should handle MethodArgumentNotValidException with field errors") - void shouldHandleMethodArgumentNotValidException() throws Exception { - // Given - Object target = new Object(); - BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); - bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); - bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); - - // Create a proper MethodParameter mock with required methods - MethodParameter methodParameter = mock(MethodParameter.class); - when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); - - MethodArgumentNotValidException exception = new MethodArgumentNotValidException( - methodParameter, bindingResult); - - // When - ResponseEntity response = exceptionHandler - .handleMethodArgumentNotValidException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 is required")); - assertTrue(errorDescription.contains("Field2 must be valid")); - assertTrue(errorDescription.contains(", ")); - } - - // Helper method for mocking - public void testMethod() { - // Empty method for testing purposes - } - - @SuppressWarnings("unchecked") - private ConstraintViolation createMockConstraintViolation(String message) { - ConstraintViolation violation = mock(ConstraintViolation.class); - when(violation.getMessage()).thenReturn(message); - return violation; - } } @Nested diff --git a/src/test/resources/db/test/R__6_insert_principal_user_for_test.sql b/src/test/resources/db/test/R__6_insert_principal_user_for_test.sql new file mode 100644 index 00000000..fb149dfa --- /dev/null +++ b/src/test/resources/db/test/R__6_insert_principal_user_for_test.sql @@ -0,0 +1,52 @@ +-- Test data for Principal Users +-- Purpose: provide pre-configured principal users for integration testing + +-- Clear existing data (for repeatable migrations) +DELETE +FROM idp_core.entity_properties +WHERE entity_id IN ('90000000-0000-0000-0000-000000000001', + '90000000-0000-0000-0000-000000000002', + '90000000-0000-0000-0000-000000000003'); +DELETE +FROM idp_core.property +WHERE id IN ('90000000-0000-0000-0000-000000001001', + '90000000-0000-0000-0000-000000001002', + '90000000-0000-0000-0000-000000001003', + '90000000-0000-0000-0000-000000001004', + '90000000-0000-0000-0000-000000001005', + '90000000-0000-0000-0000-000000001006', + '90000000-0000-0000-0000-000000001007', + '90000000-0000-0000-0000-000000001008'); +DELETE +FROM idp_core.entity +WHERE id IN ('90000000-0000-0000-0000-000000000001', + '90000000-0000-0000-0000-000000000002', + '90000000-0000-0000-0000-000000000003', + '90000000-0000-0000-0000-000000000004'); + +INSERT INTO idp_core.entity (id, identifier, name, template_identifier) +VALUES ('90000000-0000-0000-0000-000000000001', 'test-admin', 'Test Admin', 'principal'), + ('90000000-0000-0000-0000-000000000002', 'latest-tester', 'Latest user Admin', 'principal'), + ('90000000-0000-0000-0000-000000000003', 'webhook-service', 'Webhook Service', 'principal'), + ('90000000-0000-0000-0000-000000000004', 'user', 'Default Mock User', 'principal'); + +INSERT INTO idp_core.property (id, name, value) +VALUES ('90000000-0000-0000-0000-000000001001', 'kind', 'HUMAN'), + ('90000000-0000-0000-0000-000000001002', 'is_admin', 'true'), + ('90000000-0000-0000-0000-000000001003', 'kind', 'HUMAN'), + ('90000000-0000-0000-0000-000000001004', 'is_admin', 'true'), + ('90000000-0000-0000-0000-000000001005', 'kind', 'SERVICE_ACCOUNT'), + ('90000000-0000-0000-0000-000000001006', 'is_admin', 'false'), + ('90000000-0000-0000-0000-000000001007', 'kind', 'HUMAN'), + ('90000000-0000-0000-0000-000000001008', 'is_admin', 'true'); + + +INSERT INTO idp_core.entity_properties (entity_id, property_id) +VALUES ('90000000-0000-0000-0000-000000000001', '90000000-0000-0000-0000-000000001001'), + ('90000000-0000-0000-0000-000000000001', '90000000-0000-0000-0000-000000001002'), + ('90000000-0000-0000-0000-000000000002', '90000000-0000-0000-0000-000000001003'), + ('90000000-0000-0000-0000-000000000002', '90000000-0000-0000-0000-000000001004'), + ('90000000-0000-0000-0000-000000000003', '90000000-0000-0000-0000-000000001005'), + ('90000000-0000-0000-0000-000000000003', '90000000-0000-0000-0000-000000001006'), + ('90000000-0000-0000-0000-000000000004', '90000000-0000-0000-0000-000000001007'), + ('90000000-0000-0000-0000-000000000004', '90000000-0000-0000-0000-000000001008'); From 0ca53eb1f581290c5a440f9932709b1ba49d4a4f Mon Sep 17 00:00:00 2001 From: rvando12 Date: Tue, 29 Sep 2026 15:23:31 +0200 Subject: [PATCH 02/11] feat(core): Global RBAC - itegrate actions and rules --- docs/src/concepts/authentication.md | 101 +++++++++++------- .../authorization/AuthorizationAction.java | 6 ++ .../authorization/AuthorizationRequest.java | 5 +- .../authorization/AuthorizationResource.java | 20 ++++ .../GlobalAuthorizationService.java | 15 ++- .../api/auth/AuthorizationRequestFactory.java | 98 +++++++++++++++++ .../api/auth/GlobalAuthorizationFilter.java | 29 +++-- .../api/auth/JitProvisioningFilter.java | 10 +- .../api/auth/ProvisionedPrincipalContext.java | 13 +++ .../api/auth/UnifiedUserProvider.java | 39 ++----- .../AuthenticationProperties.java | 30 +++--- .../configuration/SecurityConfiguration.java | 14 +-- .../configuration/SecurityRoleProperties.java | 20 ---- .../chains/OAuth2LoginFilterChainConfig.java | 9 +- .../JwtPrincipalExtractionStrategy.java | 19 +++- ...OAuth2UserPrincipalExtractionStrategy.java | 26 +++-- src/main/resources/application-local.yml | 2 +- src/main/resources/application.yml | 11 +- .../idp_core/SecurityConfigurationTest.java | 23 ++-- .../GlobalAuthorizationServiceTest.java | 93 +++++++++++++--- .../principal/PrincipalExtractorTest.java | 84 +++++++++++---- .../PrincipalProvisioningServiceTest.java | 1 + .../auth/AuthorizationRequestFactoryTest.java | 69 ++++++++++++ .../auth/GlobalAuthorizationFilterTest.java | 55 +++++++++- .../api/auth/JitProvisioningFilterTest.java | 7 ++ .../api/auth/UnifiedUserProviderTest.java | 97 ++++------------- .../AuthenticationPropertiesTest.java | 37 +++++++ .../SecurityRolePropertiesTest.java | 34 ------ .../OAuth2LoginFilterChainConfigTest.java | 23 ++++ .../chains/PublicFilterChainConfigTest.java | 4 +- ...nboundWebhookManagementControllerTest.java | 11 ++ .../controller/PrincipalControllerTest.java | 18 ++-- src/test/resources/application-test.yml | 2 - 33 files changed, 690 insertions(+), 335 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationAction.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationResource.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/ProvisionedPrincipalContext.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityRoleProperties.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationPropertiesTest.java delete mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityRolePropertiesTest.java diff --git a/docs/src/concepts/authentication.md b/docs/src/concepts/authentication.md index 00c37fb9..8c971ae0 100644 --- a/docs/src/concepts/authentication.md +++ b/docs/src/concepts/authentication.md @@ -17,16 +17,16 @@ graph LR B --> C["Authentication Filter
JWT/API Key/Mock"] C --> D["PrincipalExtractor
Strategy Pattern"] D --> E["JitProvisioningFilter
Auto-Provisioning"] - E --> F["GlobalAuthorizationFilter
Global Policy"] - F --> G["Domain Service
Authorization"] - G --> H["Controller
Business Logic"] + E --> F["GlobalAuthorizationFilter
Global Policy"] + F --> G["Domain Service
Authorization"] + G --> H["Controller
Business Logic"] ``` - **Security Filter Chain**: Routes requests through configured authentication mechanisms in order - **Authentication Filters**: Extract credentials (JWT tokens, API keys, etc.) and create `Authentication` objects - **Principal Extractor**: Converts authentication tokens into domain `PrincipalInfo` objects - **JIT Provisioning Filter**: Automatically creates principals in the database on first authentication -- **Global Authorization Filter**: Enforces the global authorization policy in JWT and mock chains +- **Global Authorization Filter**: Enforces the global authorization policy in JWT, mock, and OAuth2 login chains - **Domain Services**: Enforce business rules and authorization checks ## Filter Chain Architecture @@ -90,15 +90,30 @@ The JWT chain provides OAuth2 resource server authentication: Different Identity Providers (IdPs) use different claim names in their JWT tokens for the same semantic meaning. The claim mapping system allows you to configure these differences without code changes. +### Principal Identifier Claim + +Set `app.security.authentication.principal-identifier-claim` to the claim whose non-empty value uniquely identifies a +human principal. The default is `sub`. The application uses this setting for JWT, OAuth2, and OIDC human principals. +Set `IDP_PRINCIPAL_IDENTIFIER_CLAIM` in the environment to override the default. The value does not need to use UUID +syntax, but it must be stable and unique for each person. + +```yaml +app: + security: + authentication: + principal-identifier-claim: ${IDP_PRINCIPAL_IDENTIFIER_CLAIM:sub} +``` + +Service accounts use their mapped `client_id`, then `azp`, and finally `sub`; this human identifier setting does not +change service-account identification. + ### Optional Claim Mapping Configuration Configure claim mappings in `application.yml` under `app.security.authentication.user-claim-mappings`: -When JWT authentication is enabled, only these two pieces of information are -mandatory: +When JWT authentication is enabled, these values are mandatory: - spring.security.oauth2.resourceserver.jwt.jwk-set-uri required for signature validation. -- A valid JWT signature and standard sub claim, sub is effectively required for JIT provisioning because it is the final - identifier fallback. +- A valid JWT signature and the configured principal identifier claim in human tokens. When `app.security.authentication.jwt.enabled` is `false`, the JWT decoder and JWT filter chain are not created, so `OAUTH_JWK_URI` is not required. Swagger @@ -111,7 +126,6 @@ app: security: authentication: user-claim-mappings: - sub: "sub" # Unique user identifier - Mandatory preferred_username: "preferred_username" # Human-readable username name: "name" # Display name email: "email" # Email address @@ -132,7 +146,6 @@ app: security: authentication: user-claim-mappings: - sub: "sub" preferred_username: "preferred_username" name: "name" email: "email" @@ -152,7 +165,6 @@ app: security: authentication: user-claim-mappings: - sub: "sub" preferred_username: "preferred_username" name: "name" email: "email" @@ -170,8 +182,8 @@ app: app: security: authentication: + principal-identifier-claim: "oid" user-claim-mappings: - sub: "oid" # Object ID in Azure preferred_username: "unique_name" name: "name" email: "email" @@ -196,19 +208,11 @@ the token: | Principal field | Mapping key | Fallback when the mapped claim is absent | |--------------------|----------------------|------------------------------------------| -| `identifier` | `preferred_username` | Original JWT `sub` | -| `name` | `name` | Extracted `identifier` | +| `identifier` | `principal-identifier-claim` | Request is rejected; the identifier is required | +| `name` | `name` | `preferred_username`, then `identifier` | | `attributes.email` | `email` | Attribute omitted | | `groups` | `groups` | Empty list | -> [!WARNING] -> The current JWT extraction strategy ignores the `sub` mapping and reads the -> original JWT subject directly. Setting `sub: uuid` alone does not select -> `uuid` as the principal identifier. Use `preferred_username: uuid` for human -> principals to select that claim with the current implementation. -> Service accounts use different rules: mapped `client_id`, then mapped `azp`, -> then the original JWT `sub`. - ### Example: UUID Identifier and Subject as Display Name The following decoded JWT payload contains only fictional values. @@ -229,8 +233,8 @@ configure: app: security: authentication: + principal-identifier-claim: uuid user-claim-mappings: - preferred_username: uuid name: sub email: email ``` @@ -245,7 +249,8 @@ For a token classified as human, the extracted `PrincipalInfo` contains: > [!WARNING] > Changing the identifier mapping can provision a separate catalog principal. -> Plan how to handle existing principals and their references before changing it. +> Choose a stable claim before onboarding. IDP-Core does not automatically rename, +> merge, or look up previously provisioned principals when this setting changes. ## Service Account Detection @@ -409,35 +414,51 @@ to one, add it to both. ## Global Authorization -The JWT and mock filter chains apply a global authorization policy after JIT provisioning. The API key chain does not -use this policy. In `GLOBAL` mode, the service evaluates requests in this order: +The JWT, mock, and OAuth2 login API chains apply the global authorization policy after JIT provisioning. The dedicated +webhook ingestion chain remains separate and continues to use the security configured on each webhook connector. + +In `GLOBAL` mode, a break-glass identifier or a principal entity with `is_admin: true` has full CRUD access. Other +principals follow this matrix: -| Order | Condition | Result | -|-------|----------------------------------------------------------------------|-----------------| -| 1 | The principal identifier matches a configured break-glass identifier | Allow | -| 2 | The principal catalog entity has `is_admin: true` | Allow | -| 3 | The principal is a `SERVICE_ACCOUNT` | Allow | -| 4 | The request uses `GET`, `HEAD`, or `OPTIONS` | Allow | -| 5 | None of the conditions above match | Return HTTP 403 | +| Principal | Access | +|----------------------------------------------------------|--------------------------------------------------------| +| Platform administrator (`is_admin: true` or break-glass) | CRUD on catalog resources and webhook configurations | +| `SERVICE_ACCOUNT` | CRUD, except creating an inbound webhook configuration | +| Standard human | Read-only | -The principal identifier uses `preferred_username` when available and falls back to the JWT `sub` claim. Configure one -or both break-glass identifiers as environment variables: +The policy classifies `GET`, `HEAD`, and `OPTIONS` as reads. It also treats `POST /api/v1/entities/search` and +`POST /api/v1/entity_dynamic_mappings/dry-run` as reads. Unsupported HTTP methods are denied. Only platform +administrators can create inbound webhook configurations at `POST /api/v1/inbound_webhooks`; service accounts retain +the remaining global CRUD behavior. This rule applies to configuration management, not to webhook delivery requests. + +Human principals use the value of `principal-identifier-claim`. Service accounts use `client_id`, falling back to `azp` +and then `sub`. Break-glass values must match the resulting principal identifiers exactly. Configure one or both root +identifiers through environment variables: ```yaml app: security: authorization: mode: GLOBAL - global-principal-identifiers: ${IDP_SUPER_ADMIN_UUID:},${IDP_PLATFORM_ADMIN_UUID:} + global-principal-identifiers: ${IDP_SUPER_ADMIN_IDENTIFIER:},${IDP_PLATFORM_ADMIN_IDENTIFIER:} ``` -The `RBAC` and `CATALOG` modes are reserved for future authorization policies. Requests fail closed when either +IDP-Core provisions each principal directly under the configured identifier. It does not retain username aliases or +automatically migrate historical principal identifiers. + +The `is_admin` property on the principal entity grants platform-wide CRUD access. Entity-template permission or +ownership settings are intentionally ignored in this phase. The former `baseline-role: "*"` assignment has been +removed; authorities from JWT scopes do not grant platform permissions. + +The `RBAC` and `ABAC` modes are reserved for future authorization policies. Requests fail closed when either unimplemented mode is configured. Use `GLOBAL` until those modes are implemented. ## API Key Authentication -For webhook receivers and service-to-service calls that don't use OAuth2, IDP-Core supports simple API key -authentication. +The `app.security.authentication.api-key` chain is currently only a conditional security-chain placeholder; it does +not validate an API-key credential or establish a service-account principal. Do not enable it as an authentication +mechanism until that authenticator is implemented. Inbound webhook delivery uses the separate connector-level security +validators described in [Webhook Security](webhooks.md#security-strategies). ### Enabling API Key Authentication @@ -518,8 +539,8 @@ app: enabled: true mock: enabled: false + principal-identifier-claim: id user-claim-mappings: - sub: id preferred_username: login name: name email: email diff --git a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationAction.java b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationAction.java new file mode 100644 index 00000000..0dd04367 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationAction.java @@ -0,0 +1,6 @@ +package com.decathlon.idp_core.domain.model.authorization; + +/// CRUD operation requested against a platform resource. +public enum AuthorizationAction { + CREATE, READ, UPDATE, DELETE, UNSUPPORTED +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java index a6906722..f9b327f8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java @@ -10,10 +10,13 @@ /// Domain input describing a principal's request without infrastructure types. public record AuthorizationRequest(@NotNull PrincipalInfo principal, - @NotNull Optional principalEntity, boolean readOperation) { + @NotNull Optional principalEntity, @NotNull AuthorizationAction action, + @NotNull AuthorizationResource resource) { public AuthorizationRequest { Objects.requireNonNull(principal, "principal must not be null"); Objects.requireNonNull(principalEntity, "principalEntity must not be null"); + Objects.requireNonNull(action, "action must not be null"); + Objects.requireNonNull(resource, "resource must not be null"); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationResource.java b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationResource.java new file mode 100644 index 00000000..41d57ffe --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationResource.java @@ -0,0 +1,20 @@ +package com.decathlon.idp_core.domain.model.authorization; + +import java.util.Objects; +import java.util.Optional; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/// Identifies the resource and optional parent context for an authorization request. +public record AuthorizationResource(@NotBlank String type, @NotNull Optional identifier, + @NotNull Optional parentIdentifier) { + + public static final String INBOUND_WEBHOOK_CONFIGURATION = "inbound_webhook_configuration"; + + public AuthorizationResource { + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(identifier, "identifier must not be null"); + Objects.requireNonNull(parentIdentifier, "parentIdentifier must not be null"); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java b/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java index 84941fba..bd2ad811 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java @@ -3,9 +3,11 @@ import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; import com.decathlon.idp_core.domain.model.authorization.AuthorizationMode; import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.principal.PrincipalKind; @@ -24,7 +26,8 @@ public class GlobalAuthorizationService { /// @throws PrincipalNotAuthorizedException when the request is not permitted public void authorize(AuthorizationRequest request, AuthorizationPolicy policy) { String identifier = request.principal().identifier(); - if (policy.mode() != AuthorizationMode.GLOBAL) { + if (policy.mode() != AuthorizationMode.GLOBAL + || request.action() == AuthorizationAction.UNSUPPORTED) { throw new PrincipalNotAuthorizedException(identifier); } @@ -36,17 +39,23 @@ public void authorize(AuthorizationRequest request, AuthorizationPolicy policy) return; } - if (request.principal().kind() == PrincipalKind.SERVICE_ACCOUNT) { + if (request.principal().kind() == PrincipalKind.SERVICE_ACCOUNT + && !isPlatformAdminOnlyCreation(request)) { return; } - if (request.readOperation()) { + if (request.action() == AuthorizationAction.READ) { return; } throw new PrincipalNotAuthorizedException(identifier); } + private boolean isPlatformAdminOnlyCreation(AuthorizationRequest request) { + return request.action() == AuthorizationAction.CREATE + && AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION.equals(request.resource().type()); + } + private boolean isCatalogAdministrator(Entity principalEntity) { return PRINCIPAL_TEMPLATE_IDENTIFIER.equals(principalEntity.templateIdentifier()) && principalEntity.properties().stream() diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java new file mode 100644 index 00000000..d2643f4a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java @@ -0,0 +1,98 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import java.util.Arrays; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; + +/// Maps servlet requests to the framework-independent authorization context. +@Component +public class AuthorizationRequestFactory { + + private static final String ENTITY_SEARCH_PATH = "/api/v1/entities/search"; + private static final String ENTITY_DYNAMIC_MAPPING_DRY_RUN_PATH = "/api/v1/entity_dynamic_mappings/dry-run"; + + /// Creates an authorization request from the principal and HTTP request. + public AuthorizationRequest create(HttpServletRequest request, PrincipalInfo principal, + Optional principalEntity) { + String path = normalizedPath(request); + return new AuthorizationRequest(principal, principalEntity, + resolveAction(request.getMethod(), path), resolveResource(path)); + } + + private AuthorizationAction resolveAction(String method, String path) { + if ("POST".equals(method) + && (ENTITY_SEARCH_PATH.equals(path) || ENTITY_DYNAMIC_MAPPING_DRY_RUN_PATH.equals(path))) { + return AuthorizationAction.READ; + } + + return switch (method) { + case "GET", "HEAD", "OPTIONS" -> AuthorizationAction.READ; + case "POST" -> AuthorizationAction.CREATE; + case "PUT", "PATCH" -> AuthorizationAction.UPDATE; + case "DELETE" -> AuthorizationAction.DELETE; + default -> AuthorizationAction.UNSUPPORTED; + }; + } + + private AuthorizationResource resolveResource(String path) { + if (ENTITY_SEARCH_PATH.equals(path)) { + return new AuthorizationResource("entity_search", Optional.empty(), Optional.empty()); + } + if (ENTITY_DYNAMIC_MAPPING_DRY_RUN_PATH.equals(path)) { + return new AuthorizationResource("entity_dynamic_mapping_dry_run", Optional.empty(), + Optional.empty()); + } + + String[] segments = Arrays.stream(path.split("/")).filter(segment -> !segment.isBlank()) + .toArray(String[]::new); + if (segments.length < 3 || !"api".equals(segments[0]) || !"v1".equals(segments[1])) { + return new AuthorizationResource("unknown", Optional.empty(), Optional.empty()); + } + + String resourceRoot = segments[2]; + if ("entities".equals(resourceRoot)) { + return entityResource(segments); + } + + if ("inbound_webhooks".equals(resourceRoot)) { + return new AuthorizationResource(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION, + identifierAt(segments, 3), Optional.empty()); + } + + String type = switch (resourceRoot) { + case "entity-templates" -> "entity_template"; + case "entity_dynamic_mappings" -> "entity_dynamic_mapping"; + case "audit" -> "audit"; + default -> "catalog_resource"; + }; + return new AuthorizationResource(type, identifierAt(segments, 3), Optional.empty()); + } + + private AuthorizationResource entityResource(String[] segments) { + if (segments.length >= 5) { + return new AuthorizationResource("entity", Optional.of(segments[4]), + Optional.of(segments[3])); + } + return new AuthorizationResource("entity", Optional.empty(), identifierAt(segments, 3)); + } + + private Optional identifierAt(String[] segments, int index) { + return segments.length > index ? Optional.of(segments[index]) : Optional.empty(); + } + + private String normalizedPath(HttpServletRequest request) { + String requestUri = request.getRequestURI(); + String contextPath = request.getContextPath(); + String path = requestUri.substring(contextPath.length()); + return path.endsWith("/") && path.length() > 1 ? path.substring(0, path.length() - 1) : path; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java index 7d37aaeb..dcd7fb89 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java @@ -1,7 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.api.auth; import java.io.IOException; -import java.util.Set; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -18,24 +17,22 @@ import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; -import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.service.authorization.GlobalAuthorizationService; import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; import lombok.extern.slf4j.Slf4j; -/// Enforces the global authorization policy for authenticated JWT and mock requests. +/// Enforces the global authorization policy for authenticated API requests. @Slf4j @Component public class GlobalAuthorizationFilter extends OncePerRequestFilter { - private static final Set READ_METHODS = Set.of("GET", "HEAD", "OPTIONS"); - private final PrincipalExtractor principalExtractor; private final PrincipalProvisioningService provisioningService; private final GlobalAuthorizationService authorizationService; private final AuthorizationPolicy authorizationPolicy; + private final AuthorizationRequestFactory authorizationRequestFactory; /// Creates a filter backed by the domain authorization service and configured /// policy. @@ -45,13 +42,17 @@ public class GlobalAuthorizationFilter extends OncePerRequestFilter { /// @param provisioningService retrieves the principal's catalog entity /// @param authorizationService evaluates the authorization policy /// @param authorizationPolicy configured policy for authenticated requests + /// @param authorizationRequestFactory maps HTTP input to domain authorization + /// context public GlobalAuthorizationFilter(PrincipalExtractor principalExtractor, PrincipalProvisioningService provisioningService, - GlobalAuthorizationService authorizationService, AuthorizationPolicy authorizationPolicy) { + GlobalAuthorizationService authorizationService, AuthorizationPolicy authorizationPolicy, + AuthorizationRequestFactory authorizationRequestFactory) { this.principalExtractor = principalExtractor; this.provisioningService = provisioningService; this.authorizationService = authorizationService; this.authorizationPolicy = authorizationPolicy; + this.authorizationRequestFactory = authorizationRequestFactory; } @Override @@ -65,10 +66,18 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, return; } - PrincipalInfo principal = principalExtractor.extractPrincipalInfo(authentication); - var principalEntity = provisioningService.getPrincipal(principal.identifier()); - AuthorizationRequest authorizationRequest = new AuthorizationRequest(principal, principalEntity, - READ_METHODS.contains(request.getMethod())); + var context = request.getAttribute(ProvisionedPrincipalContext.REQUEST_ATTRIBUTE); + var principalContext = context instanceof ProvisionedPrincipalContext provisionedContext + ? provisionedContext + : null; + var principal = principalContext != null + ? principalContext.principal() + : principalExtractor.extractPrincipalInfo(authentication); + var principalEntity = principalContext != null + ? principalContext.principalEntity() + : provisioningService.getPrincipal(principal.identifier()); + AuthorizationRequest authorizationRequest = authorizationRequestFactory.create(request, + principal, principalEntity); try { authorizationService.authorize(authorizationRequest, authorizationPolicy); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java index 5a8a48f4..2bf10989 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.api.auth; import java.io.IOException; +import java.util.Optional; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -15,6 +16,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.pattern.PathPatternParser; +import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; @@ -63,16 +65,18 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, if (authentication != null && authentication.isAuthenticated() && !isAnonymous(authentication)) { - provisionPrincipalSafely(authentication); + provisionPrincipalSafely(request, authentication); } filterChain.doFilter(request, response); } - private void provisionPrincipalSafely(Authentication authentication) { + private void provisionPrincipalSafely(HttpServletRequest request, Authentication authentication) { try { PrincipalInfo principalInfo = principalExtractor.extractPrincipalInfo(authentication); - provisioningService.provisionPrincipal(principalInfo); + Entity principalEntity = provisioningService.provisionPrincipal(principalInfo); + request.setAttribute(ProvisionedPrincipalContext.REQUEST_ATTRIBUTE, + new ProvisionedPrincipalContext(principalInfo, Optional.of(principalEntity))); log.debug("JIT provisioning successful for principal: {} (kind: {})", principalInfo.identifier(), principalInfo.kind()); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/ProvisionedPrincipalContext.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/ProvisionedPrincipalContext.java new file mode 100644 index 00000000..526b92f8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/ProvisionedPrincipalContext.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import java.util.Optional; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; + +/// Request-scoped result shared by JIT provisioning and global authorization. +public record ProvisionedPrincipalContext(PrincipalInfo principal, + Optional principalEntity) { + + public static final String REQUEST_ATTRIBUTE = ProvisionedPrincipalContext.class.getName(); +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java index 875b63b0..79ea3477 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java @@ -1,25 +1,24 @@ package com.decathlon.idp_core.infrastructure.adapters.api.auth; -import java.util.Optional; - import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Component; +import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; + +import lombok.RequiredArgsConstructor; + /// UnifiedUserProvider is a Spring component that implements the UserIdentityProvider interface to provide a consistent way /// to retrieve the authenticated user's identity across different authentication mechanisms (JWT, OAuth2, OpenID). -/// It checks the current security context for the authentication type and extracts the user ID accordingly: -/// - For JWT authentication, it retrieves the subject (sub) claim from the JWT token. -/// - For OAuth2 authentication, it first checks if the user is an OIDC user to -/// retrieve the subject, otherwise it looks for a "sub" or "id" attribute in the OAuth2 user attributes, falling back to the authentication name if neither is found. -/// - For basic authentication, it simply returns the authentication name. +/// The shared principal extractor keeps audit identities aligned with the identifiers used for provisioning +/// and authorization. @Component +@RequiredArgsConstructor public class UnifiedUserProvider implements UserIdentityProvider { + private final PrincipalExtractor principalExtractor; + @Override public String getAuthId() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -29,25 +28,7 @@ public String getAuthId() { return "UNKNOWN"; } - // Jwt Case - if (authentication instanceof JwtAuthenticationToken jwtToken) { - return jwtToken.getToken().getSubject(); - } - - // OAuth2 and OpenId case - if (authentication.getPrincipal()instanceof OAuth2User oauth2Token) { - - if (oauth2Token instanceof OidcUser oidcUser) { - return oidcUser.getSubject(); - } - - return Optional.ofNullable(oauth2Token.getAttribute("sub")).map(Object::toString) - .orElseGet(() -> Optional.ofNullable(oauth2Token.getAttribute("id")).map(Object::toString) - .orElse(authentication.getName())); - } - - // Basic Auth case - return authentication.getName(); + return principalExtractor.extractPrincipalInfo(authentication).identifier(); } @Override diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationProperties.java index bdd62f7b..31060c89 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationProperties.java @@ -21,22 +21,13 @@ *
  • Clear documentation of IdP-specific token claim expectations
  • * * - *

    Design rationale

    Follows the same pattern as {@code CorsProperties} - * and {@code SecurityRoleProperties}. Configuration is externalized to + *

    Design rationale

    Configuration is externalized to * {@code application.yml} for easy environment customization. * - *

    Claim Mapping Strategy

    Different IdPs use different JWT claim names - * for the same semantic meaning. This configuration allows mapping IdP-specific - * claim names to standardized extraction logic. + *

    Principal Identifier

    The configured claim supplies the stable + * identifier for human principals. Service accounts continue to use their + * client identifier. *

    - * Examples: - *

      - *
    • Auth0: {@code preferred_username} (username) vs. {@code sub} (unique - * ID)
    • - *
    • Azure AD: {@code unique_name} vs. {@code oid}
    • - *
    • Keycloak: {@code preferred_username} vs. {@code sub}
    • - *
    - * *

    Service Account Detection

    Service accounts (M2M tokens) can be * identified in multiple ways depending on the IdP: *
      @@ -51,7 +42,7 @@ */ @ConfigurationProperties(prefix = "app.security.authentication") public record AuthenticationProperties( - // Maps standard claim names to the names used by the configured identity + // Maps optional standard claims to the names used by the configured identity // provider. Map userClaimMappings, @@ -59,7 +50,16 @@ public record AuthenticationProperties( ServiceAccountDetection serviceAccountDetection, // Public paths excluded from JIT provisioning. - List jitProvisioningExcludedPaths) { + List jitProvisioningExcludedPaths, + + // Claim containing the stable identifier for a human principal; defaults to + // "sub". + String principalIdentifierClaim) { + + public AuthenticationProperties { + principalIdentifierClaim = principalIdentifierClaim == null + || principalIdentifierClaim.isBlank() ? "sub" : principalIdentifierClaim.trim(); + } // Configuration used to identify service accounts. public record ServiceAccountDetection( diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index 6dffd82d..c79974a1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -6,7 +6,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; @@ -45,25 +44,18 @@ @Configuration @EnableWebSecurity @Slf4j -@EnableConfigurationProperties({CorsProperties.class, SecurityRoleProperties.class, - AuthenticationProperties.class}) +@EnableConfigurationProperties({CorsProperties.class, AuthenticationProperties.class}) public class SecurityConfiguration { private final CorsProperties corsProperties; - private final SecurityRoleProperties securityRoleProperties; - public SecurityConfiguration(CorsProperties corsProperties, - SecurityRoleProperties securityRoleProperties) { + public SecurityConfiguration(CorsProperties corsProperties) { this.corsProperties = corsProperties; - this.securityRoleProperties = securityRoleProperties; } @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { - JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); - converter.setJwtGrantedAuthoritiesConverter( - jwt -> List.of(new SimpleGrantedAuthority(securityRoleProperties.baselineRole()))); - return converter; + return new JwtAuthenticationConverter(); } @Bean diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityRoleProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityRoleProperties.java deleted file mode 100644 index 46d3b50a..00000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityRoleProperties.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.configuration; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -/// Configuration properties for security role assignment. -/// -/// **Business purpose:** Externalizes baseline role configuration to enable -/// easy transition from V1 (global admin for all) to V2 (granular RBAC). -/// -/// **Usage:** Bind via `@EnableConfigurationProperties` in SecurityConfiguration. -@ConfigurationProperties(prefix = "app.security.roles") -public record SecurityRoleProperties(String baselineRole) { - - /// Default baseline role is `*` (Super Admin) for V1 phase. - public SecurityRoleProperties { - if (baselineRole == null || baselineRole.isBlank()) { - baselineRole = "*"; - } - } -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfig.java index 22ca3c11..3769ab78 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfig.java @@ -13,6 +13,7 @@ import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; /// Security filter chain for browser-based OAuth2 login. @@ -26,9 +27,12 @@ public class OAuth2LoginFilterChainConfig { private final JitProvisioningFilter jitProvisioningFilter; + private final GlobalAuthorizationFilter globalAuthorizationFilter; - public OAuth2LoginFilterChainConfig(JitProvisioningFilter jitProvisioningFilter) { + public OAuth2LoginFilterChainConfig(JitProvisioningFilter jitProvisioningFilter, + GlobalAuthorizationFilter globalAuthorizationFilter) { this.jitProvisioningFilter = jitProvisioningFilter; + this.globalAuthorizationFilter = globalAuthorizationFilter; } @Bean @@ -40,7 +44,8 @@ public SecurityFilterChain oauth2LoginSecurityFilterChain(HttpSecurity http) { .authorizeHttpRequests(auth -> auth.requestMatchers("/oauth2/**", "/login/**").permitAll() .requestMatchers("/api/v1/**").fullyAuthenticated().anyRequest().authenticated()) .oauth2Login(withDefaults()) - .addFilterAfter(jitProvisioningFilter, OAuth2LoginAuthenticationFilter.class); + .addFilterAfter(jitProvisioningFilter, OAuth2LoginAuthenticationFilter.class) + .addFilterAfter(globalAuthorizationFilter, JitProvisioningFilter.class); return http.build(); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/JwtPrincipalExtractionStrategy.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/JwtPrincipalExtractionStrategy.java index b87af68b..fafda5fb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/JwtPrincipalExtractionStrategy.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/JwtPrincipalExtractionStrategy.java @@ -10,6 +10,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.ORIGIN; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.PREFERRED_USERNAME; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.SERVICE_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.SUB; import static org.springframework.security.oauth2.core.oidc.IdTokenClaimNames.AZP; import java.util.HashMap; @@ -169,15 +170,25 @@ private boolean detectServiceAccountLegacy(Map claims, String su private PrincipalInfo extractHumanFromJwt(String sub, Map claims) { Map claimMappings = authProperties.userClaimMappings(); - // Try to extract preferred username, fallback to sub + String identifierClaim = authProperties.principalIdentifierClaim(); + Object identifierValue = claims.get(identifierClaim); + if (identifierValue == null && SUB.equals(identifierClaim)) { + identifierValue = sub; + } + String identifier = Optional.ofNullable(identifierValue).map(Object::toString) + .filter(value -> !value.isBlank()).orElseThrow(() -> new IllegalArgumentException( + "JWT principal identifier claim '" + identifierClaim + "' is required")); + String preferredUsernameClaim = claimMappings.get(PREFERRED_USERNAME); - String identifier = Optional.ofNullable(preferredUsernameClaim) - .flatMap(c -> Optional.ofNullable(claims.get(c))).map(Object::toString).orElse(sub); + Optional preferredUsername = Optional.ofNullable(preferredUsernameClaim) + .flatMap(claim -> Optional.ofNullable(claims.get(claim))).map(Object::toString) + .filter(value -> !value.isBlank()); // Try to extract name, fallback to identifier String nameClaim = claimMappings.get(NAME); String name = Optional.ofNullable(nameClaim).flatMap(c -> Optional.ofNullable(claims.get(c))) - .map(Object::toString).orElse(identifier); + .map(Object::toString).filter(value -> !value.isBlank()).or(() -> preferredUsername) + .orElse(identifier); Map attributes = new HashMap<>(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/OAuth2UserPrincipalExtractionStrategy.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/OAuth2UserPrincipalExtractionStrategy.java index 1e11cf1f..44989273 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/OAuth2UserPrincipalExtractionStrategy.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/OAuth2UserPrincipalExtractionStrategy.java @@ -4,7 +4,6 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.GROUPS; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.NAME; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.PREFERRED_USERNAME; -import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.SUB; import java.util.HashMap; import java.util.List; @@ -63,18 +62,21 @@ private PrincipalInfo extractFromOAuth2User(OAuth2User oauth2User) { Map claimMappings = authProperties.userClaimMappings(); // Standard OAuth2 user extraction using dynamic claims - String subClaim = claimMappings.getOrDefault(SUB, SUB); - String sub = Optional.ofNullable(oauth2User.getAttribute(subClaim)).map(String::valueOf) - .orElse(oauth2User.getName()); + String identifierClaim = authProperties.principalIdentifierClaim(); + String identifier = Optional.ofNullable(oauth2User.getAttribute(identifierClaim)) + .map(String::valueOf).filter(value -> !value.isBlank()) + .orElseThrow(() -> new IllegalArgumentException( + "OAuth2 principal identifier claim '" + identifierClaim + "' is required")); String preferredUsernameClaim = claimMappings.getOrDefault(PREFERRED_USERNAME, PREFERRED_USERNAME); - String identifier = Optional.ofNullable(oauth2User.getAttribute(preferredUsernameClaim)) - .map(String::valueOf).filter(value -> !value.isBlank()).orElse(sub); + Optional preferredUsername = Optional + .ofNullable(oauth2User.getAttribute(preferredUsernameClaim)).map(String::valueOf) + .filter(value -> !value.isBlank()); String nameClaim = claimMappings.getOrDefault(NAME, NAME); String name = Optional.ofNullable(oauth2User.getAttribute(nameClaim)).map(String::valueOf) - .filter(value -> !value.isBlank()).orElse(identifier); + .filter(value -> !value.isBlank()).or(() -> preferredUsername).orElse(identifier); Map attributes = new HashMap<>(); String emailClaim = claimMappings.getOrDefault(EMAIL, EMAIL); @@ -94,11 +96,15 @@ private PrincipalInfo extractFromOAuth2User(OAuth2User oauth2User) { /// is accurately extracted from OIDC claims, which are standardized across /// compliant identity providers. private PrincipalInfo extractFromOidcUser(OidcUser oidcUser) { - String sub = oidcUser.getSubject(); - String identifier = Optional.ofNullable(oidcUser.getPreferredUsername()).orElse(sub); + String identifierClaim = authProperties.principalIdentifierClaim(); + String identifier = Optional.ofNullable(oidcUser.getAttribute(identifierClaim)) + .map(String::valueOf).filter(value -> !value.isBlank()) + .orElseThrow(() -> new IllegalArgumentException( + "OIDC principal identifier claim '" + identifierClaim + "' is required")); String name = Optional.ofNullable(oidcUser.getFullName()) - .or(() -> Optional.ofNullable(oidcUser.getGivenName())).orElse(identifier); + .or(() -> Optional.ofNullable(oidcUser.getGivenName())) + .or(() -> Optional.ofNullable(oidcUser.getPreferredUsername())).orElse(identifier); Map attributes = new HashMap<>(); Optional.ofNullable(oidcUser.getEmail()).map(String::valueOf) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 8c5a8417..3c395861 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -48,8 +48,8 @@ app: enabled: true mock: enabled: false + principal-identifier-claim: id user-claim-mappings: - sub: id preferred_username: login name: name email: email diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e37e3fd5..284d5949 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -120,15 +120,15 @@ app: security: authorization: mode: GLOBAL - # Comma-separated break-glass identifiers; each value may be supplied independently. - global-principal-identifiers: ${IDP_SUPER_ADMIN_UUID:},${IDP_PLATFORM_ADMIN_UUID:} - roles: - # Baseline role assigned to all authenticated principals during V1 phase. - baseline-role: ${APP_SECURITY_ROLES_BASELINE_ROLE:*} + # Comma-separated principal identifiers for emergency platform access. + global-principal-identifiers: ${IDP_SUPER_ADMIN_IDENTIFIER:},${IDP_PLATFORM_ADMIN_IDENTIFIER:} # Authentication configuration - externalized for multi-IdP support # See docs/src/concepts/authentication.md for comprehensive documentation authentication: + # Human identity claim used as the catalog principal identifier. + principal-identifier-claim: ${IDP_PRINCIPAL_IDENTIFIER_CLAIM:sub} + jwt: enabled: true api-key: @@ -139,7 +139,6 @@ app: # JWT claim mappings for different Identity Providers (Auth0, Keycloak, Azure AD, etc.) # See docs/src/concepts/authentication.md - JWT Claim Mappings section user-claim-mappings: - sub: sub preferred_username: preferred_username name: name email: email diff --git a/src/test/java/com/decathlon/idp_core/SecurityConfigurationTest.java b/src/test/java/com/decathlon/idp_core/SecurityConfigurationTest.java index 6bfa2033..f0b1c027 100644 --- a/src/test/java/com/decathlon/idp_core/SecurityConfigurationTest.java +++ b/src/test/java/com/decathlon/idp_core/SecurityConfigurationTest.java @@ -1,6 +1,7 @@ package com.decathlon.idp_core; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -14,7 +15,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.web.cors.CorsConfiguration; @@ -22,7 +22,6 @@ import com.decathlon.idp_core.infrastructure.adapters.api.configuration.CorsProperties; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SecurityConfiguration; -import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SecurityRoleProperties; @ExtendWith(MockitoExtension.class) class SecurityConfigurationTest { @@ -30,30 +29,22 @@ class SecurityConfigurationTest { @Mock private CorsProperties corsProperties; - @Mock - private SecurityRoleProperties securityRoleProperties; - @InjectMocks private SecurityConfiguration securityConfiguration; @Test - void jwtAuthenticationConverter_shouldAssignBaselineRole() { - // Arrange - when(securityRoleProperties.baselineRole()).thenReturn("ROLE_SUPER_ADMIN"); - + void jwtAuthenticationConverter_shouldNotAssignUniversalBaselineAuthority() { Jwt jwt = Jwt.withTokenValue("mock-jwt-token").header("alg", "none").claim("sub", "user-id-123") - .build(); + .claim("scope", "read write").build(); - // Act JwtAuthenticationConverter converter = securityConfiguration.jwtAuthenticationConverter(); var authenticationToken = converter.convert(jwt); - // Assert assertNotNull(authenticationToken, "Authentication token should not be null"); - assertTrue( - authenticationToken.getAuthorities() - .contains(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")), - "The configured baseline role must be assigned to the principal"); + assertTrue(authenticationToken.getAuthorities().stream() + .anyMatch(authority -> authority.getAuthority().equals("SCOPE_read"))); + assertFalse(authenticationToken.getAuthorities().stream() + .anyMatch(authority -> authority.getAuthority().equals("*"))); } @Test diff --git a/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java index 4bce84ae..2d957680 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java @@ -12,9 +12,11 @@ import org.junit.jupiter.api.Test; import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; import com.decathlon.idp_core.domain.model.authorization.AuthorizationMode; import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; @@ -23,6 +25,11 @@ class GlobalAuthorizationServiceTest { private static final String PRINCIPAL_IDENTIFIER = "platform-user"; + private static final AuthorizationResource CATALOG = new AuthorizationResource("entity", + Optional.of("api-one"), Optional.of("api")); + private static final AuthorizationResource WEBHOOK_CONFIGURATION = new AuthorizationResource( + AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION, Optional.of("connector"), + Optional.empty()); private final GlobalAuthorizationService authorizationService = new GlobalAuthorizationService(); private AuthorizationPolicy policy; @@ -36,26 +43,54 @@ void setUp() { @Test void shouldAllowBreakGlassPrincipalBeforeCheckingCatalogAdmin() { - var request = new AuthorizationRequest(principal("break-glass-user", PrincipalKind.HUMAN), - Optional.empty(), false); + var request = request(principal("break-glass-user", PrincipalKind.HUMAN), + AuthorizationAction.DELETE, CATALOG, Optional.empty()); assertThatCode(() -> authorizationService.authorize(request, policy)) .doesNotThrowAnyException(); } @Test - void shouldAllowPrincipalWithIsAdminSetToTrue() { - var request = new AuthorizationRequest(humanPrincipal, Optional.of(principalEntity("true")), - false); + void shouldAllowPrincipalWithIsAdminSetToTrueForAllCrud() { + for (AuthorizationAction action : crudActions()) { + var request = request(humanPrincipal, action, WEBHOOK_CONFIGURATION, + Optional.of(principalEntity("true"))); + + assertThatCode(() -> authorizationService.authorize(request, policy)) + .doesNotThrowAnyException(); + } + } + + @Test + void shouldAllowPlatformAdministratorToDeletePrincipalEntities() { + AuthorizationResource principalResource = new AuthorizationResource("entity", + Optional.of("subject-123"), Optional.of("principal")); + var request = request(humanPrincipal, AuthorizationAction.DELETE, principalResource, + Optional.of(principalEntity("true"))); assertThatCode(() -> authorizationService.authorize(request, policy)) .doesNotThrowAnyException(); } @Test - void shouldAllowServiceAccountForWriteOperations() { - var request = new AuthorizationRequest( - principal(PRINCIPAL_IDENTIFIER, PrincipalKind.SERVICE_ACCOUNT), Optional.empty(), false); + void shouldAllowServiceAccountCrudOutsideWebhookConfigurationCreation() { + for (AuthorizationAction action : crudActions()) { + if (action == AuthorizationAction.CREATE) { + var webhookRequest = request(serviceAccount(), action, WEBHOOK_CONFIGURATION, + Optional.empty()); + assertThatThrownBy(() -> authorizationService.authorize(webhookRequest, policy)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + } else { + var request = request(serviceAccount(), action, WEBHOOK_CONFIGURATION, Optional.empty()); + assertThatCode(() -> authorizationService.authorize(request, policy)) + .doesNotThrowAnyException(); + } + } + } + + @Test + void shouldAllowServiceAccountToCreateOtherCatalogResources() { + var request = request(serviceAccount(), AuthorizationAction.CREATE, CATALOG, Optional.empty()); assertThatCode(() -> authorizationService.authorize(request, policy)) .doesNotThrowAnyException(); @@ -63,7 +98,7 @@ void shouldAllowServiceAccountForWriteOperations() { @Test void shouldAllowReadOperationsForNonAdminHumans() { - var request = new AuthorizationRequest(humanPrincipal, Optional.empty(), true); + var request = request(humanPrincipal, AuthorizationAction.READ, CATALOG, Optional.empty()); assertThatCode(() -> authorizationService.authorize(request, policy)) .doesNotThrowAnyException(); @@ -71,27 +106,61 @@ void shouldAllowReadOperationsForNonAdminHumans() { @Test void shouldRejectWriteOperationsForNonAdminHumans() { - var request = new AuthorizationRequest(humanPrincipal, Optional.of(principalEntity("false")), - false); + var request = request(humanPrincipal, AuthorizationAction.UPDATE, CATALOG, + Optional.of(principalEntity("false"))); assertThatThrownBy(() -> authorizationService.authorize(request, policy)) .isInstanceOf(PrincipalNotAuthorizedException.class) .hasMessageContaining(PRINCIPAL_IDENTIFIER); } + @Test + void shouldRejectAdminFlagOnNonPrincipalEntity() { + Entity nonPrincipalEntity = new Entity(UUID.randomUUID(), "team", "Platform User", + PRINCIPAL_IDENTIFIER, List.of(new Property(null, "is_admin", "true")), List.of()); + var request = request(humanPrincipal, AuthorizationAction.UPDATE, CATALOG, + Optional.of(nonPrincipalEntity)); + + assertThatThrownBy(() -> authorizationService.authorize(request, policy)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + } + + @Test + void shouldRejectUnsupportedActionsEvenForBreakGlassPrincipal() { + var request = request(principal("break-glass-user", PrincipalKind.HUMAN), + AuthorizationAction.UNSUPPORTED, CATALOG, Optional.empty()); + + assertThatThrownBy(() -> authorizationService.authorize(request, policy)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + } + @Test void shouldRejectModesThatAreNotImplementedYet() { var futureModePolicy = new AuthorizationPolicy(AuthorizationMode.RBAC, Set.of()); - var request = new AuthorizationRequest(humanPrincipal, Optional.empty(), true); + var request = request(humanPrincipal, AuthorizationAction.READ, CATALOG, Optional.empty()); assertThatThrownBy(() -> authorizationService.authorize(request, futureModePolicy)) .isInstanceOf(PrincipalNotAuthorizedException.class); } + private AuthorizationRequest request(PrincipalInfo principal, AuthorizationAction action, + AuthorizationResource resource, Optional principalEntity) { + return new AuthorizationRequest(principal, principalEntity, action, resource); + } + + private List crudActions() { + return List.of(AuthorizationAction.CREATE, AuthorizationAction.READ, AuthorizationAction.UPDATE, + AuthorizationAction.DELETE); + } + private PrincipalInfo principal(String identifier, PrincipalKind kind) { return new PrincipalInfo(identifier, kind, "Platform User", null, null); } + private PrincipalInfo serviceAccount() { + return principal(PRINCIPAL_IDENTIFIER, PrincipalKind.SERVICE_ACCOUNT); + } + private Entity principalEntity(String isAdmin) { return new Entity(UUID.randomUUID(), "principal", "Platform User", PRINCIPAL_IDENTIFIER, List.of(new Property(null, "is_admin", isAdmin)), List.of()); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalExtractorTest.java b/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalExtractorTest.java index 461ded56..e5f61d4a 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalExtractorTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalExtractorTest.java @@ -1,6 +1,7 @@ package com.decathlon.idp_core.domain.service.principal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -201,7 +202,7 @@ void setUp() { authProperties = new AuthenticationProperties(claimMappings, new ServiceAccountDetection(true, "legacy", "token_type", "m2m", List.of("grant_type", "service_name", "client_id")), - List.of()); + List.of(), "sub"); jwtStrategy = new JwtPrincipalExtractionStrategy(authProperties); } @@ -246,13 +247,31 @@ void shouldExtractHumanFromJwtWithAllClaims() { PrincipalInfo principal = jwtStrategy.extract(auth); // Then: Principal is extracted correctly - assertThat(principal.identifier()).isEqualTo("john.doe"); + assertThat(principal.identifier()).isEqualTo("user-123"); assertThat(principal.name()).isEqualTo("John Doe"); assertThat(principal.kind()).isEqualTo(PrincipalKind.HUMAN); assertThat(principal.attributes()).containsEntry("email", "john@example.com"); assertThat(principal.groups()).containsExactly("admin", "users"); } + @Test + @DisplayName("Should use the configured claim as the human principal identifier") + void shouldUseConfiguredIdentifierClaimForHumanPrincipal() { + AuthenticationProperties properties = new AuthenticationProperties( + authProperties.userClaimMappings(), authProperties.serviceAccountDetection(), List.of(), + "uuid"); + JwtPrincipalExtractionStrategy strategy = new JwtPrincipalExtractionStrategy(properties); + var jwtToken = mock(org.springframework.security.oauth2.jwt.Jwt.class); + when(jwtToken.getSubject()).thenReturn("stable-subject"); + when(jwtToken.getClaims()) + .thenReturn(Map.of("uuid", "user-uuid", "preferred_username", "recycled-login")); + + PrincipalInfo principal = strategy.extract(new JwtAuthenticationToken(jwtToken)); + + assertThat(principal.identifier()).isEqualTo("user-uuid"); + assertThat(principal.name()).isEqualTo("recycled-login"); + } + @Test @DisplayName("Should fall back to subject when preferred_username missing") void shouldFallbackToSubjectWhenPreferredUsernameMissing() { @@ -539,7 +558,7 @@ void shouldDisableServiceAccountDetectionWhenDisabled() { authProperties.userClaimMappings(), new ServiceAccountDetection(false, "legacy", "token_type", "m2m", List.of("grant_type", "service_name")), - authProperties.jitProvisioningExcludedPaths()); + authProperties.jitProvisioningExcludedPaths(), "sub"); JwtPrincipalExtractionStrategy strategyDisabled = new JwtPrincipalExtractionStrategy( disabledConfig); @@ -568,7 +587,7 @@ void shouldUseStrictModeForServiceAccountDetection() { authProperties.userClaimMappings(), new ServiceAccountDetection(true, "strict", "token_type", "m2m", List.of("grant_type", "service_name")), - authProperties.jitProvisioningExcludedPaths()); + authProperties.jitProvisioningExcludedPaths(), "sub"); JwtPrincipalExtractionStrategy strategyStrict = new JwtPrincipalExtractionStrategy( strictConfig); @@ -597,7 +616,7 @@ void shouldNotDetectServiceAccountInStrictModeWithWrongValue() { authProperties.userClaimMappings(), new ServiceAccountDetection(true, "strict", "token_type", "m2m", List.of("grant_type", "service_name")), - authProperties.jitProvisioningExcludedPaths()); + authProperties.jitProvisioningExcludedPaths(), "sub"); JwtPrincipalExtractionStrategy strategyStrict = new JwtPrincipalExtractionStrategy( strictConfig); @@ -643,7 +662,7 @@ void setUp() { authProperties = new AuthenticationProperties(claimMappings, new ServiceAccountDetection(true, "legacy", "token_type", "m2m", List.of("grant_type", "service_name", "client_id")), - List.of()); + List.of(), "sub"); oauth2Strategy = new OAuth2UserPrincipalExtractionStrategy(authProperties); } @@ -721,8 +740,8 @@ void shouldExtractOAuth2UserWithAllAttributes() { } @Test - @DisplayName("Should fall back to name when sub not present in OAuth2User") - void shouldFallbackToNameWhenSubNotPresent() { + @DisplayName("Should require a stable subject when sub is not present in OAuth2User") + void shouldRequireStableSubjectWhenSubNotPresent() { // Given: OAuth2User without sub claim Map attributes = new HashMap<>(); attributes.put("name", "John Doe"); @@ -731,16 +750,14 @@ void shouldFallbackToNameWhenSubNotPresent() { Authentication auth = mock(Authentication.class); when(auth.getPrincipal()).thenReturn(oauth2User); - // When: Extract principal - PrincipalInfo principal = oauth2Strategy.extract(auth); - - // Then: Falls back to name (which is the OAuth2User.getName()) - assertThat(principal.identifier()).isNotBlank(); + assertThatThrownBy(() -> oauth2Strategy.extract(auth)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("principal identifier claim 'sub' is required"); } @Test - @DisplayName("Should prefer configured preferred username for OAuth2User identifier") - void shouldPreferConfiguredPreferredUsernameForOAuth2UserIdentifier() { + @DisplayName("Should use the configured identifier claim for an OAuth2 principal") + void shouldUseConfiguredIdentifierClaimForOAuth2User() { // Given: OAuth2User exposing a provider-specific login Map attributes = new HashMap<>(); attributes.put("sub", "user-123"); @@ -754,8 +771,8 @@ void shouldPreferConfiguredPreferredUsernameForOAuth2UserIdentifier() { // When: Extract principal PrincipalInfo principal = oauth2Strategy.extract(auth); - // Then: Identifier uses the human-friendly login - assertThat(principal.identifier()).isEqualTo("john.doe"); + // Then: The configured stable subject is the identifier + assertThat(principal.identifier()).isEqualTo("user-123"); assertThat(principal.name()).isEqualTo("John Doe"); } @@ -801,12 +818,11 @@ void shouldSkipEmailWhenNotPresent() { void shouldSupportGithubStyleOAuth2AttributesForJitProvisioning() { // Given: GitHub returns id/login and may omit the display name Map githubClaimMappings = new HashMap<>(authProperties.userClaimMappings()); - githubClaimMappings.put("sub", "id"); githubClaimMappings.put("preferred_username", "login"); authProperties = new AuthenticationProperties(githubClaimMappings, new ServiceAccountDetection(true, "legacy", "token_type", "m2m", List.of("grant_type", "service_name", "client_id")), - List.of()); + List.of(), "id"); oauth2Strategy = new OAuth2UserPrincipalExtractionStrategy(authProperties); Map attributes = new HashMap<>(); @@ -820,8 +836,8 @@ void shouldSupportGithubStyleOAuth2AttributesForJitProvisioning() { // When: Extract principal PrincipalInfo principal = oauth2Strategy.extract(auth); - // Then: JIT provisioning gets a stable identifier and usable display name - assertThat(principal.identifier()).isEqualTo("octocat"); + // Then: JIT provisioning uses the configured stable provider ID + assertThat(principal.identifier()).isEqualTo("424242"); assertThat(principal.name()).isEqualTo("octocat"); assertThat(principal.kind()).isEqualTo(PrincipalKind.HUMAN); } @@ -878,8 +894,30 @@ void shouldExtractOidcUserWithPreferredUsername() { // When: Extract principal PrincipalInfo principal = oauth2Strategy.extract(auth); - // Then: Uses preferred_username as identifier - assertThat(principal.identifier()).isEqualTo("john.doe"); + // Then: The OIDC subject is the configured identifier and the username is only + // a display value + assertThat(principal.identifier()).isEqualTo("user-123"); + assertThat(principal.name()).isEqualTo("John Doe"); + } + + @Test + @DisplayName("Should use the configured identifier claim for an OIDC principal") + void shouldUseConfiguredIdentifierClaimForOidcUser() { + authProperties = new AuthenticationProperties(authProperties.userClaimMappings(), + authProperties.serviceAccountDetection(), List.of(), "uuid"); + oauth2Strategy = new OAuth2UserPrincipalExtractionStrategy(authProperties); + + var idToken = mock(OidcIdToken.class); + when(idToken.getClaims()) + .thenReturn(Map.of("sub", "subject-123", "uuid", "person-uuid", "name", "John Doe")); + + var oidcUser = new DefaultOidcUser(List.of(), idToken); + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(oidcUser); + + PrincipalInfo principal = oauth2Strategy.extract(auth); + + assertThat(principal.identifier()).isEqualTo("person-uuid"); assertThat(principal.name()).isEqualTo("John Doe"); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningServiceTest.java index 093378ed..2f7ec83e 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningServiceTest.java @@ -18,6 +18,7 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.model.principal.PrincipalKind; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java new file mode 100644 index 00000000..eed87082 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java @@ -0,0 +1,69 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; +import com.decathlon.idp_core.domain.model.principal.PrincipalKind; + +class AuthorizationRequestFactoryTest { + + private final AuthorizationRequestFactory factory = new AuthorizationRequestFactory(); + private final PrincipalInfo principal = new PrincipalInfo("subject-123", PrincipalKind.HUMAN, + "User", Map.of(), List.of()); + + @Test + void shouldMapPostSearchToRead() { + var authorizationRequest = create("POST", "/api/v1/entities/search"); + + assertThat(authorizationRequest.action()).isEqualTo(AuthorizationAction.READ); + assertThat(authorizationRequest.resource().type()).isEqualTo("entity_search"); + } + + @Test + void shouldMapDynamicMappingDryRunToRead() { + var authorizationRequest = create("POST", "/api/v1/entity_dynamic_mappings/dry-run"); + + assertThat(authorizationRequest.action()).isEqualTo(AuthorizationAction.READ); + assertThat(authorizationRequest.resource().type()).isEqualTo("entity_dynamic_mapping_dry_run"); + } + + @Test + void shouldMapWebhookConfigurationCreationSeparately() { + var authorizationRequest = create("POST", "/api/v1/inbound_webhooks"); + + assertThat(authorizationRequest.action()).isEqualTo(AuthorizationAction.CREATE); + assertThat(authorizationRequest.resource().type()) + .isEqualTo(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION); + } + + @Test + void shouldMapCatalogEntityPathContext() { + var authorizationRequest = create("DELETE", "/api/v1/entities/principal/user-123"); + + assertThat(authorizationRequest.action()).isEqualTo(AuthorizationAction.DELETE); + assertThat(authorizationRequest.resource().type()).isEqualTo("entity"); + assertThat(authorizationRequest.resource().identifier()).contains("user-123"); + assertThat(authorizationRequest.resource().parentIdentifier()).contains("principal"); + } + + @Test + void shouldRejectUnknownHttpMethods() { + var authorizationRequest = create("TRACE", "/api/v1/entities"); + + assertThat(authorizationRequest.action()).isEqualTo(AuthorizationAction.UNSUPPORTED); + } + + private com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest create( + String method, String path) { + return factory.create(new MockHttpServletRequest(method, path), principal, Optional.empty()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java index c7bf4b16..ba22242f 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java @@ -30,9 +30,12 @@ import org.springframework.security.core.context.SecurityContextHolder; import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; import com.decathlon.idp_core.domain.model.authorization.AuthorizationMode; import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; +import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.model.principal.PrincipalKind; import com.decathlon.idp_core.domain.service.authorization.GlobalAuthorizationService; @@ -48,6 +51,7 @@ class GlobalAuthorizationFilterTest { GlobalAuthorizationService.class); private final PrincipalInfo principal = new PrincipalInfo("platform-user", PrincipalKind.HUMAN, "Platform User", Map.of(), List.of()); + private final AuthorizationRequestFactory requestFactory = new AuthorizationRequestFactory(); private GlobalAuthorizationFilter filter; private Authentication authentication; @@ -55,7 +59,7 @@ class GlobalAuthorizationFilterTest { void setUp() { var policy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of()); filter = new GlobalAuthorizationFilter(principalExtractor, provisioningService, - authorizationService, policy); + authorizationService, policy, requestFactory); authentication = mock(Authentication.class); when(authentication.isAuthenticated()).thenReturn(true); SecurityContextHolder.getContext().setAuthentication(authentication); @@ -81,10 +85,57 @@ void shouldMarkReadMethodsAsReadAndContinue(String method) throws ServletExcepti var requestCaptor = org.mockito.ArgumentCaptor.forClass(AuthorizationRequest.class); verify(authorizationService).authorize(requestCaptor.capture(), any(AuthorizationPolicy.class)); - assertThat(requestCaptor.getValue().readOperation()).isTrue(); + assertThat(requestCaptor.getValue().action()).isEqualTo(AuthorizationAction.READ); assertThat(response.getStatus()).isEqualTo(200); } + @Test + void shouldClassifyPostEntitySearchAsRead() throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/api/v1/entities/search"); + var response = new MockHttpServletResponse(); + + filter.doFilter(request, response, (req, res) -> { + }); + + var requestCaptor = org.mockito.ArgumentCaptor.forClass(AuthorizationRequest.class); + verify(authorizationService).authorize(requestCaptor.capture(), any(AuthorizationPolicy.class)); + assertThat(requestCaptor.getValue().action()).isEqualTo(AuthorizationAction.READ); + } + + @Test + void shouldClassifyWebhookCreationAsPlatformAdminOnlyResource() + throws ServletException, IOException { + var request = new MockHttpServletRequest("POST", "/api/v1/inbound_webhooks"); + var response = new MockHttpServletResponse(); + + filter.doFilter(request, response, (req, res) -> { + }); + + var requestCaptor = org.mockito.ArgumentCaptor.forClass(AuthorizationRequest.class); + verify(authorizationService).authorize(requestCaptor.capture(), any(AuthorizationPolicy.class)); + assertThat(requestCaptor.getValue().action()).isEqualTo(AuthorizationAction.CREATE); + assertThat(requestCaptor.getValue().resource().type()) + .isEqualTo(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION); + } + + @Test + void shouldReusePrincipalResolvedByJitProvisioning() throws ServletException, IOException { + Entity principalEntity = new Entity(null, "principal", "Platform User", "platform-user", + List.of(), List.of()); + var request = new MockHttpServletRequest("GET", "/api/v1/entities"); + request.setAttribute(ProvisionedPrincipalContext.REQUEST_ATTRIBUTE, + new ProvisionedPrincipalContext(principal, Optional.of(principalEntity))); + + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + }); + + verify(principalExtractor, never()).extractPrincipalInfo(any()); + verify(provisioningService, never()).getPrincipal(any()); + var requestCaptor = org.mockito.ArgumentCaptor.forClass(AuthorizationRequest.class); + verify(authorizationService).authorize(requestCaptor.capture(), any(AuthorizationPolicy.class)); + assertThat(requestCaptor.getValue().principalEntity()).contains(principalEntity); + } + @Test void shouldReturnForbiddenAndStopChainWhenAuthorizationIsDenied() throws ServletException, IOException { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilterTest.java index 4229ee21..4f231d1b 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilterTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilterTest.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.util.List; +import java.util.Optional; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -30,6 +31,7 @@ import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextHolder; +import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.model.principal.PrincipalKind; import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; @@ -82,6 +84,9 @@ void doFilterInternal_whenAuthenticatedUser_provisionsPrincipalAndContinuesChain when(principalInfo.identifier()).thenReturn("user-123"); when(principalInfo.kind()).thenReturn(PrincipalKind.valueOf("HUMAN")); when(principalExtractor.extractPrincipalInfo(auth)).thenReturn(principalInfo); + Entity principalEntity = new Entity(null, "principal", "User", "user-123", List.of(), + List.of()); + when(provisioningService.provisionPrincipal(principalInfo)).thenReturn(principalEntity); // Act jitProvisioningFilter.doFilterInternal(request, response, filterChain); @@ -89,6 +94,8 @@ void doFilterInternal_whenAuthenticatedUser_provisionsPrincipalAndContinuesChain // Assert verify(principalExtractor).extractPrincipalInfo(auth); verify(provisioningService).provisionPrincipal(principalInfo); + verify(request).setAttribute(ProvisionedPrincipalContext.REQUEST_ATTRIBUTE, + new ProvisionedPrincipalContext(principalInfo, Optional.of(principalEntity))); verify(filterChain).doFilter(request, response); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProviderTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProviderTest.java index 7bb2ce18..533f5b26 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProviderTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProviderTest.java @@ -2,8 +2,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.List; +import java.util.Map; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -12,19 +16,21 @@ import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; +import com.decathlon.idp_core.domain.model.principal.PrincipalKind; +import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; class UnifiedUserProviderTest { private UnifiedUserProvider unifiedUserProvider; private SecurityContext securityContext; + private PrincipalExtractor principalExtractor; @BeforeEach void setUp() { - unifiedUserProvider = new UnifiedUserProvider(); + principalExtractor = mock(PrincipalExtractor.class); + unifiedUserProvider = new UnifiedUserProvider(principalExtractor); securityContext = mock(SecurityContext.class); SecurityContextHolder.setContext(securityContext); } @@ -63,84 +69,15 @@ void shouldReturnUnknownWhenAnonymousUser() { } @Test - void shouldReturnSubjectWhenJwtAuthentication() { - JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class); - Jwt jwt = mock(Jwt.class); - - when(jwtAuth.isAuthenticated()).thenReturn(true); - when(jwtAuth.getToken()).thenReturn(jwt); - when(jwt.getSubject()).thenReturn("jwt-user-id"); - when(securityContext.getAuthentication()).thenReturn(jwtAuth); - - assertThat(unifiedUserProvider.getAuthId()).isEqualTo("jwt-user-id"); - } - - @Test - void shouldReturnSubjectWhenOidcUser() { + void shouldReturnIdentifierExtractedForAuthenticatedPrincipal() { Authentication auth = mock(Authentication.class); - OidcUser oidcUser = mock(OidcUser.class); when(auth.isAuthenticated()).thenReturn(true); - when(auth.getPrincipal()).thenReturn(oidcUser); - when(oidcUser.getSubject()).thenReturn("oidc-user-id"); when(securityContext.getAuthentication()).thenReturn(auth); + when(principalExtractor.extractPrincipalInfo(auth)).thenReturn(principal("stable-id")); - assertThat(unifiedUserProvider.getAuthId()).isEqualTo("oidc-user-id"); - } - - @Test - void shouldReturnSubAttributeWhenOAuth2User() { - Authentication auth = mock(Authentication.class); - OAuth2User oauth2User = mock(OAuth2User.class); - - when(auth.isAuthenticated()).thenReturn(true); - when(auth.getPrincipal()).thenReturn(oauth2User); - when(oauth2User.getAttribute("sub")).thenReturn("oauth2-sub-id"); - when(securityContext.getAuthentication()).thenReturn(auth); - - assertThat(unifiedUserProvider.getAuthId()).isEqualTo("oauth2-sub-id"); - } - - @Test - void shouldReturnIdAttributeWhenOAuth2UserHasNoSub() { - Authentication auth = mock(Authentication.class); - OAuth2User oauth2User = mock(OAuth2User.class); - - when(auth.isAuthenticated()).thenReturn(true); - when(auth.getPrincipal()).thenReturn(oauth2User); - when(oauth2User.getAttribute("sub")).thenReturn(null); - when(oauth2User.getAttribute("id")).thenReturn("oauth2-id-attribute"); - when(securityContext.getAuthentication()).thenReturn(auth); - - assertThat(unifiedUserProvider.getAuthId()).isEqualTo("oauth2-id-attribute"); - } - - @Test - void shouldReturnFallbackNameWhenOAuth2UserHasNoSubOrId() { - Authentication auth = mock(Authentication.class); - OAuth2User oauth2User = mock(OAuth2User.class); - - when(auth.isAuthenticated()).thenReturn(true); - when(auth.getName()).thenReturn("fallback-oauth2-name"); - when(auth.getPrincipal()).thenReturn(oauth2User); - when(oauth2User.getAttribute("sub")).thenReturn(null); - when(oauth2User.getAttribute("id")).thenReturn(null); - when(securityContext.getAuthentication()).thenReturn(auth); - - assertThat(unifiedUserProvider.getAuthId()).isEqualTo("fallback-oauth2-name"); - } - - @Test - void shouldReturnNameForBasicOrOtherAuthentication() { - Authentication auth = mock(Authentication.class); - - when(auth.isAuthenticated()).thenReturn(true); - when(auth.getName()).thenReturn("basic-auth-user"); - when(auth.getPrincipal()).thenReturn("Standard String Principal"); // N'est ni OAuth2User ni - // OidcUser - when(securityContext.getAuthentication()).thenReturn(auth); - - assertThat(unifiedUserProvider.getAuthId()).isEqualTo("basic-auth-user"); + assertThat(unifiedUserProvider.getAuthId()).isEqualTo("stable-id"); + verify(principalExtractor).extractPrincipalInfo(auth); } @Test @@ -153,4 +90,8 @@ void shouldReturnNameWhenGetNameIsCalled() { assertThat(unifiedUserProvider.getName()).isEqualTo("expected-user-name"); } + + private PrincipalInfo principal(String identifier) { + return new PrincipalInfo(identifier, PrincipalKind.HUMAN, identifier, Map.of(), List.of()); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationPropertiesTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationPropertiesTest.java new file mode 100644 index 00000000..2a371e10 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationPropertiesTest.java @@ -0,0 +1,37 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +class AuthenticationPropertiesTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestConfiguration.class); + + @Test + void shouldBindConfiguredPrincipalIdentifierClaim() { + contextRunner + .withPropertyValues("app.security.authentication.principal-identifier-claim=person_uuid") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(AuthenticationProperties.class).principalIdentifierClaim()) + .isEqualTo("person_uuid"); + }); + } + + @Test + void shouldDefaultPrincipalIdentifierClaimToSubject() { + contextRunner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(AuthenticationProperties.class).principalIdentifierClaim()) + .isEqualTo("sub"); + }); + } + + @EnableConfigurationProperties(AuthenticationProperties.class) + static class TestConfiguration { + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityRolePropertiesTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityRolePropertiesTest.java deleted file mode 100644 index bd781146..00000000 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityRolePropertiesTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.configuration; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import org.junit.jupiter.api.Test; - -/// Unit tests for SecurityRoleProperties verifying correct handling of baseline role configuration. -/// when a real role managing should be put in place, this test must fail -class SecurityRolePropertiesTest { - - @Test - void shouldKeepValidRole() { - SecurityRoleProperties props = new SecurityRoleProperties("ROLE_EDITOR"); - assertEquals("ROLE_EDITOR", props.baselineRole()); - } - - @Test - void shouldDefaultToStarWhenNull() { - SecurityRoleProperties props = new SecurityRoleProperties(null); - assertEquals("*", props.baselineRole()); - } - - @Test - void shouldDefaultToStarWhenEmpty() { - SecurityRoleProperties props = new SecurityRoleProperties(""); - assertEquals("*", props.baselineRole()); - } - - @Test - void shouldDefaultToStarWhenBlank() { - SecurityRoleProperties props = new SecurityRoleProperties(" "); - assertEquals("*", props.baselineRole()); - } -} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfigTest.java index b5b5639a..38239a53 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/OAuth2LoginFilterChainConfigTest.java @@ -1,7 +1,9 @@ package com.decathlon.idp_core.infrastructure.adapters.api.configuration.security.chains; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.DisplayName; @@ -16,6 +18,7 @@ import org.springframework.security.web.DefaultSecurityFilterChain; import org.springframework.security.web.SecurityFilterChain; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; @DisplayName("OAuth2LoginFilterChainConfigTest") @@ -28,6 +31,7 @@ class OAuth2LoginFilterChainConfigTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(OAuth2LoginFilterChainConfig.class)) .withBean(JitProvisioningFilter.class, () -> mock(JitProvisioningFilter.class)) + .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)) .withBean(ClientRegistrationRepository.class, () -> mock(ClientRegistrationRepository.class)) .withBean(HttpSecurity.class, this::httpSecurity); @@ -50,15 +54,34 @@ void shouldNotCreateOauth2LoginFilterChainWithoutClientRegistration() { new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(OAuth2LoginFilterChainConfig.class)) .withBean(JitProvisioningFilter.class, () -> mock(JitProvisioningFilter.class)) + .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)) .withBean(HttpSecurity.class, this::httpSecurity) .withPropertyValues("app.security.authentication.oauth2-login.enabled=true") .run(context -> assertThat(context).doesNotHaveBean(OAuth2LoginFilterChainConfig.class) .doesNotHaveBean(SecurityFilterChain.class)); } + @Test + void shouldApplyGlobalAuthorizationAfterJitProvisioning() { + var jitFilter = mock(JitProvisioningFilter.class); + var authorizationFilter = mock(GlobalAuthorizationFilter.class); + var configuration = new OAuth2LoginFilterChainConfig(jitFilter, authorizationFilter); + HttpSecurity http = httpSecurity(); + + configuration.oauth2LoginSecurityFilterChain(http); + + verify(http).addFilterAfter(authorizationFilter, JitProvisioningFilter.class); + } + private HttpSecurity httpSecurity() { HttpSecurity http = mock(HttpSecurity.class, org.mockito.Answers.RETURNS_DEEP_STUBS); when(http.build()).thenReturn(securityFilterChain); + when(http.sessionManagement(any())).thenReturn(http); + when(http.cors(any())).thenReturn(http); + when(http.csrf(any())).thenReturn(http); + when(http.authorizeHttpRequests(any())).thenReturn(http); + when(http.oauth2Login(any())).thenReturn(http); + when(http.addFilterAfter(any(), any())).thenReturn(http); return http; } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfigTest.java index 1ab65fa5..ab55f40c 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/PublicFilterChainConfigTest.java @@ -42,8 +42,8 @@ void shouldCreatePublicFilterChain() { private AuthenticationProperties authenticationProperties(List excludedPaths) { return new AuthenticationProperties(Map.of(), - new ServiceAccountDetection(false, "legacy", "token_type", "m2m", List.of()), - excludedPaths); + new ServiceAccountDetection(false, "legacy", "token_type", "m2m", List.of()), excludedPaths, + "sub"); } private HttpSecurity httpSecurity() { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java index 504ebce5..edbcb1f1 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookManagementControllerTest.java @@ -185,6 +185,17 @@ void postWebhook_401_without_user_token() throws Exception { .andExpect(status().isUnauthorized()); } + @Test + @WithMockUser(username = "standard-human-webhook-creator") + @DisplayName("Should forbid standard humans from creating webhook configurations") + void postWebhook_403_for_standard_human() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(WEBHOOK_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(JSON_PATH + "postWebhook_201.json"))) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser @DisplayName("Should create connector and return 201") diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalControllerTest.java index 00c94b1e..b5f05a91 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalControllerTest.java @@ -158,19 +158,15 @@ void jitProvisioning_worksOnAnyEndpoint() throws Exception { } @Nested - @DisplayName("Security Role Assignment") - class SecurityRoleAssignmentTests { + @DisplayName("Global read access") + class GlobalReadAccessTests { @Test - @DisplayName("Should allow access with baseline role assignment") - @WithMockUser(username = "baseline-role-user") - void baselineRole_allowsAccess() throws Exception { - // Given: User with baseline role (assigned via JwtAuthenticationConverter) - - // When: Access protected endpoint - mockMvc.perform(get(PRINCIPAL_ME_PATH).accept(APPLICATION_JSON)) - // Then: Access granted (baseline role * provides access) - .andExpect(status().isOk()).andExpect(jsonPath("$.identifier", is("baseline-role-user"))); + @DisplayName("Should allow a standard human to read their principal") + @WithMockUser(username = "standard-human-reader") + void standardHuman_canReadPrincipal() throws Exception { + mockMvc.perform(get(PRINCIPAL_ME_PATH).accept(APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(jsonPath("$.identifier", is("standard-human-reader"))); } } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 3ffe9025..b4e76583 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -29,8 +29,6 @@ spring: # Added App Security configuration app: security: - roles: - baseline-role: "*" authentication: jwt: enabled: true From 573abaefc244f53335b144ff455f595bbe161a80 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Tue, 29 Sep 2026 15:27:35 +0200 Subject: [PATCH 03/11] feat(core): Global RBAC - update doc --- docs/src/concepts/authentication.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/src/concepts/authentication.md b/docs/src/concepts/authentication.md index 8c971ae0..9b4828de 100644 --- a/docs/src/concepts/authentication.md +++ b/docs/src/concepts/authentication.md @@ -206,12 +206,12 @@ when dealing with custom claims or missing data. The extraction strategy doesn't just rename claims; it applies specific fallback rules if a mapped claim is missing from the token: -| Principal field | Mapping key | Fallback when the mapped claim is absent | -|--------------------|----------------------|------------------------------------------| +| Principal field | Mapping key | Fallback when the mapped claim is absent | +|--------------------|------------------------------|-------------------------------------------------| | `identifier` | `principal-identifier-claim` | Request is rejected; the identifier is required | -| `name` | `name` | `preferred_username`, then `identifier` | -| `attributes.email` | `email` | Attribute omitted | -| `groups` | `groups` | Empty list | +| `name` | `name` | `preferred_username`, then `identifier` | +| `attributes.email` | `email` | Attribute omitted | +| `groups` | `groups` | Empty list | ### Example: UUID Identifier and Subject as Display Name @@ -249,7 +249,7 @@ For a token classified as human, the extracted `PrincipalInfo` contains: > [!WARNING] > Changing the identifier mapping can provision a separate catalog principal. -> Choose a stable claim before onboarding. IDP-Core does not automatically rename, +> Choose a stable claim. IDP-Core does not automatically rename, > merge, or look up previously provisioned principals when this setting changes. ## Service Account Detection @@ -458,7 +458,7 @@ unimplemented mode is configured. Use `GLOBAL` until those modes are implemented The `app.security.authentication.api-key` chain is currently only a conditional security-chain placeholder; it does not validate an API-key credential or establish a service-account principal. Do not enable it as an authentication mechanism until that authenticator is implemented. Inbound webhook delivery uses the separate connector-level security -validators described in [Webhook Security](webhooks.md#security-strategies). +validation described in [Webhook Security](webhooks.md#security-strategies). ### Enabling API Key Authentication From 8a614d81ed185c280bf692151e81ef9a13e304ae Mon Sep 17 00:00:00 2001 From: rvando12 Date: Tue, 29 Sep 2026 16:57:17 +0200 Subject: [PATCH 04/11] feat(core): Global RBAC - prepare step following --- .../api/auth/AuthorizationRequestFactory.java | 124 ++++++++++-------- .../adapters/api/auth/AuthorizedResource.java | 25 ++++ .../api/auth/GlobalAuthorizationFilter.java | 2 +- .../api/controller/AuditController.java | 2 + .../api/controller/EntityController.java | 3 + .../EntityDynamicMappingController.java | 3 + .../api/controller/EntityGraphController.java | 2 + .../controller/EntityTemplateController.java | 2 + ...InboundWebhookConfigurationController.java | 3 + .../api/controller/PrincipalController.java | 2 + .../auth/AuthorizationRequestFactoryTest.java | 29 +++- .../auth/GlobalAuthorizationFilterTest.java | 3 +- .../api/auth/TestHandlerMappings.java | 40 ++++++ 13 files changed, 183 insertions(+), 57 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizedResource.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/TestHandlerMappings.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java index d2643f4a..12fe8a3c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java @@ -1,11 +1,17 @@ package com.decathlon.idp_core.infrastructure.adapters.api.auth; -import java.util.Arrays; +import java.util.Map; import java.util.Optional; import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExecutionChain; +import org.springframework.web.servlet.HandlerMapping; import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; @@ -13,24 +19,60 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; +import lombok.extern.slf4j.Slf4j; + /// Maps servlet requests to the framework-independent authorization context. +/// +/// The resource is read from [AuthorizedResource] on the handler resolved by Spring MVC and +/// its identifiers from the matched URI template variables, so no URL is parsed manually. +@Slf4j @Component public class AuthorizationRequestFactory { - private static final String ENTITY_SEARCH_PATH = "/api/v1/entities/search"; - private static final String ENTITY_DYNAMIC_MAPPING_DRY_RUN_PATH = "/api/v1/entity_dynamic_mappings/dry-run"; + private static final String UNKNOWN_RESOURCE = "unknown"; + private static final String TEMPLATE_IDENTIFIER = "templateIdentifier"; + private static final String ENTITY_IDENTIFIER = "entityIdentifier"; + private static final String IDENTIFIER = "identifier"; + + private final HandlerMapping handlerMapping; + + /// Creates a factory backed by the MVC handler mapping. + /// + /// @param handlerMapping the mapping used to resolve the controller method of a + /// request + public AuthorizationRequestFactory( + @Lazy @Qualifier("requestMappingHandlerMapping") HandlerMapping handlerMapping) { + this.handlerMapping = handlerMapping; + } /// Creates an authorization request from the principal and HTTP request. public AuthorizationRequest create(HttpServletRequest request, PrincipalInfo principal, Optional principalEntity) { - String path = normalizedPath(request); + Optional handler = resolveHandler(request); + boolean readOnly = handler.map(this::isReadOnly).orElse(false); return new AuthorizationRequest(principal, principalEntity, - resolveAction(request.getMethod(), path), resolveResource(path)); + resolveAction(request.getMethod(), readOnly), resolveResource(request, handler)); } - private AuthorizationAction resolveAction(String method, String path) { - if ("POST".equals(method) - && (ENTITY_SEARCH_PATH.equals(path) || ENTITY_DYNAMIC_MAPPING_DRY_RUN_PATH.equals(path))) { + private Optional resolveHandler(HttpServletRequest request) { + try { + return Optional.ofNullable(handlerMapping.getHandler(request)) + .map(HandlerExecutionChain::getHandler).filter(HandlerMethod.class::isInstance) + .map(HandlerMethod.class::cast); + } catch (Exception exception) { + log.warn("Unable to resolve handler for {} {}", request.getMethod(), request.getRequestURI(), + exception); + return Optional.empty(); + } + } + + private boolean isReadOnly(HandlerMethod handler) { + AuthorizedResource annotation = handler.getMethodAnnotation(AuthorizedResource.class); + return annotation != null && annotation.readOnly(); + } + + private AuthorizationAction resolveAction(String method, boolean readOnly) { + if ("POST".equals(method) && readOnly) { return AuthorizationAction.READ; } @@ -43,56 +85,30 @@ private AuthorizationAction resolveAction(String method, String path) { }; } - private AuthorizationResource resolveResource(String path) { - if (ENTITY_SEARCH_PATH.equals(path)) { - return new AuthorizationResource("entity_search", Optional.empty(), Optional.empty()); - } - if (ENTITY_DYNAMIC_MAPPING_DRY_RUN_PATH.equals(path)) { - return new AuthorizationResource("entity_dynamic_mapping_dry_run", Optional.empty(), - Optional.empty()); - } - - String[] segments = Arrays.stream(path.split("/")).filter(segment -> !segment.isBlank()) - .toArray(String[]::new); - if (segments.length < 3 || !"api".equals(segments[0]) || !"v1".equals(segments[1])) { - return new AuthorizationResource("unknown", Optional.empty(), Optional.empty()); - } - - String resourceRoot = segments[2]; - if ("entities".equals(resourceRoot)) { - return entityResource(segments); - } - - if ("inbound_webhooks".equals(resourceRoot)) { - return new AuthorizationResource(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION, - identifierAt(segments, 3), Optional.empty()); - } - - String type = switch (resourceRoot) { - case "entity-templates" -> "entity_template"; - case "entity_dynamic_mappings" -> "entity_dynamic_mapping"; - case "audit" -> "audit"; - default -> "catalog_resource"; - }; - return new AuthorizationResource(type, identifierAt(segments, 3), Optional.empty()); + private AuthorizationResource resolveResource(HttpServletRequest request, + Optional handler) { + String type = handler.flatMap(this::resourceType).orElse(UNKNOWN_RESOURCE); + Map variables = pathVariables(request); + Optional identifier = Optional.ofNullable(variables.get(ENTITY_IDENTIFIER)) + .or(() -> Optional.ofNullable(variables.get(IDENTIFIER))); + return new AuthorizationResource(type, identifier, + Optional.ofNullable(variables.get(TEMPLATE_IDENTIFIER))); } - private AuthorizationResource entityResource(String[] segments) { - if (segments.length >= 5) { - return new AuthorizationResource("entity", Optional.of(segments[4]), - Optional.of(segments[3])); + private Optional resourceType(HandlerMethod handler) { + AuthorizedResource onMethod = handler.getMethodAnnotation(AuthorizedResource.class); + if (onMethod != null && !onMethod.value().isBlank()) { + return Optional.of(onMethod.value()); } - return new AuthorizationResource("entity", Optional.empty(), identifierAt(segments, 3)); - } - - private Optional identifierAt(String[] segments, int index) { - return segments.length > index ? Optional.of(segments[index]) : Optional.empty(); + return Optional + .ofNullable(AnnotatedElementUtils.findMergedAnnotation(handler.getBeanType(), + AuthorizedResource.class)) + .map(AuthorizedResource::value).filter(value -> !value.isBlank()); } - private String normalizedPath(HttpServletRequest request) { - String requestUri = request.getRequestURI(); - String contextPath = request.getContextPath(); - String path = requestUri.substring(contextPath.length()); - return path.endsWith("/") && path.length() > 1 ? path.substring(0, path.length() - 1) : path; + @SuppressWarnings("unchecked") + private Map pathVariables(HttpServletRequest request) { + Object variables = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + return variables instanceof Map map ? (Map) map : Map.of(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizedResource.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizedResource.java new file mode 100644 index 00000000..3ed985d5 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizedResource.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Declares the authorization resource exposed by a controller or endpoint. +/// +/// A class-level declaration provides the default resource type; a method-level +/// declaration overrides it and may flag a non-safe HTTP method as read-only. +/// Path variables named `templateIdentifier`, `entityIdentifier` and `identifier` +/// are used to populate the resource context. +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface AuthorizedResource { + + /// Resource type; blank on a method means "inherit from the controller". + String value() default ""; + + /// Marks an endpoint that uses POST but only reads data (search, dry-run). + boolean readOnly() default false; +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java index dcd7fb89..2ca730df 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java @@ -81,7 +81,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, try { authorizationService.authorize(authorizationRequest, authorizationPolicy); - } catch (PrincipalNotAuthorizedException exception) { + } catch (PrincipalNotAuthorizedException principalException) { log.warn("Authorization denied for principal {} on {} {}", principal.identifier(), request.getMethod(), request.getRequestURI()); response.sendError(HttpServletResponse.SC_FORBIDDEN); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java index fbbdabd1..1b3c7a6f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/AuditController.java @@ -29,6 +29,7 @@ import com.decathlon.idp_core.domain.model.entity.EntityAuditInfo; import com.decathlon.idp_core.domain.service.entity.EntityAuditService; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.audit.EntityAuditDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityAuditDtoOutMapper; import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; @@ -53,6 +54,7 @@ /// keeping the other controller focused on CRUD operations. This follows the Single /// Responsibility Principle. @RestController +@AuthorizedResource("audit") @RequestMapping("/api/v1/audit/") @Tag(name = "Audit", description = "Operations related to audit history") @Validated diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 4c0aa137..5d67a761 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -84,6 +84,7 @@ import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; import com.decathlon.idp_core.domain.service.search.SearchFilterParser; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntitySearchRequestDtoIn; @@ -113,6 +114,7 @@ /// - Provides paginated responses for efficient data transfer /// - Maps domain exceptions to appropriate HTTP status codes @RestController +@AuthorizedResource("entity") @RequestMapping("/api/v1/entities") @Tag(name = "Entities Management", description = "Operations related to entity management") @Validated @@ -339,6 +341,7 @@ public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { @Content(schema = @Schema(implementation = ErrorResponse.class))}) @PostMapping("/search") + @AuthorizedResource(value = "entity_search", readOnly = true) @ResponseStatus(OK) public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java index e6eae749..07a150ab 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java @@ -14,6 +14,7 @@ import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingDryRunService; import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingService; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingDryRunDtoIn; @@ -35,6 +36,7 @@ import lombok.RequiredArgsConstructor; @RestController +@AuthorizedResource("entity_dynamic_mapping") @RequiredArgsConstructor @RequestMapping("/api/v1/entity_dynamic_mappings") @Tag(name = "Entity dynamic mapping", description = "Operations related to entity dynamic mapping management") @@ -126,6 +128,7 @@ public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable Strin @ApiResponse(responseCode = UNPROCESSABLE_CONTENT_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DRY_RUN_VALIDATION_ERROR, content = { @Content(schema = @Schema(implementation = ErrorResponse.class))}) @PostMapping("/dry-run") + @AuthorizedResource(value = "entity_dynamic_mapping_dry_run", readOnly = true) @ResponseStatus(OK) public EntityDynamicMappingDryRunDtoOut executeDryRun( @Valid @RequestBody EntityDynamicMappingDryRunDtoIn dryRunRequest) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index ad45f22a..6008066f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -29,6 +29,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_graph.EntityGraphFlatDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_graph.EntityGraphFlatDtoOutMapper; import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; @@ -47,6 +48,7 @@ /// starting from a specified entity, suitable for frontend visualization tools /// such as React Flow, Vis.js, and Cytoscape. @RestController +@AuthorizedResource("entity") @RequestMapping("/api/v1/entities") @RequiredArgsConstructor @Validated diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java index bd32c898..5f10f854 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java @@ -46,6 +46,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.TemplatePageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateUpdateDtoIn; @@ -81,6 +82,7 @@ /// paginated listing, identifier-based lookup, creation with uniqueness validation, /// updates with conflict resolution, and safe deletion with referential checks. @RestController +@AuthorizedResource("entity_template") @RequiredArgsConstructor @RequestMapping("/api/v1/entity-templates") @Tag(name = "Entities Templates Management", description = "Operations related to entity template management") diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java index 1405f0a8..92f064df 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java @@ -10,8 +10,10 @@ import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.*; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn; @@ -30,6 +32,7 @@ /// REST controller exposing inbound webhook configuration management endpoints. @RestController +@AuthorizedResource(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION) @RequiredArgsConstructor @RequestMapping("/api/v1/inbound_webhooks") @Tag(name = "Inbound Webhook Management", description = "Operations for managing inbound webhook connector configurations") diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalController.java index 8faf06d5..bc2b80dd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/PrincipalController.java @@ -20,6 +20,7 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; @@ -43,6 +44,7 @@ /// **Design rationale:** Separates principal self-service from general entity /// management. Uses dedicated `/principals/me` path to clearly signal intent. @RestController +@AuthorizedResource("principal") @RequestMapping("/api/v1/entities/principals") @RequiredArgsConstructor @Tag(name = "Principals", description = "Principal identity and profile management") diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java index eed87082..9c312d64 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java @@ -16,7 +16,8 @@ class AuthorizationRequestFactoryTest { - private final AuthorizationRequestFactory factory = new AuthorizationRequestFactory(); + private final AuthorizationRequestFactory factory = new AuthorizationRequestFactory( + TestHandlerMappings.controllers()); private final PrincipalInfo principal = new PrincipalInfo("subject-123", PrincipalKind.HUMAN, "User", Map.of(), List.of()); @@ -55,6 +56,32 @@ void shouldMapCatalogEntityPathContext() { assertThat(authorizationRequest.resource().parentIdentifier()).contains("principal"); } + @Test + void shouldMapAuditPathVariablesByName() { + var authorizationRequest = create("GET", "/api/v1/audit/entities/principal/user-123"); + + assertThat(authorizationRequest.resource().type()).isEqualTo("audit"); + assertThat(authorizationRequest.resource().identifier()).contains("user-123"); + assertThat(authorizationRequest.resource().parentIdentifier()).contains("principal"); + } + + @Test + void shouldMapTemplateIdentifier() { + var authorizationRequest = create("PUT", "/api/v1/entity-templates/service"); + + assertThat(authorizationRequest.action()).isEqualTo(AuthorizationAction.UPDATE); + assertThat(authorizationRequest.resource().type()).isEqualTo("entity_template"); + assertThat(authorizationRequest.resource().identifier()).contains("service"); + } + + @Test + void shouldUseUnknownResourceWhenNoHandlerMatches() { + var authorizationRequest = create("GET", "/actuator/health"); + + assertThat(authorizationRequest.action()).isEqualTo(AuthorizationAction.READ); + assertThat(authorizationRequest.resource().type()).isEqualTo("unknown"); + } + @Test void shouldRejectUnknownHttpMethods() { var authorizationRequest = create("TRACE", "/api/v1/entities"); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java index ba22242f..93820039 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java @@ -51,7 +51,8 @@ class GlobalAuthorizationFilterTest { GlobalAuthorizationService.class); private final PrincipalInfo principal = new PrincipalInfo("platform-user", PrincipalKind.HUMAN, "Platform User", Map.of(), List.of()); - private final AuthorizationRequestFactory requestFactory = new AuthorizationRequestFactory(); + private final AuthorizationRequestFactory requestFactory = new AuthorizationRequestFactory( + TestHandlerMappings.controllers()); private GlobalAuthorizationFilter filter; private Authentication authentication; diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/TestHandlerMappings.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/TestHandlerMappings.java new file mode 100644 index 00000000..eae787d3 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/TestHandlerMappings.java @@ -0,0 +1,40 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import static org.mockito.Mockito.mock; + +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import com.decathlon.idp_core.infrastructure.adapters.api.controller.AuditController; +import com.decathlon.idp_core.infrastructure.adapters.api.controller.EntityController; +import com.decathlon.idp_core.infrastructure.adapters.api.controller.EntityDynamicMappingController; +import com.decathlon.idp_core.infrastructure.adapters.api.controller.EntityTemplateController; +import com.decathlon.idp_core.infrastructure.adapters.api.controller.InboundWebhookConfigurationController; + +/// Builds a real MVC handler mapping over mocked controllers to test route-based authorization. +final class TestHandlerMappings { + + private TestHandlerMappings() { + } + + static RequestMappingHandlerMapping controllers() { + var context = new GenericApplicationContext(); + context.registerBean("entityController", EntityController.class, + () -> mock(EntityController.class)); + context.registerBean("auditController", AuditController.class, + () -> mock(AuditController.class)); + context.registerBean("entityTemplateController", EntityTemplateController.class, + () -> mock(EntityTemplateController.class)); + context.registerBean("entityDynamicMappingController", EntityDynamicMappingController.class, + () -> mock(EntityDynamicMappingController.class)); + context.registerBean("inboundWebhookConfigurationController", + InboundWebhookConfigurationController.class, + () -> mock(InboundWebhookConfigurationController.class)); + context.refresh(); + + var mapping = new RequestMappingHandlerMapping(); + mapping.setApplicationContext(context); + mapping.afterPropertiesSet(); + return mapping; + } +} From f11fa24721aaa9084e5cd2e810ed119d7313dbca Mon Sep 17 00:00:00 2001 From: rvando12 Date: Fri, 2 Oct 2026 13:44:54 +0200 Subject: [PATCH 05/11] feat(core): Global RBAC - cleaning logic --- docs/src/concepts/authentication.md | 49 +++++++--- .../authorization/AuthorizationPolicy.java | 7 +- .../GlobalAuthorizationService.java | 46 ++++++++- .../PrincipalProvisioningService.java | 10 +- .../api/auth/GlobalAuthorizationFilter.java | 2 +- .../AuthorizationConfiguration.java | 4 +- .../AuthorizationProperties.java | 4 +- src/main/resources/application.yml | 4 +- .../GlobalAuthorizationServiceTest.java | 98 +++++++++++++++++-- .../PrincipalProvisioningServiceTest.java | 21 +++- .../auth/GlobalAuthorizationFilterTest.java | 2 +- .../AuthorizationConfigurationTest.java | 20 ++-- .../db/test/R__1_Insert_test_data.sql | 6 +- 13 files changed, 230 insertions(+), 43 deletions(-) diff --git a/docs/src/concepts/authentication.md b/docs/src/concepts/authentication.md index 9b4828de..4f524db6 100644 --- a/docs/src/concepts/authentication.md +++ b/docs/src/concepts/authentication.md @@ -420,20 +420,34 @@ webhook ingestion chain remains separate and continues to use the security confi In `GLOBAL` mode, a break-glass identifier or a principal entity with `is_admin: true` has full CRUD access. Other principals follow this matrix: -| Principal | Access | -|----------------------------------------------------------|--------------------------------------------------------| -| Platform administrator (`is_admin: true` or break-glass) | CRUD on catalog resources and webhook configurations | -| `SERVICE_ACCOUNT` | CRUD, except creating an inbound webhook configuration | -| Standard human | Read-only | +| Principal | Access | +|--------------------------------------------------|---------------------------------------------------| +| Platform admin (`is_admin: true` or break-glass) | CRUD on catalog and webhook configurations | +| Service account | CRUD except principal writes and webhook creation | +| Human (gate disabled or property true) | Read-only | +| Human (configured property missing or false) | No protected API access | The policy classifies `GET`, `HEAD`, and `OPTIONS` as reads. It also treats `POST /api/v1/entities/search` and `POST /api/v1/entity_dynamic_mappings/dry-run` as reads. Unsupported HTTP methods are denied. Only platform administrators can create inbound webhook configurations at `POST /api/v1/inbound_webhooks`; service accounts retain -the remaining global CRUD behavior. This rule applies to configuration management, not to webhook delivery requests. +the remaining global CRUD behavior except writes to principal records and the `principal` template. This prevents +service accounts from granting administrator access by changing principal data. This rule applies to configuration +management, not to webhook delivery requests. When configured, a human must have the named principal property set to +`true` to access any protected API endpoint, including read endpoints. If the property name is unset, this additional +check is disabled and the normal `GLOBAL` policy applies. When configured, missing or false properties deny access. +Catalog admins and break-glass identities bypass this check; service accounts use their separate access rules. + +For example, set `IDP_REQUIRED_PRINCIPAL_PROPERTY=is_idp_user` to require that property. A trusted ingestion webhook +can populate or update it on principal entities; configure connector mappings so only an authoritative source can set +it. JIT does not derive the property from token claims, and it does not overwrite existing principal data. Before +enabling the gate, add the configured optional Boolean property to the `principal` template so the ingestion mapping +can validate and store it. Human principals use the value of `principal-identifier-claim`. Service accounts use `client_id`, falling back to `azp` -and then `sub`. Break-glass values must match the resulting principal identifiers exactly. Configure one or both root -identifiers through environment variables: +and then `sub`. Break-glass values must match the resulting principal identifiers exactly and are checked even when +JIT provisioning did not create a catalog entity. Use an immutable, globally unique identifier, such as a UUID claim; +the current allow-list is identifier-based and does not scope entries by issuer. Configure one or both root identifiers +through environment variables: ```yaml app: @@ -441,17 +455,24 @@ app: authorization: mode: GLOBAL global-principal-identifiers: ${IDP_SUPER_ADMIN_IDENTIFIER:},${IDP_PLATFORM_ADMIN_IDENTIFIER:} + required-principal-property: ${IDP_REQUIRED_PRINCIPAL_PROPERTY:} ``` IDP-Core provisions each principal directly under the configured identifier. It does not retain username aliases or automatically migrate historical principal identifiers. -The `is_admin` property on the principal entity grants platform-wide CRUD access. Entity-template permission or -ownership settings are intentionally ignored in this phase. The former `baseline-role: "*"` assignment has been -removed; authorities from JWT scopes do not grant platform permissions. - -The `RBAC` and `ABAC` modes are reserved for future authorization policies. Requests fail closed when either -unimplemented mode is configured. Use `GLOBAL` until those modes are implemented. +JIT provisions new principals with `is_admin: false` and ignores `is_admin` and `kind` among extracted attributes. +An authorized administrator or a trusted ingestion webhook can update `is_admin`; do not map an untrusted identity +claim to this property. Normal service-account API requests cannot write principal records, while the connector- +authenticated webhook ingestion chain remains separate. Restrict its mappings to authoritative sources because +`is_admin` grants platform-wide CRUD access. Entity-template permission or ownership settings are intentionally ignored +in this phase. The former `baseline-role: "*"` assignment has been removed; JWT authorities do not grant platform +permissions. + +The authorization request already identifies a principal, action, and resource. The next stage can add configurable +access profiles and resource/action permissions, with profile names stored as data rather than hardcoded role names. +The full rights matrix can then move to catalog entities and relations without changing the request shape. Until that +policy is implemented, the `RBAC` and `ABAC` modes fail closed; use `GLOBAL`. ## API Key Authentication diff --git a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationPolicy.java b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationPolicy.java index 8a89cddf..88849506 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationPolicy.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationPolicy.java @@ -1,15 +1,20 @@ package com.decathlon.idp_core.domain.model.authorization; import java.util.Objects; +import java.util.Optional; import java.util.Set; /// Immutable authorization configuration used by domain authorization services. -public record AuthorizationPolicy(AuthorizationMode mode, Set globalPrincipalIdentifiers) { +public record AuthorizationPolicy(AuthorizationMode mode, Set globalPrincipalIdentifiers, + Optional requiredPrincipalProperty) { public AuthorizationPolicy { Objects.requireNonNull(mode, "mode must not be null"); globalPrincipalIdentifiers = globalPrincipalIdentifiers != null ? Set.copyOf(globalPrincipalIdentifiers) : Set.of(); + Objects.requireNonNull(requiredPrincipalProperty, "requiredPrincipalProperty must not be null"); + requiredPrincipalProperty = requiredPrincipalProperty.map(String::trim) + .filter(propertyName -> !propertyName.isBlank()); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java b/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java index bd2ad811..3db962f4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java @@ -17,9 +17,12 @@ public class GlobalAuthorizationService { private static final String IS_ADMIN_PROPERTY = "is_admin"; private static final String PRINCIPAL_TEMPLATE_IDENTIFIER = "principal"; + private static final String ENTITY_RESOURCE = "entity"; + private static final String PRINCIPAL_RESOURCE = "principal"; + private static final String ENTITY_TEMPLATE_RESOURCE = "entity_template"; - /// Authorizes break-glass principals, catalog administrators, service accounts, - /// and reads. + /// Authorizes break-glass principals, catalog administrators, eligible + /// service-account operations, and reads. /// /// @param request the principal and operation being authorized /// @param policy the configured global authorization policy @@ -39,9 +42,22 @@ public void authorize(AuthorizationRequest request, AuthorizationPolicy policy) return; } - if (request.principal().kind() == PrincipalKind.SERVICE_ACCOUNT - && !isPlatformAdminOnlyCreation(request)) { - return; + boolean missingConfiguredAccessProperty = request.principal().kind() == PrincipalKind.HUMAN + && policy.requiredPrincipalProperty() + .map(propertyName -> request.principalEntity() + .filter(entity -> hasRequiredPrincipalProperty(entity, propertyName)).isEmpty()) + .orElse(false); + if (missingConfiguredAccessProperty) { + throw new PrincipalNotAuthorizedException(identifier); + } + + if (request.principal().kind() == PrincipalKind.SERVICE_ACCOUNT) { + if (isPrincipalManagementWrite(request)) { + throw new PrincipalNotAuthorizedException(identifier); + } + if (!isPlatformAdminOnlyCreation(request)) { + return; + } } if (request.action() == AuthorizationAction.READ) { @@ -56,10 +72,30 @@ private boolean isPlatformAdminOnlyCreation(AuthorizationRequest request) { && AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION.equals(request.resource().type()); } + private boolean isPrincipalManagementWrite(AuthorizationRequest request) { + if (request.action() == AuthorizationAction.READ) { + return false; + } + + AuthorizationResource resource = request.resource(); + return PRINCIPAL_RESOURCE.equals(resource.type()) + || (ENTITY_RESOURCE.equals(resource.type()) && resource.parentIdentifier() + .filter(PRINCIPAL_TEMPLATE_IDENTIFIER::equals).isPresent()) + || (ENTITY_TEMPLATE_RESOURCE.equals(resource.type()) + && resource.identifier().filter(PRINCIPAL_TEMPLATE_IDENTIFIER::equals).isPresent()); + } + private boolean isCatalogAdministrator(Entity principalEntity) { return PRINCIPAL_TEMPLATE_IDENTIFIER.equals(principalEntity.templateIdentifier()) && principalEntity.properties().stream() .anyMatch(property -> IS_ADMIN_PROPERTY.equals(property.name()) && Boolean.parseBoolean(property.value())); } + + private boolean hasRequiredPrincipalProperty(Entity principalEntity, String propertyName) { + return PRINCIPAL_TEMPLATE_IDENTIFIER.equals(principalEntity.templateIdentifier()) + && principalEntity.properties().stream() + .anyMatch(property -> propertyName.equals(property.name()) + && Boolean.parseBoolean(property.value())); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningService.java b/src/main/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningService.java index 3d9e9521..6eb80923 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningService.java @@ -36,6 +36,8 @@ public class PrincipalProvisioningService { private static final String PRINCIPAL_TEMPLATE_IDENTIFIER = "principal"; + private static final String KIND_PROPERTY = "kind"; + private static final String IS_ADMIN_PROPERTY = "is_admin"; private final EntityRepositoryPort entityRepository; @@ -112,10 +114,14 @@ private Entity createNewPrincipal(PrincipalInfo principalInfo) { /// @return list of properties for the principal entity private List buildProperties(PrincipalInfo principalInfo) { List properties = new ArrayList<>(); - properties.add(new Property(null, "kind", principalInfo.kind().name())); + properties.add(new Property(null, KIND_PROPERTY, principalInfo.kind().name())); + // JIT must not grant catalog-admin privileges from identity-provider + // attributes. + properties.add(new Property(null, IS_ADMIN_PROPERTY, "false")); principalInfo.attributes().forEach((key, value) -> { - if (value != null && !value.isBlank()) { + if (!KIND_PROPERTY.equalsIgnoreCase(key) && !IS_ADMIN_PROPERTY.equalsIgnoreCase(key) + && value != null && !value.isBlank()) { properties.add(new Property(null, key, value)); } }); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java index 2ca730df..a239ebfa 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java @@ -81,7 +81,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, try { authorizationService.authorize(authorizationRequest, authorizationPolicy); - } catch (PrincipalNotAuthorizedException principalException) { + } catch (PrincipalNotAuthorizedException _) { log.warn("Authorization denied for principal {} on {} {}", principal.identifier(), request.getMethod(), request.getRequestURI()); response.sendError(HttpServletResponse.SC_FORBIDDEN); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java index fe10f10b..75320d7f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java @@ -1,5 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.api.configuration; +import java.util.Optional; import java.util.Set; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -35,6 +36,7 @@ public FilterRegistrationBean globalAuthorizationFilt @Bean public AuthorizationPolicy authorizationPolicy(AuthorizationProperties properties) { return new AuthorizationPolicy(properties.mode(), - Set.copyOf(properties.globalPrincipalIdentifiers())); + Set.copyOf(properties.globalPrincipalIdentifiers()), + Optional.ofNullable(properties.requiredPrincipalProperty())); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationProperties.java index 81f46548..ba1363aa 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationProperties.java @@ -9,7 +9,7 @@ /// Authorization settings bound from `app.security.authorization`. @ConfigurationProperties(prefix = "app.security.authorization") public record AuthorizationProperties(AuthorizationMode mode, - List globalPrincipalIdentifiers) { + List globalPrincipalIdentifiers, String requiredPrincipalProperty) { public AuthorizationProperties { mode = mode != null ? mode : AuthorizationMode.GLOBAL; @@ -18,5 +18,7 @@ public record AuthorizationProperties(AuthorizationMode mode, .filter(identifier -> identifier != null && !identifier.isBlank()).map(String::trim) .toList() : List.of(); + requiredPrincipalProperty = requiredPrincipalProperty == null + || requiredPrincipalProperty.isBlank() ? null : requiredPrincipalProperty.trim(); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 284d5949..924dc99a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -120,8 +120,10 @@ app: security: authorization: mode: GLOBAL - # Comma-separated principal identifiers for emergency platform access. + # Comma-separated immutable principal identifiers for emergency access, even before JIT provisioning. global-principal-identifiers: ${IDP_SUPER_ADMIN_IDENTIFIER:},${IDP_PLATFORM_ADMIN_IDENTIFIER:} + # Optional human-access gate; when set, the named principal property must be true. + required-principal-property: ${IDP_REQUIRED_PRINCIPAL_PROPERTY:} # Authentication configuration - externalized for multi-IdP support # See docs/src/concepts/authentication.md for comprehensive documentation diff --git a/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java index 2d957680..1fb7ad6a 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java @@ -25,6 +25,7 @@ class GlobalAuthorizationServiceTest { private static final String PRINCIPAL_IDENTIFIER = "platform-user"; + private static final String REQUIRED_PRINCIPAL_PROPERTY = "is_idp_user"; private static final AuthorizationResource CATALOG = new AuthorizationResource("entity", Optional.of("api-one"), Optional.of("api")); private static final AuthorizationResource WEBHOOK_CONFIGURATION = new AuthorizationResource( @@ -37,7 +38,8 @@ class GlobalAuthorizationServiceTest { @BeforeEach void setUp() { - policy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of("break-glass-user")); + policy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of("break-glass-user"), + Optional.of(REQUIRED_PRINCIPAL_PROPERTY)); humanPrincipal = principal(PRINCIPAL_IDENTIFIER, PrincipalKind.HUMAN); } @@ -96,18 +98,93 @@ void shouldAllowServiceAccountToCreateOtherCatalogResources() { .doesNotThrowAnyException(); } + @Test + void shouldRejectServiceAccountWritesToPrincipalRecordsAndTemplate() { + List protectedResources = List.of( + new AuthorizationResource("entity", Optional.of("subject-123"), Optional.of("principal")), + new AuthorizationResource("principal", Optional.of("subject-123"), Optional.empty()), + new AuthorizationResource("entity_template", Optional.of("principal"), Optional.empty())); + + for (AuthorizationResource resource : protectedResources) { + for (AuthorizationAction action : List.of(AuthorizationAction.CREATE, + AuthorizationAction.UPDATE, AuthorizationAction.DELETE)) { + var request = request(serviceAccount(), action, resource, Optional.empty()); + assertThatThrownBy(() -> authorizationService.authorize(request, policy)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + } + } + } + + @Test + void shouldAllowBreakGlassPrincipalToManagePrincipalRecordsWithoutCatalogEntity() { + AuthorizationResource principalResource = new AuthorizationResource("entity", + Optional.of("subject-123"), Optional.of("principal")); + var request = request(principal("break-glass-user", PrincipalKind.HUMAN), + AuthorizationAction.UPDATE, principalResource, Optional.empty()); + + assertThatCode(() -> authorizationService.authorize(request, policy)) + .doesNotThrowAnyException(); + } + @Test void shouldAllowReadOperationsForNonAdminHumans() { - var request = request(humanPrincipal, AuthorizationAction.READ, CATALOG, Optional.empty()); + var request = request(humanPrincipal, AuthorizationAction.READ, CATALOG, + Optional.of(principalEntity("false", "true"))); assertThatCode(() -> authorizationService.authorize(request, policy)) .doesNotThrowAnyException(); } + @Test + void shouldUseConfiguredPrincipalPropertyForHumanAccess() { + var configuredPolicy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of(), + Optional.of("employee_enabled")); + Entity eligiblePrincipal = new Entity(UUID.randomUUID(), "principal", "Platform User", + PRINCIPAL_IDENTIFIER, List.of(new Property(null, "is_admin", "false"), + new Property(null, "employee_enabled", "true")), + List.of()); + var request = request(humanPrincipal, AuthorizationAction.READ, CATALOG, + Optional.of(eligiblePrincipal)); + + assertThatCode(() -> authorizationService.authorize(request, configuredPolicy)) + .doesNotThrowAnyException(); + } + + @Test + void shouldNotRequirePrincipalPropertyWhenGateIsNotConfigured() { + var ungatedPolicy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of(), + Optional.empty()); + var request = request(humanPrincipal, AuthorizationAction.READ, CATALOG, Optional.empty()); + + assertThatCode(() -> authorizationService.authorize(request, ungatedPolicy)) + .doesNotThrowAnyException(); + } + + @Test + void shouldKeepHumanWritesDeniedWhenGateIsNotConfigured() { + var ungatedPolicy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of(), + Optional.empty()); + var request = request(humanPrincipal, AuthorizationAction.UPDATE, CATALOG, Optional.empty()); + + assertThatThrownBy(() -> authorizationService.authorize(request, ungatedPolicy)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + } + + @Test + void shouldRejectHumanWithoutRequiredPropertyEvenForReadOperations() { + for (Optional principalEntity : List.>of(Optional.empty(), + Optional.of(principalEntity("false", "false")))) { + var request = request(humanPrincipal, AuthorizationAction.READ, CATALOG, principalEntity); + + assertThatThrownBy(() -> authorizationService.authorize(request, policy)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + } + } + @Test void shouldRejectWriteOperationsForNonAdminHumans() { var request = request(humanPrincipal, AuthorizationAction.UPDATE, CATALOG, - Optional.of(principalEntity("false"))); + Optional.of(principalEntity("false", "true"))); assertThatThrownBy(() -> authorizationService.authorize(request, policy)) .isInstanceOf(PrincipalNotAuthorizedException.class) @@ -117,7 +194,9 @@ void shouldRejectWriteOperationsForNonAdminHumans() { @Test void shouldRejectAdminFlagOnNonPrincipalEntity() { Entity nonPrincipalEntity = new Entity(UUID.randomUUID(), "team", "Platform User", - PRINCIPAL_IDENTIFIER, List.of(new Property(null, "is_admin", "true")), List.of()); + PRINCIPAL_IDENTIFIER, List.of(new Property(null, "is_admin", "true"), + new Property(null, REQUIRED_PRINCIPAL_PROPERTY, "true")), + List.of()); var request = request(humanPrincipal, AuthorizationAction.UPDATE, CATALOG, Optional.of(nonPrincipalEntity)); @@ -136,7 +215,8 @@ void shouldRejectUnsupportedActionsEvenForBreakGlassPrincipal() { @Test void shouldRejectModesThatAreNotImplementedYet() { - var futureModePolicy = new AuthorizationPolicy(AuthorizationMode.RBAC, Set.of()); + var futureModePolicy = new AuthorizationPolicy(AuthorizationMode.RBAC, Set.of(), + Optional.of(REQUIRED_PRINCIPAL_PROPERTY)); var request = request(humanPrincipal, AuthorizationAction.READ, CATALOG, Optional.empty()); assertThatThrownBy(() -> authorizationService.authorize(request, futureModePolicy)) @@ -162,7 +242,13 @@ private PrincipalInfo serviceAccount() { } private Entity principalEntity(String isAdmin) { + return principalEntity(isAdmin, "false"); + } + + private Entity principalEntity(String isAdmin, String isDigitalTeammate) { return new Entity(UUID.randomUUID(), "principal", "Platform User", PRINCIPAL_IDENTIFIER, - List.of(new Property(null, "is_admin", isAdmin)), List.of()); + List.of(new Property(null, "is_admin", isAdmin), + new Property(null, REQUIRED_PRINCIPAL_PROPERTY, isDigitalTeammate)), + List.of()); } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningServiceTest.java index 2f7ec83e..3a2598ff 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningServiceTest.java @@ -18,7 +18,6 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; -import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.model.principal.PrincipalKind; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; @@ -116,6 +115,26 @@ void shouldCreateServiceAccountPrincipal() { "github-connector", "github"); } + @Test + void shouldProvisionPrincipalWithAdminDisabledRegardlessOfAttributes() { + PrincipalInfo principalInfo = new PrincipalInfo("alice", PrincipalKind.HUMAN, "Alice Dupont", + Map.of("is_admin", "true", "kind", "SERVICE_ACCOUNT"), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("principal", "alice")) + .thenReturn(Optional.empty()); + when(entityRepository.save(any(Entity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + provisioningService.provisionPrincipal(principalInfo); + + ArgumentCaptor entityCaptor = ArgumentCaptor.forClass(Entity.class); + verify(entityRepository).save(entityCaptor.capture()); + List properties = entityCaptor.getValue().properties(); + assertThat(properties).filteredOn(property -> property.name().equals("is_admin")) + .extracting(Property::value).containsExactly("false"); + assertThat(properties).filteredOn(property -> property.name().equals("kind")) + .extracting(Property::value).containsExactly("HUMAN"); + } + @Test void shouldHandlePrincipalWithNoGroups() { // Given: Principal without group membership diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java index 93820039..823490c0 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java @@ -58,7 +58,7 @@ class GlobalAuthorizationFilterTest { @BeforeEach void setUp() { - var policy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of()); + var policy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of(), Optional.empty()); filter = new GlobalAuthorizationFilter(principalExtractor, provisioningService, authorizationService, policy, requestFactory); authentication = mock(Authentication.class); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java index 15afae74..8038d05d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java @@ -19,16 +19,17 @@ class AuthorizationConfigurationTest { @Test void shouldBridgeConfiguredAuthorizationPropertiesIntoDomainPolicy() { - contextRunner - .withPropertyValues("app.security.authorization.mode=GLOBAL", - "app.security.authorization.global-principal-identifiers=super-admin, platform-admin") - .run(context -> { + contextRunner.withPropertyValues("app.security.authorization.mode=GLOBAL", + "app.security.authorization.global-principal-identifiers=super-admin, platform-admin", + "app.security.authorization.required-principal-property=eligible").run(context -> { assertThat(context).hasSingleBean(AuthorizationProperties.class) .hasSingleBean(AuthorizationPolicy.class); assertThat(context.getBean(AuthorizationPolicy.class).mode()) .isEqualTo(AuthorizationMode.GLOBAL); assertThat(context.getBean(AuthorizationPolicy.class).globalPrincipalIdentifiers()) .containsExactlyInAnyOrder("super-admin", "platform-admin"); + assertThat(context.getBean(AuthorizationPolicy.class).requiredPrincipalProperty()) + .contains("eligible"); assertThat( context.getBean("globalAuthorizationFilterRegistration", FilterRegistrationBean.class) .isEnabled()).isFalse(); @@ -36,8 +37,13 @@ void shouldBridgeConfiguredAuthorizationPropertiesIntoDomainPolicy() { } @Test - void shouldDefaultToGlobalModeWhenModeIsNotConfigured() { - contextRunner.run(context -> assertThat(context.getBean(AuthorizationPolicy.class).mode()) - .isEqualTo(AuthorizationMode.GLOBAL)); + void shouldDefaultAuthorizationSettingsWhenNotConfigured() { + contextRunner.withPropertyValues("app.security.authorization.required-principal-property=") + .run(context -> { + assertThat(context.getBean(AuthorizationPolicy.class).mode()) + .isEqualTo(AuthorizationMode.GLOBAL); + assertThat(context.getBean(AuthorizationPolicy.class).requiredPrincipalProperty()) + .isEmpty(); + }); } } diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index f743b427..334b5881 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -95,7 +95,8 @@ INSERT INTO property_definition (id, name, description, type, required, rules_id -- Principal properties ('550e8400-e29b-41d4-a716-446655440045', 'kind', 'Kind of principal', 'STRING', true, '550e8400-e29b-41d4-a716-446655440016'), ('550e8400-e29b-41d4-a716-446655440046', 'email', 'Email address (for HUMAN principals)', 'STRING', false, '550e8400-e29b-41d4-a716-446655440015'), -('550e8400-e29b-41d4-a716-446655440047', 'is_admin', 'is_admin', 'BOOLEAN', false, NULL); +('550e8400-e29b-41d4-a716-446655440047', 'is_admin', 'is_admin', 'BOOLEAN', false, NULL), +('550e8400-e29b-41d4-a716-446655440048', 'is_idp_user', 'is idp user', 'BOOLEAN', false, NULL); -- Insert diverse relation definitions INSERT INTO relation_definition (id, name, target_template_identifier, required, to_many) VALUES @@ -154,7 +155,8 @@ INSERT INTO entity_template (id, identifier, name, description) VALUES INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES ('550e8400-e29b-41d4-a716-446655440062', '550e8400-e29b-41d4-a716-446655440045'), -- kind ('550e8400-e29b-41d4-a716-446655440062', '550e8400-e29b-41d4-a716-446655440046'), -- email -('550e8400-e29b-41d4-a716-446655440062', '550e8400-e29b-41d4-a716-446655440047'); -- is_admin +('550e8400-e29b-41d4-a716-446655440062', '550e8400-e29b-41d4-a716-446655440047'), -- is_admin +('550e8400-e29b-41d4-a716-446655440062', '550e8400-e29b-41d4-a716-446655440048'); -- is_idp_user -- Link web-service template (comprehensive web API) INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES From 5d895a87a91f65aff903fc92d15896e9b3d5952f Mon Sep 17 00:00:00 2001 From: rvando12 Date: Fri, 2 Oct 2026 13:57:56 +0200 Subject: [PATCH 06/11] feat(core): Global RBAC - vale issue --- docs/src/concepts/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/concepts/authentication.md b/docs/src/concepts/authentication.md index 4f524db6..ce7a6fa3 100644 --- a/docs/src/concepts/authentication.md +++ b/docs/src/concepts/authentication.md @@ -470,7 +470,7 @@ in this phase. The former `baseline-role: "*"` assignment has been removed; JWT permissions. The authorization request already identifies a principal, action, and resource. The next stage can add configurable -access profiles and resource/action permissions, with profile names stored as data rather than hardcoded role names. +access profiles and resource/action permissions, with profile names stored as data. The full rights matrix can then move to catalog entities and relations without changing the request shape. Until that policy is implemented, the `RBAC` and `ABAC` modes fail closed; use `GLOBAL`. From c71a220d6bfca3bdda82af039128e30e6cb37d3f Mon Sep 17 00:00:00 2001 From: rvando12 Date: Mon, 5 Oct 2026 09:11:48 +0200 Subject: [PATCH 07/11] feat(core): Global RBAC - fix copilot issue --- docs/src/concepts/authentication.md | 64 ++++-------- docs/src/concepts/entity-dynamic-mapping.md | 4 + docs/src/concepts/entity-templates.md | 3 + docs/src/concepts/webhooks.md | 4 + .../contributing/code/exception-handling.md | 5 +- .../authorization/AuthorizationResource.java | 3 + .../port/audit/AuditIdentityProviderPort.java | 15 +++ .../GlobalAuthorizationService.java | 11 ++- .../api/auth/GlobalAuthorizationFilter.java | 36 ++++++- .../api/auth/JitProvisioningFilter.java | 26 ++++- .../adapters/api/auth/RequestAuthorizer.java | 63 ++++++++++++ .../api/auth/UnifiedUserProvider.java | 5 +- .../api/auth/UserIdentityProvider.java | 6 -- .../AuthenticationProperties.java | 22 +++-- .../chains/MockFilterChainConfig.java | 36 +++++-- .../EntityDynamicMappingController.java | 27 ++++- .../controller/EntityTemplateController.java | 16 ++- ...InboundWebhookConfigurationController.java | 17 +++- .../MissingPrincipalIdentifierException.java | 22 +++++ .../api/handler/ApiErrorResponseWriter.java | 38 +++++++ .../api/handler/ApiExceptionHandler.java | 10 ++ .../JwtPrincipalExtractionStrategy.java | 98 ++----------------- ...OAuth2UserPrincipalExtractionStrategy.java | 7 +- .../audit/UserIdentityProviderHolder.java | 14 ++- src/main/resources/application-local.yml | 2 +- src/main/resources/application.yml | 5 +- .../GlobalAuthorizationServiceTest.java | 11 ++- .../principal/PrincipalExtractorTest.java | 53 +++++----- .../auth/GlobalAuthorizationFilterTest.java | 27 ++++- .../api/auth/JitProvisioningFilterTest.java | 41 ++++++++ .../chains/MockFilterChainConfigTest.java | 29 +++++- .../EntityDynamicMappingControllerTest.java | 30 ++++++ .../EntityTemplateControllerTest.java | 35 +++++++ ...WebhookConfigurationAuthorizationTest.java | 78 +++++++++++++++ .../api/handler/ApiExceptionHandlerTest.java | 13 +++ .../audit/CustomRevisionListenerTest.java | 4 +- .../audit/UserIdentityProviderHolderTest.java | 45 ++++----- src/test/resources/application-test.yml | 3 +- 38 files changed, 677 insertions(+), 251 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/audit/AuditIdentityProviderPort.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/RequestAuthorizer.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UserIdentityProvider.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/exception/MissingPrincipalIdentifierException.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiErrorResponseWriter.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java diff --git a/docs/src/concepts/authentication.md b/docs/src/concepts/authentication.md index ce7a6fa3..5831b774 100644 --- a/docs/src/concepts/authentication.md +++ b/docs/src/concepts/authentication.md @@ -196,12 +196,14 @@ app: ``` When a JWT is received, the `PrincipalExtractor` uses these mappings to extract the actual claim values from the token, -ensuring that regardless of IdP differences, your application always receives standardized `PrincipalInfo` objects. +ensuring that regardless of IdP differences, your application always receives standardized `PrincipalInfo` objects. If +the configured identifier claim is missing or blank, IDP-Core rejects the request with HTTP 401 and a standard JSON +error response. Local mock authentication includes the configured identifier claim in its generated token. ### How Claim Mappings Work -While the examples above cover standard configurations, understanding the extraction engine's fallback logic is crucial -when dealing with custom claims or missing data. +The extraction engine uses limited fallbacks for display data. It does not invent or fall back to an identifier because +that value keys provisioning, audit records, and authorization. The extraction strategy doesn't just rename claims; it applies specific fallback rules if a mapped claim is missing from the token: @@ -261,11 +263,11 @@ API clients, automation tools). Identifying them correctly is important for: - Audit logging and security monitoring - Rate limiting and quota management -IDP-Core supports two detection modes: +IDP-Core requires a configured definitive claim for service-account classification: -### Strict Mode (Recommended) +### Definitive Claim -In strict mode, a single **definitive claim** unambiguously identifies M2M tokens: +A single **definitive claim** unambiguously identifies M2M tokens: ```yaml app: @@ -279,7 +281,8 @@ app: ``` The system checks if the token contains `token_type=m2m`. If it does, the principal is classified as a -`SERVICE_ACCOUNT`; otherwise, it's a `HUMAN_USER`. +`SERVICE_ACCOUNT`; otherwise, it follows the human-principal path. The definitive claim is required even when the +legacy `mode` value is configured. **Benefits:** @@ -292,47 +295,16 @@ The system checks if the token contains `token_type=m2m`. If it does, the princi - Your IdP or API gateway must inject the definitive claim - May require custom configuration at the IdP level -### Legacy Mode (Backwards Compatibility) +### Legacy Configuration Compatibility -In legacy mode, the system checks multiple optional claims with OR logic: +Optional claims +such as `service_name`, `client_id`, `azp`, `grant_type`, and `gty` do not +establish a service account because this classification grants broader write +access and bypasses the human access gate. -```yaml -app: - security: - authentication: - service-account-detection: - enabled: true - mode: "legacy" - legacy-fallback-claims: - - "grant_type" - - "service_name" - - "client_id" -``` - -In legacy mode, IDP-Core classifies a token as a `SERVICE_ACCOUNT` when **any** of -the following conditions is true: - -- The claim mapped to `grant_type` or `gty` has the value - `client_credentials`. -- The claim mapped to `service_name` is present. -- The claim mapped to `client_id` or `azp` has the same value as `sub`. - -If none of these conditions is met, the token is classified as a `HUMAN_USER`. - -**Benefits:** - -- Works immediately without IdP reconfiguration -- Maintains backwards compatibility with existing deployments - -**Drawbacks:** - -- Prone to false positives (for example, human user with `client_id` present) -- Less secure - may misclassify humans as services -- Will be deprecated in future versions - -> [!WARNING] -> Migrate from legacy to strict mode is really encouraged for the systems meeting the prerequisites. Legacy mode is only -recommended for existing systems +IDP-Core still accepts the legacy `mode` and `legacy-fallback-claims` +configuration fields so existing deployments can bind their configuration. +These fields do not weaken the definitive-claim requirement. ## Principal Extraction diff --git a/docs/src/concepts/entity-dynamic-mapping.md b/docs/src/concepts/entity-dynamic-mapping.md index b2bd8487..56a2a6b5 100644 --- a/docs/src/concepts/entity-dynamic-mapping.md +++ b/docs/src/concepts/entity-dynamic-mapping.md @@ -7,6 +7,10 @@ description: Understand Dynamic mappings and JSLT expressions. A mapping targets one Entity Template and describes how to derive entity fields from the incoming JSON payload with a JSLT filter and entity projections. +Only catalog administrators can create or update mappings that target the protected `principal` template. This +restriction prevents an ingestion mapping from changing principal privileges. Webhook deliveries that use an already +authorized mapping continue to run through the configured connector security strategy. + ## Entity Dynamic Mapping Fields | Field | Required | Description | diff --git a/docs/src/concepts/entity-templates.md b/docs/src/concepts/entity-templates.md index 77db63d4..fa7cdeff 100644 --- a/docs/src/concepts/entity-templates.md +++ b/docs/src/concepts/entity-templates.md @@ -13,6 +13,9 @@ An Entity Template defines: - **Properties** - Data fields with types and validation rules - **Relations** - Connections to other entity templates +The `principal` template is protected because its entities determine access to IDP-Core. Only catalog administrators can +create it. + ```mermaid classDiagram class EntityTemplate { diff --git a/docs/src/concepts/webhooks.md b/docs/src/concepts/webhooks.md index d1030c8c..318a43ef 100644 --- a/docs/src/concepts/webhooks.md +++ b/docs/src/concepts/webhooks.md @@ -72,6 +72,10 @@ It checks that: This validation keeps the connector configuration aligned with the current data model. +Only catalog administrators can attach a mapping that targets the protected `principal` template to a connector. +This applies to connector updates as well as mapping creation or updates. The restriction protects principal +authorization data; it does not block trusted deliveries through connectors that already have authorized mappings. + ## Security Strategies Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests diff --git a/docs/src/contributing/code/exception-handling.md b/docs/src/contributing/code/exception-handling.md index 72a9aae2..7b0f7f9b 100644 --- a/docs/src/contributing/code/exception-handling.md +++ b/docs/src/contributing/code/exception-handling.md @@ -22,12 +22,11 @@ description: Global exception handling strategy and error response formats for I ## Error Response Format -At API level, always follow the same error response structure: +At API level, always follow the same error response structure, including errors written directly by security filters: ```json { "error": "NOT_FOUND", - "error_description": "Template with ID 'invalid-id' not found", - "timestamp": "2025-11-28T10:30:00Z" + "error_description": "Template with ID 'invalid-id' not found" } ``` diff --git a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationResource.java b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationResource.java index 41d57ffe..fc3a6845 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationResource.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationResource.java @@ -11,6 +11,9 @@ public record AuthorizationResource(@NotBlank String type, @NotNull Optional parentIdentifier) { public static final String INBOUND_WEBHOOK_CONFIGURATION = "inbound_webhook_configuration"; + public static final String ENTITY_DYNAMIC_MAPPING = "entity_dynamic_mapping"; + public static final String ENTITY_TEMPLATE = "entity_template"; + public static final String PRINCIPAL_TEMPLATE_IDENTIFIER = "principal"; public AuthorizationResource { Objects.requireNonNull(type, "type must not be null"); diff --git a/src/main/java/com/decathlon/idp_core/domain/port/audit/AuditIdentityProviderPort.java b/src/main/java/com/decathlon/idp_core/domain/port/audit/AuditIdentityProviderPort.java new file mode 100644 index 00000000..56f6f7cb --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/audit/AuditIdentityProviderPort.java @@ -0,0 +1,15 @@ +package com.decathlon.idp_core.domain.port.audit; + +/// Provides the authenticated identity associated with an audit revision. +public interface AuditIdentityProviderPort { + + /// Returns the identifier recorded for the authenticated user. + /// + /// @return authenticated user identifier + String getAuthId(); + + /// Returns the name associated with the authenticated user. + /// + /// @return authenticated user name + String getName(); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java b/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java index 3db962f4..f3a5c7ac 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java @@ -16,10 +16,11 @@ public class GlobalAuthorizationService { private static final String IS_ADMIN_PROPERTY = "is_admin"; - private static final String PRINCIPAL_TEMPLATE_IDENTIFIER = "principal"; + private static final String PRINCIPAL_TEMPLATE_IDENTIFIER = AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER; private static final String ENTITY_RESOURCE = "entity"; private static final String PRINCIPAL_RESOURCE = "principal"; - private static final String ENTITY_TEMPLATE_RESOURCE = "entity_template"; + private static final String ENTITY_TEMPLATE_RESOURCE = AuthorizationResource.ENTITY_TEMPLATE; + private static final String ENTITY_DYNAMIC_MAPPING_RESOURCE = AuthorizationResource.ENTITY_DYNAMIC_MAPPING; /// Authorizes break-glass principals, catalog administrators, eligible /// service-account operations, and reads. @@ -82,7 +83,11 @@ private boolean isPrincipalManagementWrite(AuthorizationRequest request) { || (ENTITY_RESOURCE.equals(resource.type()) && resource.parentIdentifier() .filter(PRINCIPAL_TEMPLATE_IDENTIFIER::equals).isPresent()) || (ENTITY_TEMPLATE_RESOURCE.equals(resource.type()) - && resource.identifier().filter(PRINCIPAL_TEMPLATE_IDENTIFIER::equals).isPresent()); + && resource.identifier().filter(PRINCIPAL_TEMPLATE_IDENTIFIER::equals).isPresent()) + || ((ENTITY_DYNAMIC_MAPPING_RESOURCE.equals(resource.type()) + || AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION.equals(resource.type())) + && resource.parentIdentifier().filter(PRINCIPAL_TEMPLATE_IDENTIFIER::equals) + .isPresent()); } private boolean isCatalogAdministrator(Entity principalEntity) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java index a239ebfa..c26b2800 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java @@ -1,12 +1,14 @@ package com.decathlon.idp_core.infrastructure.adapters.api.auth; import java.io.IOException; +import java.util.Optional; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; import org.springframework.lang.NonNull; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; @@ -17,8 +19,11 @@ import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.service.authorization.GlobalAuthorizationService; import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; +import com.decathlon.idp_core.infrastructure.adapters.api.exception.MissingPrincipalIdentifierException; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiErrorResponseWriter; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; import lombok.extern.slf4j.Slf4j; @@ -33,6 +38,7 @@ public class GlobalAuthorizationFilter extends OncePerRequestFilter { private final GlobalAuthorizationService authorizationService; private final AuthorizationPolicy authorizationPolicy; private final AuthorizationRequestFactory authorizationRequestFactory; + private final ApiErrorResponseWriter errorResponseWriter; /// Creates a filter backed by the domain authorization service and configured /// policy. @@ -44,15 +50,19 @@ public class GlobalAuthorizationFilter extends OncePerRequestFilter { /// @param authorizationPolicy configured policy for authenticated requests /// @param authorizationRequestFactory maps HTTP input to domain authorization /// context + /// @param errorResponseWriter writes standard JSON errors at the filter + /// boundary public GlobalAuthorizationFilter(PrincipalExtractor principalExtractor, PrincipalProvisioningService provisioningService, GlobalAuthorizationService authorizationService, AuthorizationPolicy authorizationPolicy, - AuthorizationRequestFactory authorizationRequestFactory) { + AuthorizationRequestFactory authorizationRequestFactory, + ApiErrorResponseWriter errorResponseWriter) { this.principalExtractor = principalExtractor; this.provisioningService = provisioningService; this.authorizationService = authorizationService; this.authorizationPolicy = authorizationPolicy; this.authorizationRequestFactory = authorizationRequestFactory; + this.errorResponseWriter = errorResponseWriter; } @Override @@ -70,9 +80,13 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, var principalContext = context instanceof ProvisionedPrincipalContext provisionedContext ? provisionedContext : null; - var principal = principalContext != null - ? principalContext.principal() - : principalExtractor.extractPrincipalInfo(authentication); + Optional principalInfo = principalContext != null + ? Optional.of(principalContext.principal()) + : extractPrincipal(authentication, response); + if (principalInfo.isEmpty()) { + return; + } + PrincipalInfo principal = principalInfo.orElseThrow(); var principalEntity = principalContext != null ? principalContext.principalEntity() : provisioningService.getPrincipal(principal.identifier()); @@ -84,10 +98,22 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, } catch (PrincipalNotAuthorizedException _) { log.warn("Authorization denied for principal {} on {} {}", principal.identifier(), request.getMethod(), request.getRequestURI()); - response.sendError(HttpServletResponse.SC_FORBIDDEN); + errorResponseWriter.write(response, HttpStatus.FORBIDDEN, + "Principal is not authorized to perform this operation"); return; } filterChain.doFilter(request, response); } + + private Optional extractPrincipal(Authentication authentication, + HttpServletResponse response) throws IOException { + try { + return Optional.of(principalExtractor.extractPrincipalInfo(authentication)); + } catch (MissingPrincipalIdentifierException exception) { + log.warn("Authentication rejected: {}", exception.getMessage()); + errorResponseWriter.write(response, HttpStatus.UNAUTHORIZED, exception.getMessage()); + return Optional.empty(); + } + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java index 2bf10989..9d931bc7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java @@ -8,6 +8,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; import org.springframework.http.server.PathContainer; import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; @@ -21,6 +22,8 @@ import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SecurityConfiguration; +import com.decathlon.idp_core.infrastructure.adapters.api.exception.MissingPrincipalIdentifierException; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiErrorResponseWriter; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; import lombok.RequiredArgsConstructor; @@ -54,6 +57,7 @@ public class JitProvisioningFilter extends OncePerRequestFilter { private final PrincipalExtractor principalExtractor; private final PrincipalProvisioningService provisioningService; private final AuthenticationProperties authProperties; + private final ApiErrorResponseWriter errorResponseWriter; private final PathPatternParser patternParser = new PathPatternParser(); @Override @@ -65,25 +69,37 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, if (authentication != null && authentication.isAuthenticated() && !isAnonymous(authentication)) { - provisionPrincipalSafely(request, authentication); + if (!provisionPrincipalSafely(request, response, authentication)) { + return; + } } filterChain.doFilter(request, response); } - private void provisionPrincipalSafely(HttpServletRequest request, Authentication authentication) { + private boolean provisionPrincipalSafely(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + PrincipalInfo principalInfo; + try { + principalInfo = principalExtractor.extractPrincipalInfo(authentication); + } catch (MissingPrincipalIdentifierException exception) { + log.warn("Authentication rejected: {}", exception.getMessage()); + errorResponseWriter.write(response, HttpStatus.UNAUTHORIZED, exception.getMessage()); + return false; + } + try { - PrincipalInfo principalInfo = principalExtractor.extractPrincipalInfo(authentication); Entity principalEntity = provisioningService.provisionPrincipal(principalInfo); request.setAttribute(ProvisionedPrincipalContext.REQUEST_ATTRIBUTE, new ProvisionedPrincipalContext(principalInfo, Optional.of(principalEntity))); log.debug("JIT provisioning successful for principal: {} (kind: {})", principalInfo.identifier(), principalInfo.kind()); + return true; } catch (Exception e) { - // Log error but don't block the request - fail-open for availability - // The principal may not have a catalog entry, but can still authenticate + // Provisioning failures remain fail-open; extraction failures propagate. log.warn("JIT provisioning failed for authenticated principal: {}", e.getMessage(), e); + return true; } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/RequestAuthorizer.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/RequestAuthorizer.java new file mode 100644 index 00000000..2a39fa3e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/RequestAuthorizer.java @@ -0,0 +1,63 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationPolicy; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationRequest; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; +import com.decathlon.idp_core.domain.service.authorization.GlobalAuthorizationService; +import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; +import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; + +import lombok.RequiredArgsConstructor; + +/// Reapplies global authorization after a controller has resolved body-only resource context. +@Component +@RequiredArgsConstructor +public class RequestAuthorizer { + + private final PrincipalExtractor principalExtractor; + private final PrincipalProvisioningService provisioningService; + private final GlobalAuthorizationService authorizationService; + private final AuthorizationPolicy authorizationPolicy; + + /// Authorizes a sensitive operation using its fully resolved resource context. + /// + /// @param request current HTTP request + /// @param action operation being performed + /// @param resource resource context resolved from the request body + public void authorize(HttpServletRequest request, AuthorizationAction action, + AuthorizationResource resource) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + throw new PrincipalNotAuthorizedException("unknown"); + } + + Object context = request.getAttribute(ProvisionedPrincipalContext.REQUEST_ATTRIBUTE); + ProvisionedPrincipalContext provisionedContext = context instanceof ProvisionedPrincipalContext value + ? value + : null; + PrincipalInfo principal = provisionedContext != null + ? provisionedContext.principal() + : principalExtractor.extractPrincipalInfo(authentication); + Optional principalEntity = provisionedContext != null + ? provisionedContext.principalEntity() + : provisioningService.getPrincipal(principal.identifier()); + + authorizationService.authorize( + new AuthorizationRequest(principal, principalEntity, action, resource), + authorizationPolicy); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java index 79ea3477..9b6cd594 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UnifiedUserProvider.java @@ -5,17 +5,18 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import com.decathlon.idp_core.domain.port.audit.AuditIdentityProviderPort; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; import lombok.RequiredArgsConstructor; -/// UnifiedUserProvider is a Spring component that implements the UserIdentityProvider interface to provide a consistent way +/// UnifiedUserProvider is a Spring component that implements the AuditIdentityProviderPort interface to provide a consistent way /// to retrieve the authenticated user's identity across different authentication mechanisms (JWT, OAuth2, OpenID). /// The shared principal extractor keeps audit identities aligned with the identifiers used for provisioning /// and authorization. @Component @RequiredArgsConstructor -public class UnifiedUserProvider implements UserIdentityProvider { +public class UnifiedUserProvider implements AuditIdentityProviderPort { private final PrincipalExtractor principalExtractor; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UserIdentityProvider.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UserIdentityProvider.java deleted file mode 100644 index 3ec29a3b..00000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/UserIdentityProvider.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.auth; - -public interface UserIdentityProvider { - String getAuthId(); - String getName(); -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationProperties.java index 31060c89..682feefe 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthenticationProperties.java @@ -28,14 +28,14 @@ * identifier for human principals. Service accounts continue to use their * client identifier. *

      - *

      Service Account Detection

      Service accounts (M2M tokens) can be - * identified in multiple ways depending on the IdP: + *

      Service Account Detection

      Service accounts (M2M tokens) require one + * configured definitive claim: *
        - *
      • Strict Mode (Recommended): Single definitive claim - * (e.g., {@code token_type=m2m}, {@code client_credentials})
      • - *
      • Legacy Mode: Multiple fallback claims (for backwards - * compatibility with existing deployments)
      • + *
      • {@code token_type=m2m}
      • + *
      • {@code account_type=service}
      • *
      + * Legacy detection properties remain bindable for configuration compatibility + * but do not classify a token as a service account. * * @see SecurityConfiguration * @see PrincipalExtractor @@ -66,16 +66,18 @@ public record ServiceAccountDetection( // Enables or disables service account detection. boolean enabled, - // "strict" checks one claim; "legacy" checks the configured fallbacks. + // Retained for configuration compatibility; detection always requires the + // definitive claim. String mode, - // Claim checked in strict mode. + // Claim required to identify a service account. String definitiveClaimName, - // Value that identifies a service account in strict mode. + // Value that identifies a service account. String definitiveClaimValue, - // Claims checked as fallbacks in legacy mode. + // Retained for configuration compatibility; fallback claims do not classify + // service accounts. List legacyFallbackClaims) { } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java index 00889d9d..b73a89b6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.time.Instant; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -33,6 +34,7 @@ import com.decathlon.idp_core.domain.exception.mock.MockSecurityConfigurationException; import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; /// Local mock security configuration that mirrors OAuth2/JWT behavior for local development. /// @@ -53,11 +55,14 @@ public class MockFilterChainConfig { private final JitProvisioningFilter jitProvisioningFilter; private final GlobalAuthorizationFilter globalAuthorizationFilter; + private final AuthenticationProperties authenticationProperties; public MockFilterChainConfig(JitProvisioningFilter jitProvisioningFilter, - GlobalAuthorizationFilter globalAuthorizationFilter) { + GlobalAuthorizationFilter globalAuthorizationFilter, + AuthenticationProperties authenticationProperties) { this.jitProvisioningFilter = jitProvisioningFilter; this.globalAuthorizationFilter = globalAuthorizationFilter; + this.authenticationProperties = authenticationProperties; } /// Security filter chain for local mocking with JWT-like behavior. @@ -83,7 +88,9 @@ public SecurityFilterChain mockSecurityFilterChain(HttpSecurity http) { // Everything reaching here must be authenticated via our mock filter .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) // 1. Inject the fake JWT token into the SecurityContext - .addFilterBefore(new MockJwtAuthenticationFilter(), AnonymousAuthenticationFilter.class) + .addFilterBefore( + new MockJwtAuthenticationFilter(authenticationProperties.principalIdentifierClaim()), + AnonymousAuthenticationFilter.class) // 2. Trigger JIT provisioning based on the fake token (tests production // behavior locally) .addFilterAfter(jitProvisioningFilter, MockJwtAuthenticationFilter.class) @@ -108,6 +115,16 @@ public SecurityFilterChain mockSecurityFilterChain(HttpSecurity http) { /// making it available to all downstream components (controllers, services, /// etc.) static class MockJwtAuthenticationFilter extends OncePerRequestFilter { + private final String principalIdentifierClaim; + + MockJwtAuthenticationFilter() { + this("sub"); + } + + MockJwtAuthenticationFilter(String principalIdentifierClaim) { + this.principalIdentifierClaim = principalIdentifierClaim; + } + @Override protected void doFilterInternal(@Nonnull HttpServletRequest request, @Nonnull HttpServletResponse response, @Nonnull FilterChain filterChain) @@ -143,10 +160,17 @@ private Jwt createMockJwt() { Instant expiresAt = now.plusSeconds(3600); Map headers = Map.of("alg", "RS256", "typ", "JWT"); - Map claims = Map.of("sub", "local-developer", "preferred_username", - "local-developer", "name", "Local Developer", "client_id", "client-id", "scope", - "auth read write", "iat", now.getEpochSecond(), "exp", expiresAt.getEpochSecond(), - "email", "developer@local.dev", "user_id", "dev-user-001"); + Map claims = new HashMap<>(); + claims.put("sub", "local-developer"); + claims.put("preferred_username", "local-developer"); + claims.put("name", "Local Developer"); + claims.put("client_id", "client-id"); + claims.put("scope", "auth read write"); + claims.put("iat", now.getEpochSecond()); + claims.put("exp", expiresAt.getEpochSecond()); + claims.put("email", "developer@local.dev"); + claims.put("user_id", "dev-user-001"); + claims.put(principalIdentifierClaim, "local-developer"); return new Jwt("mock-token-value", now, expiresAt, headers, claims); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java index 07a150ab..839177a6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java @@ -3,6 +3,9 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; import static org.springframework.http.HttpStatus.*; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.data.domain.Page; @@ -10,11 +13,14 @@ import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.*; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; import com.decathlon.idp_core.domain.model.entity_mapping.DryRunResult; import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingDryRunService; import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingService; import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.RequestAuthorizer; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDynamicMappingDryRunDtoIn; @@ -47,6 +53,7 @@ public class EntityDynamicMappingController { private final EntityDynamicMappingDryRunService dynamicMappingDryRunService; private final EntityDynamicMappingDryRunDtoOutMapper entityDynamicMappingDryRunDtoOutMapper; private final EntityDynamicMappingDryRunDtoInMapper entityDynamicMappingDryRunDtoInMapper; + private final RequestAuthorizer requestAuthorizer; @Operation(summary = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_SUMMARY, description = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DESCRIPTION) @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_CREATED) @@ -55,7 +62,11 @@ public class EntityDynamicMappingController { @PostMapping @ResponseStatus(CREATED) public EntityDynamicMappingDtoOut createDynamicMapping( - @Valid @RequestBody EntityDynamicMappingCreateDtoIn inboundWebhookMappingDtoIn) { + @Valid @RequestBody EntityDynamicMappingCreateDtoIn inboundWebhookMappingDtoIn, + HttpServletRequest request) { + authorizePrincipalMapping(request, AuthorizationAction.CREATE, + inboundWebhookMappingDtoIn.identifier(), + inboundWebhookMappingDtoIn.entityTemplateIdentifier()); EntityDynamicMapping entityDynamicMapping = dynamicMappingService .createEntityDynamicMapping(dynamicMappingMapper.toDomain(inboundWebhookMappingDtoIn)); return dynamicMappingMapper.fromEntityMappingToDto(entityDynamicMapping); @@ -112,12 +123,24 @@ public EntityDynamicMappingDtoOut getEntityDynamicMappingByIdentifier( @PutMapping("/{identifier}") @ResponseStatus(OK) public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable String identifier, - @Valid @RequestBody EntityDynamicMappingUpdateDtoIn entityDynamicMappingDtoIn) { + @Valid @RequestBody EntityDynamicMappingUpdateDtoIn entityDynamicMappingDtoIn, + HttpServletRequest request) { + authorizePrincipalMapping(request, AuthorizationAction.UPDATE, identifier, + entityDynamicMappingDtoIn.entityTemplateIdentifier()); return dynamicMappingMapper .fromEntityMappingToDto(dynamicMappingService.updateEntityDynamicMapping(identifier, dynamicMappingMapper.toDomainForUpdate(identifier, entityDynamicMappingDtoIn))); } + private void authorizePrincipalMapping(HttpServletRequest request, AuthorizationAction action, + String mappingIdentifier, String targetTemplateIdentifier) { + if (AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER.equals(targetTemplateIdentifier)) { + requestAuthorizer.authorize(request, action, + new AuthorizationResource(AuthorizationResource.ENTITY_DYNAMIC_MAPPING, + Optional.of(mappingIdentifier), Optional.of(targetTemplateIdentifier))); + } + } + @Operation(summary = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DRY_RUN_SUMMARY, description = ENDPOINT_POST_ENTITY_DYNAMIC_MAPPING_DRY_RUN_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_DYNAMIC_MAPPING_DRY_RUN_SUCCESS, content = { @Content(schema = @Schema(implementation = EntityDynamicMappingDryRunDtoOut.class))}) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java index 5f10f854..e383d204 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java @@ -29,6 +29,9 @@ import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.http.HttpStatus.OK; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.data.domain.Page; @@ -44,9 +47,12 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.RequestAuthorizer; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.TemplatePageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityTemplateUpdateDtoIn; @@ -90,6 +96,7 @@ public class EntityTemplateController { private final EntityTemplateService entityTemplateService; private final EntityTemplateMapper templateMapper; + private final RequestAuthorizer requestAuthorizer; /// Retrieves paginated entity templates for administrative interfaces. /// @@ -143,7 +150,14 @@ public EntityTemplateDtoOut getTemplateByIdentifier(@PathVariable String identif @PostMapping @ResponseStatus(CREATED) public EntityTemplateDtoOut createTemplate( - @Valid @RequestBody EntityTemplateCreateDtoIn entityTemplateCreateDtoIn) { + @Valid @RequestBody EntityTemplateCreateDtoIn entityTemplateCreateDtoIn, + HttpServletRequest request) { + if (AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER + .equals(entityTemplateCreateDtoIn.getIdentifier())) { + requestAuthorizer.authorize(request, AuthorizationAction.CREATE, + new AuthorizationResource(AuthorizationResource.ENTITY_TEMPLATE, + Optional.of(entityTemplateCreateDtoIn.getIdentifier()), Optional.empty())); + } EntityTemplate entityTemplate = entityTemplateService .createEntityTemplate(templateMapper.fromDtoToEntityTemplate(entityTemplateCreateDtoIn)); return templateMapper.fromEntityTemplatetoDto(entityTemplate); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java index 92f064df..652121d9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java @@ -3,6 +3,9 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; import static org.springframework.http.HttpStatus.*; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.data.domain.Page; @@ -10,10 +13,12 @@ import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.*; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.RequestAuthorizer; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn; @@ -40,6 +45,7 @@ public class InboundWebhookConfigurationController { private final WebhookConnectorService webhookConnectorService; private final InboundWebhookMapper inboundWebhookMapper; + private final RequestAuthorizer requestAuthorizer; /// Creates a new inbound webhook connector configuration. /// @@ -108,9 +114,18 @@ public InboundWebhookDtoOut getWebhookConnectorByIdentifier(@PathVariable String @PutMapping("/{identifier}") @ResponseStatus(OK) public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier, - @Valid @RequestBody InboundWebhookUpdateDtoIn request) { + @Valid @RequestBody InboundWebhookUpdateDtoIn request, HttpServletRequest httpRequest) { var resolvedMappings = webhookConnectorService .resolveAndValidateMappings(request.mappingIdentifiers()); + boolean principalTargetMapping = resolvedMappings.stream() + .anyMatch(mapping -> AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER + .equals(mapping.entityTemplateIdentifier())); + if (principalTargetMapping) { + requestAuthorizer.authorize(httpRequest, AuthorizationAction.UPDATE, + new AuthorizationResource(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION, + Optional.of(identifier), + Optional.of(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER))); + } return inboundWebhookMapper .fromWebhookConnectorToDto(webhookConnectorService.updateWebhookConnector(identifier, inboundWebhookMapper.toDomainForUpdate(identifier, request, resolvedMappings))); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/exception/MissingPrincipalIdentifierException.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/exception/MissingPrincipalIdentifierException.java new file mode 100644 index 00000000..5200e2bc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/exception/MissingPrincipalIdentifierException.java @@ -0,0 +1,22 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.exception; + +/// Indicates that an authenticated principal is missing its configured identifier claim. +public class MissingPrincipalIdentifierException extends RuntimeException { + + private final String claimName; + + /// Creates a failure for a missing or blank principal identifier. + /// + /// @param claimName configured claim expected to identify the principal + public MissingPrincipalIdentifierException(String claimName) { + super("Required principal identifier claim '" + claimName + "' is missing or blank"); + this.claimName = claimName; + } + + /// Returns the configured claim that was missing or blank. + /// + /// @return principal identifier claim name + public String getClaimName() { + return claimName; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiErrorResponseWriter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiErrorResponseWriter.java new file mode 100644 index 00000000..5d9d061d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiErrorResponseWriter.java @@ -0,0 +1,38 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.handler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +/// Writes the standard API error body from servlet filters. +@Component +@RequiredArgsConstructor +public class ApiErrorResponseWriter { + + private final ObjectMapper objectMapper; + + /// Writes an error status and the standard JSON error response. + /// + /// @param response servlet response to populate + /// @param status HTTP status for the error + /// @param description human-readable explanation of the error + /// @throws IOException if the response body cannot be written + public void write(HttpServletResponse response, HttpStatus status, String description) + throws IOException { + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getOutputStream(), + new ErrorResponse(status.name(), description)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 685c0060..54c189a6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -32,6 +32,7 @@ import com.decathlon.idp_core.domain.exception.principal.PrincipalNotFoundException; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; import com.decathlon.idp_core.domain.exception.webhook.*; +import com.decathlon.idp_core.infrastructure.adapters.api.exception.MissingPrincipalIdentifierException; import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; import lombok.extern.slf4j.Slf4j; @@ -63,6 +64,15 @@ public class ApiExceptionHandler { private ApiExceptionHandler() { } + /// Rejects authenticated principals that do not provide their configured + /// identifier claim. + @ExceptionHandler(MissingPrincipalIdentifierException.class) + public ResponseEntity handleMissingPrincipalIdentifierException( + MissingPrincipalIdentifierException ex) { + log.warn("Authentication rejected: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + /// Handles domain exception when entity templates are not found. /// /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/JwtPrincipalExtractionStrategy.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/JwtPrincipalExtractionStrategy.java index fafda5fb..ddeb2a8b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/JwtPrincipalExtractionStrategy.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/JwtPrincipalExtractionStrategy.java @@ -1,11 +1,8 @@ package com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies; -import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.CLIENT_CREDENTIALS; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.CLIENT_ID; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.EMAIL; -import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.GRANT_TYPE; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.GROUPS; -import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.GTY; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.NAME; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.ORIGIN; import static com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.PrincipalStrategiesConstants.PREFERRED_USERNAME; @@ -25,6 +22,7 @@ import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.model.principal.PrincipalKind; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.exception.MissingPrincipalIdentifierException; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractionStrategy; @Component @@ -47,7 +45,7 @@ public PrincipalInfo extract(Authentication authentication) { var claims = jwtToken.getToken().getClaims(); String sub = jwtToken.getToken().getSubject(); - boolean isServiceAccount = detectServiceAccount(claims, sub); + boolean isServiceAccount = detectServiceAccount(claims); if (isServiceAccount) { return extractServiceAccountFromJwt(sub, claims); @@ -55,29 +53,14 @@ public PrincipalInfo extract(Authentication authentication) { return extractHumanFromJwt(sub, claims); } - /// Detects if the JWT token belongs to a service account (M2M) based on - /// configured strategy. - /// - /// **Strict Mode (Recommended):** - /// Checks for a single, definitive claim configured in - /// `app.security.authentication.service-account-detection.definitive-claim-name`. - /// Example: If configured to check `token_type=m2m`, returns true only if that - /// exact claim is present and has that value. - /// Benefits: Prevents false positives and security-critical misclassifications. - /// Risks: Requires IdP or API gateway to inject the definitive claim. - /// - /// **Legacy Mode (Backwards Compatibility):** - /// Falls back to loose OR conditions checking multiple optional claims. - /// Checks: `grant_type=client_credentials` OR `service_name` exists OR - /// `sub==client_id` - /// Benefits: Works without reconfiguration for existing deployments. - /// Risks: Prone to false positives (e.g., human user classified as M2M). + /// Detects if the JWT token belongs to a service account (M2M) using the + /// configured definitive claim. Fallback claims cannot establish a service + /// account because this classification bypasses human authorization checks. /// /// @param claims the JWT claims map - /// @param sub the subject claim value /// @return true if the token is identified as a service account, false /// otherwise - private boolean detectServiceAccount(Map claims, String sub) { + private boolean detectServiceAccount(Map claims) { AuthenticationProperties.ServiceAccountDetection config = authProperties .serviceAccountDetection(); @@ -85,12 +68,7 @@ private boolean detectServiceAccount(Map claims, String sub) { return false; } - if ("strict".equalsIgnoreCase(config.mode())) { - return detectServiceAccountStrict(claims, config); - } - - // Legacy mode (default for backwards compatibility) - return detectServiceAccountLegacy(claims, sub); + return detectServiceAccountStrict(claims, config); } /// Strict mode service account detection: checks for a single definitive claim. @@ -109,64 +87,6 @@ private boolean detectServiceAccountStrict(Map claims, return config.definitiveClaimValue().equals(claimValue.toString()); } - /// Legacy mode service account detection: checks multiple claims with OR logic. - /// Fallback behavior for backwards compatibility. - /// - /// Checks: - /// 1. grant_type or gty = "client_credentials" (OAuth2 standard) - /// 2. service_name claim exists (Auth0 / custom IdP) - /// 3. sub equals client_id or azp (subject is the OAuth2 client itself) - /// - /// **Warning:** This logic is prone to false positives. Strongly recommend - /// upgrading - /// to strict mode for production deployments. - /// - /// @param claims the JWT claims map - /// @param sub the subject claim value - /// @return true if any legacy condition indicates M2M - private boolean detectServiceAccountLegacy(Map claims, String sub) { - AuthenticationProperties.ServiceAccountDetection config = authProperties - .serviceAccountDetection(); - - List fallbackClaims = config.legacyFallbackClaims(); - - if (fallbackClaims.contains(GRANT_TYPE)) { - String grantTypeClaim = authProperties.userClaimMappings().get(GRANT_TYPE); - String gtyClaim = authProperties.userClaimMappings().get(GTY); - - String grantType = Optional.ofNullable(claims.get(grantTypeClaim)) - .or(() -> Optional.ofNullable(gtyClaim) - .flatMap(claim -> Optional.ofNullable(claims.get(claim)))) - .map(Object::toString).orElse(null); - - if (CLIENT_CREDENTIALS.equals(grantType)) { - return true; - } - } - - if (fallbackClaims.contains(SERVICE_NAME)) { - String serviceNameClaim = authProperties.userClaimMappings().get(SERVICE_NAME); - if (serviceNameClaim != null && claims.containsKey(serviceNameClaim)) { - return true; - } - } - - if (fallbackClaims.contains(CLIENT_ID)) { - String clientIdClaim = authProperties.userClaimMappings().get(CLIENT_ID); - String azpClaim = authProperties.userClaimMappings().get(AZP); - - String clientId = Optional.ofNullable(clientIdClaim) - .flatMap(claim -> Optional.ofNullable(claims.get(claim))) - .or(() -> Optional.ofNullable(azpClaim) - .flatMap(claim -> Optional.ofNullable(claims.get(claim)))) - .map(Object::toString).orElse(null); - - return clientId != null && clientId.equals(sub); - } - - return false; - } - private PrincipalInfo extractHumanFromJwt(String sub, Map claims) { Map claimMappings = authProperties.userClaimMappings(); @@ -176,8 +96,8 @@ private PrincipalInfo extractHumanFromJwt(String sub, Map claims identifierValue = sub; } String identifier = Optional.ofNullable(identifierValue).map(Object::toString) - .filter(value -> !value.isBlank()).orElseThrow(() -> new IllegalArgumentException( - "JWT principal identifier claim '" + identifierClaim + "' is required")); + .filter(value -> !value.isBlank()) + .orElseThrow(() -> new MissingPrincipalIdentifierException(identifierClaim)); String preferredUsernameClaim = claimMappings.get(PREFERRED_USERNAME); Optional preferredUsername = Optional.ofNullable(preferredUsernameClaim) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/OAuth2UserPrincipalExtractionStrategy.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/OAuth2UserPrincipalExtractionStrategy.java index 44989273..b88e2ff3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/OAuth2UserPrincipalExtractionStrategy.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/principal/strategies/OAuth2UserPrincipalExtractionStrategy.java @@ -18,6 +18,7 @@ import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; import com.decathlon.idp_core.domain.model.principal.PrincipalKind; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.exception.MissingPrincipalIdentifierException; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractionStrategy; /// Strategy for extracting principal information from OAuth2 and OpenID Connect (OIDC) users. @@ -65,8 +66,7 @@ private PrincipalInfo extractFromOAuth2User(OAuth2User oauth2User) { String identifierClaim = authProperties.principalIdentifierClaim(); String identifier = Optional.ofNullable(oauth2User.getAttribute(identifierClaim)) .map(String::valueOf).filter(value -> !value.isBlank()) - .orElseThrow(() -> new IllegalArgumentException( - "OAuth2 principal identifier claim '" + identifierClaim + "' is required")); + .orElseThrow(() -> new MissingPrincipalIdentifierException(identifierClaim)); String preferredUsernameClaim = claimMappings.getOrDefault(PREFERRED_USERNAME, PREFERRED_USERNAME); @@ -99,8 +99,7 @@ private PrincipalInfo extractFromOidcUser(OidcUser oidcUser) { String identifierClaim = authProperties.principalIdentifierClaim(); String identifier = Optional.ofNullable(oidcUser.getAttribute(identifierClaim)) .map(String::valueOf).filter(value -> !value.isBlank()) - .orElseThrow(() -> new IllegalArgumentException( - "OIDC principal identifier claim '" + identifierClaim + "' is required")); + .orElseThrow(() -> new MissingPrincipalIdentifierException(identifierClaim)); String name = Optional.ofNullable(oidcUser.getFullName()) .or(() -> Optional.ofNullable(oidcUser.getGivenName())) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java index e1ef17b2..7e47cbb1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolder.java @@ -4,20 +4,18 @@ import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import com.decathlon.idp_core.infrastructure.adapters.api.auth.UserIdentityProvider; +import com.decathlon.idp_core.domain.port.audit.AuditIdentityProviderPort; @Component public class UserIdentityProviderHolder { - private static final AtomicReference userIdentityProvider = new AtomicReference<>(); + private static final AtomicReference userIdentityProvider = new AtomicReference<>(); - private final UserIdentityProvider injectedProvider; + private final AuditIdentityProviderPort injectedProvider; - @Autowired - UserIdentityProviderHolder(final UserIdentityProvider injectedProvider) { + UserIdentityProviderHolder(final AuditIdentityProviderPort injectedProvider) { this.injectedProvider = injectedProvider; } @@ -27,8 +25,8 @@ public class UserIdentityProviderHolder { /// initialized, which should not happen in normal operation. /// This design allows us to bridge the gap between Spring-managed beans and /// Hibernate's non-Spring-managed listeners. - public static UserIdentityProvider getUserIdentityProvider() { - UserIdentityProvider provider = userIdentityProvider.get(); + public static AuditIdentityProviderPort getUserIdentityProvider() { + AuditIdentityProviderPort provider = userIdentityProvider.get(); if (provider == null) { throw new IllegalStateException( "UserIdentityProviderHolder not initialized. Spring context may not be loaded."); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 3c395861..817afa1c 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -48,7 +48,7 @@ app: enabled: true mock: enabled: false - principal-identifier-claim: id + principal-identifier-claim: ${IDP_PRINCIPAL_IDENTIFIER_CLAIM:sub} user-claim-mappings: preferred_username: login name: name diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 924dc99a..fd6f69a3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -155,12 +155,9 @@ app: # See docs/src/concepts/authentication.md - Service Account Detection section service-account-detection: enabled: true - mode: legacy + mode: strict definitive-claim-name: token_type definitive-claim-value: m2m - # Simplified list binding. Overriding via env var requires a comma-separated string: - # export APP_SECURITY_AUTHENTICATION_SERVICE_ACCOUNT_DETECTION_LEGACY_FALLBACK_CLAIMS="grant_type,service_name,client_id" - legacy-fallback-claims: grant_type,service_name,client_id # JIT Provisioning excluded paths # Simplified list binding. Overriding via env var requires a comma-separated string: # export APP_SECURITY_AUTHENTICATION_JIT_PROVISIONING_EXCLUDED_PATHS="/,/actuator/**,/swagger-ui/**,/swagger-ui.html,/v3/api-docs/**" diff --git a/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java index 1fb7ad6a..b2307732 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java @@ -101,9 +101,16 @@ void shouldAllowServiceAccountToCreateOtherCatalogResources() { @Test void shouldRejectServiceAccountWritesToPrincipalRecordsAndTemplate() { List protectedResources = List.of( - new AuthorizationResource("entity", Optional.of("subject-123"), Optional.of("principal")), + new AuthorizationResource("entity", Optional.of("subject-123"), + Optional.of(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER)), new AuthorizationResource("principal", Optional.of("subject-123"), Optional.empty()), - new AuthorizationResource("entity_template", Optional.of("principal"), Optional.empty())); + new AuthorizationResource("entity_template", + Optional.of(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER), Optional.empty()), + new AuthorizationResource("entity_dynamic_mapping", Optional.of("principal-mapping"), + Optional.of(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER)), + new AuthorizationResource(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION, + Optional.of("webhook"), + Optional.of(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER))); for (AuthorizationResource resource : protectedResources) { for (AuthorizationAction action : List.of(AuthorizationAction.CREATE, diff --git a/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalExtractorTest.java b/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalExtractorTest.java index e5f61d4a..9fb6e93d 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalExtractorTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/principal/PrincipalExtractorTest.java @@ -28,6 +28,7 @@ import com.decathlon.idp_core.domain.model.principal.PrincipalKind; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties.ServiceAccountDetection; +import com.decathlon.idp_core.infrastructure.adapters.api.exception.MissingPrincipalIdentifierException; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractionStrategy; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; import com.decathlon.idp_core.infrastructure.adapters.api.principal.strategies.JwtPrincipalExtractionStrategy; @@ -377,9 +378,9 @@ void shouldFilterNonStringValuesFromGroups() { } @Test - @DisplayName("Should detect service account with legacy mode - grant_type client_credentials") - void shouldDetectServiceAccountLegacyGrantType() { - // Given: JWT with grant_type=client_credentials (legacy mode enabled) + @DisplayName("Should require the definitive claim despite grant_type=client_credentials") + void shouldNotDetectServiceAccountFromGrantTypeAlone() { + // Given: JWT with grant_type=client_credentials but no definitive claim Map claims = new HashMap<>(); claims.put("client_id", "my-service"); claims.put("name", "My Service"); @@ -394,16 +395,16 @@ void shouldDetectServiceAccountLegacyGrantType() { // When: Extract principal PrincipalInfo principal = jwtStrategy.extract(auth); - // Then: Detected as service account - assertThat(principal.kind()).isEqualTo(PrincipalKind.SERVICE_ACCOUNT); + // Then: Classified as human because the definitive claim is absent + assertThat(principal.kind()).isEqualTo(PrincipalKind.HUMAN); assertThat(principal.identifier()).isEqualTo("my-service"); assertThat(principal.name()).isEqualTo("My Service"); } @Test - @DisplayName("Should detect service account with gty claim") - void shouldDetectServiceAccountWithGtyClaim() { - // Given: JWT with gty=client_credentials + @DisplayName("Should not detect service account from gty alone") + void shouldNotDetectServiceAccountFromGtyAlone() { + // Given: JWT with gty=client_credentials but no definitive claim Map claims = new HashMap<>(); claims.put("client_id", "my-service"); claims.put("gty", "client_credentials"); @@ -417,20 +418,19 @@ void shouldDetectServiceAccountWithGtyClaim() { // When: Extract principal PrincipalInfo principal = jwtStrategy.extract(auth); - // Then: Detected as service account - assertThat(principal.kind()).isEqualTo(PrincipalKind.SERVICE_ACCOUNT); + // Then: Classified as human because the definitive claim is absent + assertThat(principal.kind()).isEqualTo(PrincipalKind.HUMAN); } @Test - @DisplayName("Should detect service account with service_name claim") - void shouldDetectServiceAccountWithServiceNameClaim() { - // Given: JWT with service_name claim + @DisplayName("Should classify a token with service_name but no definitive claim as human") + void shouldTreatServiceNameWithoutDefinitiveClaimAsHuman() { + // Given: Human JWT with service_name but no definitive machine-account claim Map claims = new HashMap<>(); claims.put("service_name", "my-service"); - claims.put("client_id", "my-service"); var jwtToken = mock(org.springframework.security.oauth2.jwt.Jwt.class); - when(jwtToken.getSubject()).thenReturn("my-service"); + when(jwtToken.getSubject()).thenReturn("user-123"); when(jwtToken.getClaims()).thenReturn(claims); var auth = new JwtAuthenticationToken(jwtToken); @@ -438,14 +438,15 @@ void shouldDetectServiceAccountWithServiceNameClaim() { // When: Extract principal PrincipalInfo principal = jwtStrategy.extract(auth); - // Then: Detected as service account - assertThat(principal.kind()).isEqualTo(PrincipalKind.SERVICE_ACCOUNT); + // Then: Classified as human and keeps the human subject identifier + assertThat(principal.kind()).isEqualTo(PrincipalKind.HUMAN); + assertThat(principal.identifier()).isEqualTo("user-123"); } @Test - @DisplayName("Should detect service account when sub equals client_id") - void shouldDetectServiceAccountWhenSubEqualsClientId() { - // Given: JWT where sub equals client_id + @DisplayName("Should not detect service account when sub equals client_id alone") + void shouldNotDetectServiceAccountWhenSubEqualsClientIdAlone() { + // Given: JWT where sub equals client_id but no definitive claim Map claims = new HashMap<>(); claims.put("client_id", "my-service"); @@ -458,8 +459,8 @@ void shouldDetectServiceAccountWhenSubEqualsClientId() { // When: Extract principal PrincipalInfo principal = jwtStrategy.extract(auth); - // Then: Detected as service account - assertThat(principal.kind()).isEqualTo(PrincipalKind.SERVICE_ACCOUNT); + // Then: Classified as human because the definitive claim is absent + assertThat(principal.kind()).isEqualTo(PrincipalKind.HUMAN); } @Test @@ -467,6 +468,7 @@ void shouldDetectServiceAccountWhenSubEqualsClientId() { void shouldExtractServiceAccountWithClientIdFallbackToAzp() { // Given: JWT with azp instead of client_id Map claims = new HashMap<>(); + claims.put("token_type", "m2m"); claims.put("azp", "authorized-party"); claims.put("grant_type", "client_credentials"); @@ -489,6 +491,7 @@ void shouldExtractServiceAccountWithClientIdFallbackToAzp() { void shouldExtractServiceAccountWithServiceNameFallbackToName() { // Given: JWT with name instead of service_name Map claims = new HashMap<>(); + claims.put("token_type", "m2m"); claims.put("client_id", "my-service"); claims.put("name", "My Service Name"); claims.put("grant_type", "client_credentials"); @@ -511,6 +514,7 @@ void shouldExtractServiceAccountWithServiceNameFallbackToName() { void shouldIncludeOriginInServiceAccountAttributes() { // Given: JWT with origin claim for service account Map claims = new HashMap<>(); + claims.put("token_type", "m2m"); claims.put("client_id", "my-service"); claims.put("origin", "https://origin.example.com"); claims.put("grant_type", "client_credentials"); @@ -533,6 +537,7 @@ void shouldIncludeOriginInServiceAccountAttributes() { void shouldExtractServiceAccountGroups() { // Given: Service account JWT with groups Map claims = new HashMap<>(); + claims.put("token_type", "m2m"); claims.put("client_id", "my-service"); claims.put("grant_type", "client_credentials"); claims.put("groups", List.of("service-admins", "integrations")); @@ -751,8 +756,8 @@ void shouldRequireStableSubjectWhenSubNotPresent() { when(auth.getPrincipal()).thenReturn(oauth2User); assertThatThrownBy(() -> oauth2Strategy.extract(auth)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("principal identifier claim 'sub' is required"); + .isInstanceOf(MissingPrincipalIdentifierException.class) + .hasMessageContaining("principal identifier claim 'sub' is missing or blank"); } @Test diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java index 823490c0..08dada70 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java @@ -40,7 +40,10 @@ import com.decathlon.idp_core.domain.model.principal.PrincipalKind; import com.decathlon.idp_core.domain.service.authorization.GlobalAuthorizationService; import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; +import com.decathlon.idp_core.infrastructure.adapters.api.exception.MissingPrincipalIdentifierException; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiErrorResponseWriter; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; +import com.fasterxml.jackson.databind.ObjectMapper; class GlobalAuthorizationFilterTest { @@ -53,6 +56,8 @@ class GlobalAuthorizationFilterTest { "Platform User", Map.of(), List.of()); private final AuthorizationRequestFactory requestFactory = new AuthorizationRequestFactory( TestHandlerMappings.controllers()); + private final ApiErrorResponseWriter errorResponseWriter = new ApiErrorResponseWriter( + new ObjectMapper()); private GlobalAuthorizationFilter filter; private Authentication authentication; @@ -60,7 +65,7 @@ class GlobalAuthorizationFilterTest { void setUp() { var policy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of(), Optional.empty()); filter = new GlobalAuthorizationFilter(principalExtractor, provisioningService, - authorizationService, policy, requestFactory); + authorizationService, policy, requestFactory, errorResponseWriter); authentication = mock(Authentication.class); when(authentication.isAuthenticated()).thenReturn(true); SecurityContextHolder.getContext().setAuthentication(authentication); @@ -149,9 +154,29 @@ void shouldReturnForbiddenAndStopChainWhenAuthorizationIsDenied() filter.doFilter(request, response, chain); assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getContentType()).startsWith("application/json"); + assertThat(response.getContentAsString()).contains("\"error\":\"FORBIDDEN\"", + "\"error_description\":\"Principal is not authorized to perform this operation\""); verify(chain, never()).doFilter(any(), any()); } + @Test + void shouldRejectMissingPrincipalIdentifierAsUnauthorized() throws ServletException, IOException { + var request = new MockHttpServletRequest("GET", "/api/v1/entities"); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + when(principalExtractor.extractPrincipalInfo(authentication)) + .thenThrow(new MissingPrincipalIdentifierException("id")); + + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("\"error\":\"UNAUTHORIZED\"", + "\"error_description\":\"Required principal identifier claim 'id' is missing or blank\""); + verify(chain, never()).doFilter(any(), any()); + verify(authorizationService, never()).authorize(any(), any()); + } + @Test void shouldTreatNonUppercaseReadMethodsAsWrites() throws ServletException, IOException { var request = new MockHttpServletRequest("POST", "/api/v1/entities"); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilterTest.java index 4f231d1b..f1bc09c1 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilterTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilterTest.java @@ -26,6 +26,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.AuthorityUtils; @@ -36,6 +37,8 @@ import com.decathlon.idp_core.domain.model.principal.PrincipalKind; import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.exception.MissingPrincipalIdentifierException; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiErrorResponseWriter; import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; @ExtendWith(MockitoExtension.class) @@ -50,6 +53,9 @@ class JitProvisioningFilterTest { @Mock private AuthenticationProperties authProperties; + @Mock + private ApiErrorResponseWriter errorResponseWriter; + @Mock private HttpServletRequest request; @@ -166,6 +172,41 @@ void doFilterInternal_whenProvisioningThrowsException_failsOpenAndContinuesChain verify(filterChain).doFilter(request, response); } + @Test + void doFilterInternal_whenIdentifierClaimIsMissing_rejectsAuthentication() + throws ServletException, IOException { + Authentication auth = mock(Authentication.class); + when(auth.isAuthenticated()).thenReturn(true); + SecurityContextHolder.getContext().setAuthentication(auth); + when(principalExtractor.extractPrincipalInfo(auth)) + .thenThrow(new MissingPrincipalIdentifierException("id")); + + jitProvisioningFilter.doFilterInternal(request, response, filterChain); + + verify(errorResponseWriter).write(response, HttpStatus.UNAUTHORIZED, + "Required principal identifier claim 'id' is missing or blank"); + verify(filterChain, never()).doFilter(any(), any()); + verify(provisioningService, never()).provisionPrincipal(any()); + } + + @Test + void doFilterInternal_whenOtherPrincipalExtractionFailureOccurs_propagatesIt() + throws ServletException, IOException { + Authentication auth = mock(Authentication.class); + when(auth.isAuthenticated()).thenReturn(true); + SecurityContextHolder.getContext().setAuthentication(auth); + when(principalExtractor.extractPrincipalInfo(auth)) + .thenThrow(new IllegalStateException("Unexpected extraction failure")); + + org.assertj.core.api.Assertions + .assertThatThrownBy( + () -> jitProvisioningFilter.doFilterInternal(request, response, filterChain)) + .isInstanceOf(IllegalStateException.class).hasMessage("Unexpected extraction failure"); + + verify(errorResponseWriter, never()).write(any(), any(), any()); + verify(filterChain, never()).doFilter(any(), any()); + } + @ParameterizedTest @ValueSource(strings = {"/actuator/health", "/actuator/info", "/swagger-ui/index.html", "/swagger-ui/", "/v3/api-docs/swagger-config", "/"}) diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java index a877727e..b3e9b31c 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfigTest.java @@ -8,6 +8,8 @@ import static org.mockito.Mockito.when; import java.io.IOException; +import java.util.List; +import java.util.Map; import java.util.Objects; import jakarta.servlet.FilterChain; @@ -35,6 +37,8 @@ import com.decathlon.idp_core.domain.exception.mock.MockSecurityConfigurationException; import com.decathlon.idp_core.infrastructure.adapters.api.auth.GlobalAuthorizationFilter; import com.decathlon.idp_core.infrastructure.adapters.api.auth.JitProvisioningFilter; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties.ServiceAccountDetection; /// Unit tests for MockFilterChainConfig and MockJwtAuthenticationFilter. /// Covers mock security setup and JWT token generation for local development. @@ -81,6 +85,7 @@ void shouldInjectMockJwtAuthenticationAndClearAfterwards() throws ServletExcepti .withConfiguration(AutoConfigurations.of(MockFilterChainConfig.class)) .withBean(JitProvisioningFilter.class, () -> mock(JitProvisioningFilter.class)) .withBean(GlobalAuthorizationFilter.class, () -> mock(GlobalAuthorizationFilter.class)) + .withBean(AuthenticationProperties.class, this::authenticationProperties) .withBean(HttpSecurity.class, this::httpSecurity); private HttpSecurity httpSecurity() { @@ -122,7 +127,7 @@ void shouldThrowMockFilterChainConfigExceptionWhenHttpConfigFails() { // Given JitProvisioningFilter jitProvisioningFilterMock = mock(JitProvisioningFilter.class); MockFilterChainConfig configuration = new MockFilterChainConfig(jitProvisioningFilterMock, - mock(GlobalAuthorizationFilter.class)); + mock(GlobalAuthorizationFilter.class), authenticationProperties()); HttpSecurity httpSecurityMock = mock(HttpSecurity.class); RuntimeException simulatedError = new RuntimeException("Internal simulated Error"); when(httpSecurityMock.sessionManagement(any())).thenThrow(simulatedError); @@ -133,15 +138,35 @@ void shouldThrowMockFilterChainConfigExceptionWhenHttpConfigFails() { .hasMessage("Failed to configure mock security filter chain").hasCause(simulatedError); } + private AuthenticationProperties authenticationProperties() { + return new AuthenticationProperties(Map.of(), + new ServiceAccountDetection(false, "strict", "token_type", "m2m", List.of()), List.of(), + "sub"); + } + @Nested @DisplayName("JWT Token Creation Tests") class JwtTokenCreationTests { + @Test + @DisplayName("Should set the configured principal identifier claim on the mock JWT") + void shouldSetConfiguredIdentifierClaim() throws ServletException, IOException { + var filter = new MockFilterChainConfig.MockJwtAuthenticationFilter("id"); + + filter.doFilter(new MockHttpServletRequest(), new MockHttpServletResponse(), + (request, response) -> { + Jwt jwt = ((JwtAuthenticationToken) SecurityContextHolder.getContext() + .getAuthentication()).getToken(); + assertThat(jwt.getClaimAsString("id")).isEqualTo("local-developer"); + }); + } + @Test void shouldRegisterGlobalAuthorizationAfterJitProvisioning() { var jitFilter = mock(JitProvisioningFilter.class); var authorizationFilter = mock(GlobalAuthorizationFilter.class); - var configuration = new MockFilterChainConfig(jitFilter, authorizationFilter); + var configuration = new MockFilterChainConfig(jitFilter, authorizationFilter, + authenticationProperties()); HttpSecurity http = httpSecurity(); configuration.mockSecurityFilterChain(http); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java index 5dddd058..a0b1f486 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingControllerTest.java @@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.containsString; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -151,6 +152,35 @@ void getMappings_200_with_custom_pagination() throws Exception { @Order(2) class PostMappingTests { + @Test + @DisplayName("Should forbid service accounts from creating principal-targeting mappings") + void postPrincipalTargetMapping_forbiddenForServiceAccount() throws Exception { + String serviceIdentifier = "mapping-service-" + java.util.UUID.randomUUID(); + String payload = """ + { + "identifier": "protected-principal-mapping", + "entity_template_identifier": "principal", + "filter": ".action == \\"pushed\\"", + "action": "UPDATE_ENTITY", + "name": "Protected principal mapping", + "entity": { + "identifier": ".user.id", + "name": ".user.name", + "properties": {"is_admin": "true"}, + "relations": [] + } + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(MAPPING_PATH) + .with(jwt().jwt(token -> token.claim("sub", serviceIdentifier) + .claim("client_id", serviceIdentifier).claim("token_type", "m2m"))) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.error").value("FORBIDDEN")) + .andExpect(jsonPath("$.error_description").exists()); + } + @Test @DisplayName("Should return 401 without authentication") void postMapping_401_without_user_token() throws Exception { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index 3a7272ff..f6b6949e 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -106,6 +107,17 @@ void getTemplates_paginated_401_without_user_token() throws Exception { .andExpect(status().isUnauthorized()); } + @Test + @DisplayName("Should return standard 401 when the configured identifier claim is blank") + void getTemplates_401_whenPrincipalIdentifierClaimIsBlank() throws Exception { + mockMvc + .perform(get(ENTITY_TEMPLATE_PATH).with(jwt().jwt(token -> token.claim("sub", " "))) + .accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()).andExpect(jsonPath("$.error").value("UNAUTHORIZED")) + .andExpect(jsonPath("$.error_description") + .value(containsString("principal identifier claim 'sub'"))); + } + /// Tests the GET /api/v1/entity-templates/ endpoint with custom pagination /// parameters. /// This test verifies that: @@ -183,6 +195,29 @@ void postTemplate_201() throws Exception { .andExpect(status().isCreated()).andReturn(); } + @Test + @DisplayName("Should forbid service accounts from creating the protected principal template") + void postPrincipalTemplate_forbiddenForServiceAccount() throws Exception { + String serviceIdentifier = "template-service-" + UUID.randomUUID(); + String payload = """ + { + "identifier": "principal", + "name": "Principal", + "description": "Protected identity template", + "properties_definitions": [], + "relations_definitions": [] + } + """; + + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) + .with(jwt().jwt(token -> token.claim("sub", serviceIdentifier) + .claim("client_id", serviceIdentifier).claim("token_type", "m2m"))) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.error").value("FORBIDDEN")) + .andExpect(jsonPath("$.error_description").exists()); + } + /// Tests the POST /api/v1/entity-templates endpoint without authentication. /// This test verifies that: /// diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java new file mode 100644 index 00000000..09266e68 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java @@ -0,0 +1,78 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import jakarta.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_mapping.MappingAction; +import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.RequestAuthorizer; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook.InboundWebhookMapper; + +@ExtendWith(MockitoExtension.class) +class InboundWebhookConfigurationAuthorizationTest { + + @Mock + private WebhookConnectorService webhookConnectorService; + + @Mock + private InboundWebhookMapper inboundWebhookMapper; + + @Mock + private RequestAuthorizer requestAuthorizer; + + @Mock + private HttpServletRequest request; + + @InjectMocks + private InboundWebhookConfigurationController controller; + + @Test + void shouldAuthorizeConnectorUpdatesThatAttachPrincipalMappingsBeforePersistence() { + var mapping = new EntityDynamicMapping(UUID.randomUUID(), "principal-mapping", + AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER, ".action == 'update'", + MappingAction.UPDATE_ENTITY, "Principal mapping", null, ".user.id", ".user.name", + Map.of("is_admin", "true"), List.of()); + when(webhookConnectorService.resolveAndValidateMappings(List.of("principal-mapping"))) + .thenReturn(List.of(mapping)); + doThrow(new PrincipalNotAuthorizedException("service-account")).when(requestAuthorizer) + .authorize(eq(request), eq(AuthorizationAction.UPDATE), any(AuthorizationResource.class)); + + var update = new InboundWebhookUpdateDtoIn("connector", "update", true, + List.of("principal-mapping"), new InboundWebhookSecurityContractDtoIn("NONE", Map.of())); + + assertThatThrownBy(() -> controller.putWebhookConnector("connector", update, request)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + + verify(requestAuthorizer).authorize(eq(request), eq(AuthorizationAction.UPDATE), + org.mockito.ArgumentMatchers.argThat( + resource -> resource.type().equals(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION) + && resource.identifier().filter("connector"::equals).isPresent() + && resource.parentIdentifier() + .filter(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER::equals) + .isPresent())); + verify(webhookConnectorService, never()).updateWebhookConnector(any(), any()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 577729f0..840a3403 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -38,6 +38,7 @@ import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameNotFoundEntityTemplatePropertiesException; import com.decathlon.idp_core.domain.exception.entity_template.RelationNameNotFoundEntityTemplateRelationsException; import com.decathlon.idp_core.domain.exception.webhook.WebhookSecurityConfigurationException; +import com.decathlon.idp_core.infrastructure.adapters.api.exception.MissingPrincipalIdentifierException; import com.decathlon.idp_core.infrastructure.adapters.common.model.ErrorResponse; /// Comprehensive unit tests for [ApiExceptionHandler]. @@ -74,6 +75,18 @@ void shouldHandlePrincipalNotAuthorizedExceptionWith403Status() { assertEquals(exception.getMessage(), response.getBody().getErrorDescription()); } + @Test + void shouldHandleMissingPrincipalIdentifierAsUnauthorized() { + var exception = new MissingPrincipalIdentifierException("id"); + + ResponseEntity response = exceptionHandler + .handleMissingPrincipalIdentifierException(exception); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + assertEquals(HttpStatus.UNAUTHORIZED.name(), response.getBody().getError()); + assertEquals(exception.getMessage(), response.getBody().getErrorDescription()); + } + /// Tests the handling of [EntityTemplateNotFoundException] by the /// [ApiExceptionHandler]. /// diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListenerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListenerTest.java index f46ea34a..55423b12 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListenerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/CustomRevisionListenerTest.java @@ -12,7 +12,7 @@ import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; -import com.decathlon.idp_core.infrastructure.adapters.api.auth.UserIdentityProvider; +import com.decathlon.idp_core.domain.port.audit.AuditIdentityProviderPort; /** * Unit tests for CustomRevisionListener. @@ -25,7 +25,7 @@ class CustomRevisionListenerTest { @Mock - private UserIdentityProvider userIdentityProvider; + private AuditIdentityProviderPort userIdentityProvider; @Mock private CustomRevisionEntity revisionEntity; diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java index a325f104..00a0d2a7 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java @@ -9,20 +9,20 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import com.decathlon.idp_core.infrastructure.adapters.api.auth.UserIdentityProvider; +import com.decathlon.idp_core.domain.port.audit.AuditIdentityProviderPort; /// Unit tests for UserIdentityProviderHolder. /// Covers the static holder pattern used to bridge Spring-managed beans with Hibernate Envers. @DisplayName("UserIdentityProviderHolder Tests") class UserIdentityProviderHolderTest { - private UserIdentityProvider mockProvider; + private AuditIdentityProviderPort mockProvider; @BeforeEach void setUp() { // Reset the static holder before each test resetHolder(); - mockProvider = mock(UserIdentityProvider.class); + mockProvider = mock(AuditIdentityProviderPort.class); } /// Helper to reset the static holder for test isolation @@ -72,7 +72,7 @@ void shouldInitializeStaticProvider() { @DisplayName("Should use injected provider during initialization") void shouldUseInjectedProvider() { // Given - UserIdentityProvider customProvider = mock(UserIdentityProvider.class); + AuditIdentityProviderPort customProvider = mock(AuditIdentityProviderPort.class); var holder = new UserIdentityProviderHolder(customProvider); // When @@ -95,8 +95,8 @@ void shouldThrowExceptionIfNotInitialized() { @DisplayName("Should reinitialize with new provider") void shouldReinitializeWithNewProvider() { // Given - var provider1 = mock(UserIdentityProvider.class); - var provider2 = mock(UserIdentityProvider.class); + var provider1 = mock(AuditIdentityProviderPort.class); + var provider2 = mock(AuditIdentityProviderPort.class); var holder1 = new UserIdentityProviderHolder(provider1); holder1.init(); @@ -118,8 +118,8 @@ class ThreadSafetyTests { @DisplayName("Should handle concurrent initialization safely") void shouldHandleConcurrentInitializationSafely() throws InterruptedException { // Given - var provider1 = mock(UserIdentityProvider.class); - var provider2 = mock(UserIdentityProvider.class); + var provider1 = mock(AuditIdentityProviderPort.class); + var provider2 = mock(AuditIdentityProviderPort.class); var thread1Results = new java.util.concurrent.CountDownLatch(1); var thread2Results = new java.util.concurrent.CountDownLatch(1); @@ -151,7 +151,7 @@ void shouldHandleConcurrentInitializationSafely() throws InterruptedException { @DisplayName("Should allow concurrent reads after initialization") void shouldAllowConcurrentReadsAfterInitialization() throws InterruptedException { // Given - var provider = mock(UserIdentityProvider.class); + var provider = mock(AuditIdentityProviderPort.class); var holder = new UserIdentityProviderHolder(provider); holder.init(); @@ -164,7 +164,7 @@ void shouldAllowConcurrentReadsAfterInitialization() throws InterruptedException new Thread(() -> { try { barrier.await(); - UserIdentityProvider result = UserIdentityProviderHolder.getUserIdentityProvider(); + AuditIdentityProviderPort result = UserIdentityProviderHolder.getUserIdentityProvider(); assertThat(result).isEqualTo(provider); } catch (Exception e) { throw new RuntimeException(e); @@ -192,7 +192,7 @@ void shouldReturnInitializedProvider() { holder.init(); // When - UserIdentityProvider result = UserIdentityProviderHolder.getUserIdentityProvider(); + AuditIdentityProviderPort result = UserIdentityProviderHolder.getUserIdentityProvider(); // Then assertThat(result).isEqualTo(mockProvider); @@ -206,9 +206,9 @@ void shouldPersistProviderAcrossAccesses() { holder.init(); // When & Then - UserIdentityProvider result1 = UserIdentityProviderHolder.getUserIdentityProvider(); - UserIdentityProvider result2 = UserIdentityProviderHolder.getUserIdentityProvider(); - UserIdentityProvider result3 = UserIdentityProviderHolder.getUserIdentityProvider(); + AuditIdentityProviderPort result1 = UserIdentityProviderHolder.getUserIdentityProvider(); + AuditIdentityProviderPort result2 = UserIdentityProviderHolder.getUserIdentityProvider(); + AuditIdentityProviderPort result3 = UserIdentityProviderHolder.getUserIdentityProvider(); assertThat(result1).isEqualTo(result2).isEqualTo(result3).isEqualTo(mockProvider); } @@ -217,12 +217,13 @@ void shouldPersistProviderAcrossAccesses() { @DisplayName("Should work as bridge for Hibernate Envers listeners") void shouldBridgeHibernateEnversAccess() { // Given - var provider = mock(UserIdentityProvider.class); + var provider = mock(AuditIdentityProviderPort.class); var holder = new UserIdentityProviderHolder(provider); holder.init(); // When - Simulate Envers listener access (non-Spring context) - UserIdentityProvider enversProvider = UserIdentityProviderHolder.getUserIdentityProvider(); + AuditIdentityProviderPort enversProvider = UserIdentityProviderHolder + .getUserIdentityProvider(); // Then assertThat(enversProvider).isNotNull().isEqualTo(provider); @@ -234,7 +235,7 @@ void shouldBridgeHibernateEnversAccess() { class ConstructorTests { @Test - @DisplayName("Should accept UserIdentityProvider in constructor") + @DisplayName("Should accept AuditIdentityProviderPort in constructor") void shouldAcceptProviderInConstructor() { // When UserIdentityProviderHolder holder = new UserIdentityProviderHolder(mockProvider); @@ -302,7 +303,7 @@ void shouldFollowSpringComponentPattern() { holder.init(); // Then - Simulates what Spring would do - UserIdentityProvider provider = UserIdentityProviderHolder.getUserIdentityProvider(); + AuditIdentityProviderPort provider = UserIdentityProviderHolder.getUserIdentityProvider(); assertThat(provider).isEqualTo(mockProvider); } @@ -310,10 +311,10 @@ void shouldFollowSpringComponentPattern() { @DisplayName("Should enable Hibernate Envers listener to access provider") void shouldEnableHibernateEnversAccess() { // Given - var provider = mock(UserIdentityProvider.class); + var provider = mock(AuditIdentityProviderPort.class); var holder = new UserIdentityProviderHolder(provider); holder.init(); - UserIdentityProvider enversAccessibleProvider = UserIdentityProviderHolder + AuditIdentityProviderPort enversAccessibleProvider = UserIdentityProviderHolder .getUserIdentityProvider(); // Then @@ -327,8 +328,8 @@ void shouldServeSingletonPatternForNonSpringCode() { var holder = new UserIdentityProviderHolder(mockProvider); holder.init(); - UserIdentityProvider access1 = UserIdentityProviderHolder.getUserIdentityProvider(); - UserIdentityProvider access2 = UserIdentityProviderHolder.getUserIdentityProvider(); + AuditIdentityProviderPort access1 = UserIdentityProviderHolder.getUserIdentityProvider(); + AuditIdentityProviderPort access2 = UserIdentityProviderHolder.getUserIdentityProvider(); assertThat(access1).isSameAs(access2).isEqualTo(mockProvider); } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index b4e76583..26df5f88 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -41,8 +41,7 @@ app: preferred_username: "preferred_username" service-account-detection: enabled: true - mode: "legacy" - legacy-fallback-claims: "grant_type,service_name,client_id" + mode: "strict" # ✅ Un-indented: Now properly binds to app.security.authentication.jit-provisioning-excluded-paths jit-provisioning-excluded-paths: "/,/actuator/**,/swagger-ui/**,/swagger-ui.html,/v3/api-docs/**" ingestion: From ec085d8848d55f4f1f86afea98cb328b4f0926e8 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Mon, 5 Oct 2026 10:12:45 +0200 Subject: [PATCH 08/11] feat(core): Global RBAC - fix copilot issue --- .../api/auth/JitProvisioningFilter.java | 8 +- .../chains/MockFilterChainConfig.java | 7 +- .../EntityDynamicMappingController.java | 11 ++- ...InboundWebhookConfigurationController.java | 13 ++- ...namicMappingDeletionAuthorizationTest.java | 98 +++++++++++++++++++ ...WebhookConfigurationAuthorizationTest.java | 47 +++++++++ 6 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java index 9d931bc7..5c0b6297 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java @@ -67,12 +67,10 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated() - && !isAnonymous(authentication)) { - if (!provisionPrincipalSafely(request, response, authentication)) { - return; + if (authentication != null && authentication.isAuthenticated() + && !isAnonymous(authentication) && !provisionPrincipalSafely(request, response, authentication)) { + return; } - } filterChain.doFilter(request, response); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java index b73a89b6..f620da88 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/security/chains/MockFilterChainConfig.java @@ -53,6 +53,7 @@ @ConditionalOnProperty(prefix = "app.security.authentication.mock", name = "enabled", havingValue = "true") public class MockFilterChainConfig { + public static final String LOCAL_DEVELOPER = "local-developer"; private final JitProvisioningFilter jitProvisioningFilter; private final GlobalAuthorizationFilter globalAuthorizationFilter; private final AuthenticationProperties authenticationProperties; @@ -161,8 +162,8 @@ private Jwt createMockJwt() { Map headers = Map.of("alg", "RS256", "typ", "JWT"); Map claims = new HashMap<>(); - claims.put("sub", "local-developer"); - claims.put("preferred_username", "local-developer"); + claims.put("sub", LOCAL_DEVELOPER); + claims.put("preferred_username", LOCAL_DEVELOPER); claims.put("name", "Local Developer"); claims.put("client_id", "client-id"); claims.put("scope", "auth read write"); @@ -170,7 +171,7 @@ private Jwt createMockJwt() { claims.put("exp", expiresAt.getEpochSecond()); claims.put("email", "developer@local.dev"); claims.put("user_id", "dev-user-001"); - claims.put(principalIdentifierClaim, "local-developer"); + claims.put(principalIdentifierClaim, LOCAL_DEVELOPER); return new Jwt("mock-token-value", now, expiresAt, headers, claims); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java index 839177a6..dbbf88e4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java @@ -93,7 +93,11 @@ public Page getEntityDynamicMappingPaginated( @Content(schema = @Schema(implementation = ErrorResponse.class))}) @ResponseStatus(NO_CONTENT) @DeleteMapping("/{identifier}") - public void deleteEntityDynamicMapping(@PathVariable String identifier) { + public void deleteEntityDynamicMapping(@PathVariable String identifier, + HttpServletRequest request) { + var mapping = dynamicMappingService.getEntityDynamicMapping(identifier); + authorizePrincipalMapping(request, AuthorizationAction.DELETE, identifier, + mapping.entityTemplateIdentifier()); dynamicMappingService.deleteEntityDynamicMapping(identifier); } @@ -125,8 +129,11 @@ public EntityDynamicMappingDtoOut getEntityDynamicMappingByIdentifier( public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable String identifier, @Valid @RequestBody EntityDynamicMappingUpdateDtoIn entityDynamicMappingDtoIn, HttpServletRequest request) { + var existingMapping = dynamicMappingService.getEntityDynamicMapping(identifier); + authorizePrincipalMapping(request, AuthorizationAction.UPDATE, identifier, + existingMapping.entityTemplateIdentifier()); authorizePrincipalMapping(request, AuthorizationAction.UPDATE, identifier, - entityDynamicMappingDtoIn.entityTemplateIdentifier()); + entityDynamicMappingDtoIn.entityTemplateIdentifier()); return dynamicMappingMapper .fromEntityMappingToDto(dynamicMappingService.updateEntityDynamicMapping(identifier, dynamicMappingMapper.toDomainForUpdate(identifier, entityDynamicMappingDtoIn))); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java index 652121d9..2696c164 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java @@ -86,7 +86,18 @@ public Page getWebhooksPaginated( @Content(schema = @Schema(implementation = ErrorResponse.class))}) @ResponseStatus(NO_CONTENT) @DeleteMapping("/{identifier}") - public void deleteWebhookConnector(@PathVariable String identifier) { + public void deleteWebhookConnector(@PathVariable String identifier, + HttpServletRequest request) { + var connector = webhookConnectorService.getWebhookConnector(identifier); + boolean principalTargetMapping = connector.mappings().stream() + .anyMatch(mapping -> AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER + .equals(mapping.entityTemplateIdentifier())); + if (principalTargetMapping) { + requestAuthorizer.authorize(request, AuthorizationAction.DELETE, + new AuthorizationResource(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION, + Optional.of(identifier), + Optional.of(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER))); + } webhookConnectorService.deleteWebhookConnector(identifier); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java new file mode 100644 index 00000000..8270eb5b --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java @@ -0,0 +1,98 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import jakarta.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; +import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; +import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; +import com.decathlon.idp_core.domain.model.entity_mapping.MappingAction; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingDryRunService; +import com.decathlon.idp_core.domain.service.entity_dynamic_mapping.EntityDynamicMappingService; +import com.decathlon.idp_core.infrastructure.adapters.api.auth.RequestAuthorizer; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingDryRunDtoInMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingDryRunDtoOutMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_dynamic_mapping.EntityDynamicMappingMapper; + +@ExtendWith(MockitoExtension.class) +class EntityDynamicMappingDeletionAuthorizationTest { + + @Mock + private EntityDynamicMappingMapper dynamicMappingMapper; + + @Mock + private EntityDynamicMappingService dynamicMappingService; + + @Mock + private EntityDynamicMappingDryRunService dynamicMappingDryRunService; + + @Mock + private EntityDynamicMappingDryRunDtoOutMapper entityDynamicMappingDryRunDtoOutMapper; + + @Mock + private EntityDynamicMappingDryRunDtoInMapper entityDynamicMappingDryRunDtoInMapper; + + @Mock + private RequestAuthorizer requestAuthorizer; + + @Mock + private HttpServletRequest request; + + @InjectMocks + private EntityDynamicMappingController controller; + + @Test + void shouldAuthorizeResolvedPrincipalTargetBeforeDeletingMapping() { + when(dynamicMappingService.getEntityDynamicMapping("principal-mapping")) + .thenReturn(mapping("principal-mapping", AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER)); + doThrow(new PrincipalNotAuthorizedException("service-account")).when(requestAuthorizer) + .authorize(eq(request), eq(AuthorizationAction.DELETE), any(AuthorizationResource.class)); + + assertThatThrownBy( + () -> controller.deleteEntityDynamicMapping("principal-mapping", request)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + + verify(requestAuthorizer).authorize(eq(request), eq(AuthorizationAction.DELETE), + argThat(resource -> resource.type().equals(AuthorizationResource.ENTITY_DYNAMIC_MAPPING) + && resource.identifier().filter("principal-mapping"::equals).isPresent() + && resource.parentIdentifier() + .filter(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER::equals).isPresent())); + verify(dynamicMappingService, never()).deleteEntityDynamicMapping(any()); + } + + @Test + void shouldDeleteMappingWithoutPrincipalAuthorizationWhenTargetIsNotPrincipal() { + when(dynamicMappingService.getEntityDynamicMapping("regular-mapping")) + .thenReturn(mapping("regular-mapping", "microservice")); + + controller.deleteEntityDynamicMapping("regular-mapping", request); + + verify(requestAuthorizer, never()).authorize(any(), any(), any()); + verify(dynamicMappingService).deleteEntityDynamicMapping("regular-mapping"); + } + + private EntityDynamicMapping mapping(String identifier, String targetTemplate) { + return new EntityDynamicMapping(UUID.randomUUID(), identifier, targetTemplate, ".action == 'update'", + MappingAction.UPDATE_ENTITY, "Mapping", null, ".entity.id", ".entity.name", Map.of(), + List.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java index 09266e68..93744e58 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java @@ -25,6 +25,9 @@ import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.model.entity_mapping.MappingAction; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; +import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; import com.decathlon.idp_core.infrastructure.adapters.api.auth.RequestAuthorizer; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; @@ -75,4 +78,48 @@ void shouldAuthorizeConnectorUpdatesThatAttachPrincipalMappingsBeforePersistence .isPresent())); verify(webhookConnectorService, never()).updateWebhookConnector(any(), any()); } + + @Test + void shouldAuthorizeResolvedPrincipalMappingsBeforeDeletingConnector() { + var mapping = mapping("principal-mapping", + AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER); + when(webhookConnectorService.getWebhookConnector("connector")) + .thenReturn(connector("connector", List.of(mapping))); + doThrow(new PrincipalNotAuthorizedException("service-account")).when(requestAuthorizer) + .authorize(eq(request), eq(AuthorizationAction.DELETE), any(AuthorizationResource.class)); + + assertThatThrownBy(() -> controller.deleteWebhookConnector("connector", request)) + .isInstanceOf(PrincipalNotAuthorizedException.class); + + verify(requestAuthorizer).authorize(eq(request), eq(AuthorizationAction.DELETE), + org.mockito.ArgumentMatchers.argThat( + resource -> resource.type().equals(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION) + && resource.identifier().filter("connector"::equals).isPresent() + && resource.parentIdentifier() + .filter(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER::equals) + .isPresent())); + verify(webhookConnectorService, never()).deleteWebhookConnector(any()); + } + + @Test + void shouldDeleteConnectorWithoutPrincipalAuthorizationWhenMappingsAreNotPrincipalTargeted() { + when(webhookConnectorService.getWebhookConnector("connector")) + .thenReturn(connector("connector", List.of(mapping("regular-mapping", "microservice")))); + + controller.deleteWebhookConnector("connector", request); + + verify(requestAuthorizer, never()).authorize(any(), any(), any()); + verify(webhookConnectorService).deleteWebhookConnector("connector"); + } + + private EntityDynamicMapping mapping(String identifier, String targetTemplate) { + return new EntityDynamicMapping(UUID.randomUUID(), identifier, targetTemplate, + ".action == 'update'", MappingAction.UPDATE_ENTITY, "Mapping", null, ".entity.id", + ".entity.name", Map.of(), List.of()); + } + + private WebhookConnector connector(String identifier, List mappings) { + return new WebhookConnector(UUID.randomUUID(), identifier, "Connector", null, true, mappings, + new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); + } } From c2186cbb67f9e96bb66d0b4cb52055609d2eac3e Mon Sep 17 00:00:00 2001 From: rvando12 Date: Mon, 5 Oct 2026 10:19:01 +0200 Subject: [PATCH 09/11] feat(core): Global RBAC - fix copilot issue --- .../adapters/api/auth/JitProvisioningFilter.java | 8 ++++---- .../EntityDynamicMappingController.java | 4 ++-- .../InboundWebhookConfigurationController.java | 3 +-- ...tyDynamicMappingDeletionAuthorizationTest.java | 15 +++++++-------- ...oundWebhookConfigurationAuthorizationTest.java | 5 ++--- 5 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java index 5c0b6297..bcf51824 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilter.java @@ -67,10 +67,10 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated() - && !isAnonymous(authentication) && !provisionPrincipalSafely(request, response, authentication)) { - return; - } + if (authentication != null && authentication.isAuthenticated() && !isAnonymous(authentication) + && !provisionPrincipalSafely(request, response, authentication)) { + return; + } filterChain.doFilter(request, response); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java index dbbf88e4..798e46fd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingController.java @@ -131,9 +131,9 @@ public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable Strin HttpServletRequest request) { var existingMapping = dynamicMappingService.getEntityDynamicMapping(identifier); authorizePrincipalMapping(request, AuthorizationAction.UPDATE, identifier, - existingMapping.entityTemplateIdentifier()); + existingMapping.entityTemplateIdentifier()); authorizePrincipalMapping(request, AuthorizationAction.UPDATE, identifier, - entityDynamicMappingDtoIn.entityTemplateIdentifier()); + entityDynamicMappingDtoIn.entityTemplateIdentifier()); return dynamicMappingMapper .fromEntityMappingToDto(dynamicMappingService.updateEntityDynamicMapping(identifier, dynamicMappingMapper.toDomainForUpdate(identifier, entityDynamicMappingDtoIn))); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java index 2696c164..f7d70db5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java @@ -86,8 +86,7 @@ public Page getWebhooksPaginated( @Content(schema = @Schema(implementation = ErrorResponse.class))}) @ResponseStatus(NO_CONTENT) @DeleteMapping("/{identifier}") - public void deleteWebhookConnector(@PathVariable String identifier, - HttpServletRequest request) { + public void deleteWebhookConnector(@PathVariable String identifier, HttpServletRequest request) { var connector = webhookConnectorService.getWebhookConnector(identifier); boolean principalTargetMapping = connector.mappings().stream() .anyMatch(mapping -> AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java index 8270eb5b..d28b2be9 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java @@ -62,14 +62,13 @@ class EntityDynamicMappingDeletionAuthorizationTest { @Test void shouldAuthorizeResolvedPrincipalTargetBeforeDeletingMapping() { - when(dynamicMappingService.getEntityDynamicMapping("principal-mapping")) - .thenReturn(mapping("principal-mapping", AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER)); + when(dynamicMappingService.getEntityDynamicMapping("principal-mapping")).thenReturn( + mapping("principal-mapping", AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER)); doThrow(new PrincipalNotAuthorizedException("service-account")).when(requestAuthorizer) .authorize(eq(request), eq(AuthorizationAction.DELETE), any(AuthorizationResource.class)); - assertThatThrownBy( - () -> controller.deleteEntityDynamicMapping("principal-mapping", request)) - .isInstanceOf(PrincipalNotAuthorizedException.class); + assertThatThrownBy(() -> controller.deleteEntityDynamicMapping("principal-mapping", request)) + .isInstanceOf(PrincipalNotAuthorizedException.class); verify(requestAuthorizer).authorize(eq(request), eq(AuthorizationAction.DELETE), argThat(resource -> resource.type().equals(AuthorizationResource.ENTITY_DYNAMIC_MAPPING) @@ -91,8 +90,8 @@ void shouldDeleteMappingWithoutPrincipalAuthorizationWhenTargetIsNotPrincipal() } private EntityDynamicMapping mapping(String identifier, String targetTemplate) { - return new EntityDynamicMapping(UUID.randomUUID(), identifier, targetTemplate, ".action == 'update'", - MappingAction.UPDATE_ENTITY, "Mapping", null, ".entity.id", ".entity.name", Map.of(), - List.of()); + return new EntityDynamicMapping(UUID.randomUUID(), identifier, targetTemplate, + ".action == 'update'", MappingAction.UPDATE_ENTITY, "Mapping", null, ".entity.id", + ".entity.name", Map.of(), List.of()); } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java index 93744e58..2aded51d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java @@ -25,9 +25,9 @@ import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; import com.decathlon.idp_core.domain.model.entity_mapping.MappingAction; +import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; -import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; import com.decathlon.idp_core.infrastructure.adapters.api.auth.RequestAuthorizer; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; @@ -81,8 +81,7 @@ void shouldAuthorizeConnectorUpdatesThatAttachPrincipalMappingsBeforePersistence @Test void shouldAuthorizeResolvedPrincipalMappingsBeforeDeletingConnector() { - var mapping = mapping("principal-mapping", - AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER); + var mapping = mapping("principal-mapping", AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER); when(webhookConnectorService.getWebhookConnector("connector")) .thenReturn(connector("connector", List.of(mapping))); doThrow(new PrincipalNotAuthorizedException("service-account")).when(requestAuthorizer) From 5a5d669feb03adcc01f46b5285854b2890ccbc3d Mon Sep 17 00:00:00 2001 From: rvando12 Date: Mon, 5 Oct 2026 14:12:52 +0200 Subject: [PATCH 10/11] fix: CBC-999 - remove useless security --- docs/src/concepts/webhooks.md | 4 --- ...InboundWebhookConfigurationController.java | 29 ++----------------- 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/docs/src/concepts/webhooks.md b/docs/src/concepts/webhooks.md index 318a43ef..d1030c8c 100644 --- a/docs/src/concepts/webhooks.md +++ b/docs/src/concepts/webhooks.md @@ -72,10 +72,6 @@ It checks that: This validation keeps the connector configuration aligned with the current data model. -Only catalog administrators can attach a mapping that targets the protected `principal` template to a connector. -This applies to connector updates as well as mapping creation or updates. The restriction protects principal -authorization data; it does not block trusted deliveries through connectors that already have authorized mappings. - ## Security Strategies Each connector declares one security type. IDP-Core validates the configuration at creation time and validates requests diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java index f7d70db5..92f064df 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationController.java @@ -3,9 +3,6 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; import static org.springframework.http.HttpStatus.*; -import java.util.Optional; - -import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.data.domain.Page; @@ -13,12 +10,10 @@ import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.*; -import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; import com.decathlon.idp_core.infrastructure.adapters.api.auth.AuthorizedResource; -import com.decathlon.idp_core.infrastructure.adapters.api.auth.RequestAuthorizer; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn; @@ -45,7 +40,6 @@ public class InboundWebhookConfigurationController { private final WebhookConnectorService webhookConnectorService; private final InboundWebhookMapper inboundWebhookMapper; - private final RequestAuthorizer requestAuthorizer; /// Creates a new inbound webhook connector configuration. /// @@ -86,17 +80,7 @@ public Page getWebhooksPaginated( @Content(schema = @Schema(implementation = ErrorResponse.class))}) @ResponseStatus(NO_CONTENT) @DeleteMapping("/{identifier}") - public void deleteWebhookConnector(@PathVariable String identifier, HttpServletRequest request) { - var connector = webhookConnectorService.getWebhookConnector(identifier); - boolean principalTargetMapping = connector.mappings().stream() - .anyMatch(mapping -> AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER - .equals(mapping.entityTemplateIdentifier())); - if (principalTargetMapping) { - requestAuthorizer.authorize(request, AuthorizationAction.DELETE, - new AuthorizationResource(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION, - Optional.of(identifier), - Optional.of(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER))); - } + public void deleteWebhookConnector(@PathVariable String identifier) { webhookConnectorService.deleteWebhookConnector(identifier); } @@ -124,18 +108,9 @@ public InboundWebhookDtoOut getWebhookConnectorByIdentifier(@PathVariable String @PutMapping("/{identifier}") @ResponseStatus(OK) public InboundWebhookDtoOut putWebhookConnector(@PathVariable String identifier, - @Valid @RequestBody InboundWebhookUpdateDtoIn request, HttpServletRequest httpRequest) { + @Valid @RequestBody InboundWebhookUpdateDtoIn request) { var resolvedMappings = webhookConnectorService .resolveAndValidateMappings(request.mappingIdentifiers()); - boolean principalTargetMapping = resolvedMappings.stream() - .anyMatch(mapping -> AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER - .equals(mapping.entityTemplateIdentifier())); - if (principalTargetMapping) { - requestAuthorizer.authorize(httpRequest, AuthorizationAction.UPDATE, - new AuthorizationResource(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION, - Optional.of(identifier), - Optional.of(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER))); - } return inboundWebhookMapper .fromWebhookConnectorToDto(webhookConnectorService.updateWebhookConnector(identifier, inboundWebhookMapper.toDomainForUpdate(identifier, request, resolvedMappings))); From ef8975704204be31cf112b4422240f32cb415466 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Mon, 5 Oct 2026 16:17:05 +0200 Subject: [PATCH 11/11] feat(core): Global RBAC - fix test fail --- ...WebhookConfigurationAuthorizationTest.java | 124 ------------------ 1 file changed, 124 deletions(-) delete mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java deleted file mode 100644 index 2aded51d..00000000 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/InboundWebhookConfigurationAuthorizationTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.controller; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import jakarta.servlet.http.HttpServletRequest; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.decathlon.idp_core.domain.exception.authorization.PrincipalNotAuthorizedException; -import com.decathlon.idp_core.domain.model.authorization.AuthorizationAction; -import com.decathlon.idp_core.domain.model.authorization.AuthorizationResource; -import com.decathlon.idp_core.domain.model.entity_mapping.EntityDynamicMapping; -import com.decathlon.idp_core.domain.model.entity_mapping.MappingAction; -import com.decathlon.idp_core.domain.model.enums.WebhookSecurityType; -import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookConnector; -import com.decathlon.idp_core.domain.model.inbound_connectors.webhook.WebhookSecurity; -import com.decathlon.idp_core.domain.service.webhook.WebhookConnectorService; -import com.decathlon.idp_core.infrastructure.adapters.api.auth.RequestAuthorizer; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookSecurityContractDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.InboundWebhookUpdateDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.mapper.connector.webhook.InboundWebhookMapper; - -@ExtendWith(MockitoExtension.class) -class InboundWebhookConfigurationAuthorizationTest { - - @Mock - private WebhookConnectorService webhookConnectorService; - - @Mock - private InboundWebhookMapper inboundWebhookMapper; - - @Mock - private RequestAuthorizer requestAuthorizer; - - @Mock - private HttpServletRequest request; - - @InjectMocks - private InboundWebhookConfigurationController controller; - - @Test - void shouldAuthorizeConnectorUpdatesThatAttachPrincipalMappingsBeforePersistence() { - var mapping = new EntityDynamicMapping(UUID.randomUUID(), "principal-mapping", - AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER, ".action == 'update'", - MappingAction.UPDATE_ENTITY, "Principal mapping", null, ".user.id", ".user.name", - Map.of("is_admin", "true"), List.of()); - when(webhookConnectorService.resolveAndValidateMappings(List.of("principal-mapping"))) - .thenReturn(List.of(mapping)); - doThrow(new PrincipalNotAuthorizedException("service-account")).when(requestAuthorizer) - .authorize(eq(request), eq(AuthorizationAction.UPDATE), any(AuthorizationResource.class)); - - var update = new InboundWebhookUpdateDtoIn("connector", "update", true, - List.of("principal-mapping"), new InboundWebhookSecurityContractDtoIn("NONE", Map.of())); - - assertThatThrownBy(() -> controller.putWebhookConnector("connector", update, request)) - .isInstanceOf(PrincipalNotAuthorizedException.class); - - verify(requestAuthorizer).authorize(eq(request), eq(AuthorizationAction.UPDATE), - org.mockito.ArgumentMatchers.argThat( - resource -> resource.type().equals(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION) - && resource.identifier().filter("connector"::equals).isPresent() - && resource.parentIdentifier() - .filter(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER::equals) - .isPresent())); - verify(webhookConnectorService, never()).updateWebhookConnector(any(), any()); - } - - @Test - void shouldAuthorizeResolvedPrincipalMappingsBeforeDeletingConnector() { - var mapping = mapping("principal-mapping", AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER); - when(webhookConnectorService.getWebhookConnector("connector")) - .thenReturn(connector("connector", List.of(mapping))); - doThrow(new PrincipalNotAuthorizedException("service-account")).when(requestAuthorizer) - .authorize(eq(request), eq(AuthorizationAction.DELETE), any(AuthorizationResource.class)); - - assertThatThrownBy(() -> controller.deleteWebhookConnector("connector", request)) - .isInstanceOf(PrincipalNotAuthorizedException.class); - - verify(requestAuthorizer).authorize(eq(request), eq(AuthorizationAction.DELETE), - org.mockito.ArgumentMatchers.argThat( - resource -> resource.type().equals(AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION) - && resource.identifier().filter("connector"::equals).isPresent() - && resource.parentIdentifier() - .filter(AuthorizationResource.PRINCIPAL_TEMPLATE_IDENTIFIER::equals) - .isPresent())); - verify(webhookConnectorService, never()).deleteWebhookConnector(any()); - } - - @Test - void shouldDeleteConnectorWithoutPrincipalAuthorizationWhenMappingsAreNotPrincipalTargeted() { - when(webhookConnectorService.getWebhookConnector("connector")) - .thenReturn(connector("connector", List.of(mapping("regular-mapping", "microservice")))); - - controller.deleteWebhookConnector("connector", request); - - verify(requestAuthorizer, never()).authorize(any(), any(), any()); - verify(webhookConnectorService).deleteWebhookConnector("connector"); - } - - private EntityDynamicMapping mapping(String identifier, String targetTemplate) { - return new EntityDynamicMapping(UUID.randomUUID(), identifier, targetTemplate, - ".action == 'update'", MappingAction.UPDATE_ENTITY, "Mapping", null, ".entity.id", - ".entity.name", Map.of(), List.of()); - } - - private WebhookConnector connector(String identifier, List mappings) { - return new WebhookConnector(UUID.randomUUID(), identifier, "Connector", null, true, mappings, - new WebhookSecurity(WebhookSecurityType.NONE, Map.of())); - } -}