diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar deleted file mode 100644 index eebc3c18..00000000 Binary files a/.mvn/wrapper/maven-wrapper.jar and /dev/null differ diff --git a/docs/src/concepts/authentication.md b/docs/src/concepts/authentication.md index 15de6cf7..5831b774 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, mock, and OAuth2 login chains - **Domain Services**: Enforce business rules and authorization checks ## Filter Chain Architecture @@ -88,14 +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 @@ -108,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 @@ -129,7 +146,6 @@ app: security: authentication: user-claim-mappings: - sub: "sub" preferred_username: "preferred_username" name: "name" email: "email" @@ -149,7 +165,6 @@ app: security: authentication: user-claim-mappings: - sub: "sub" preferred_username: "preferred_username" name: "name" email: "email" @@ -167,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" @@ -181,30 +196,24 @@ 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: -| Principal field | Mapping key | Fallback when the mapped claim is absent | -|--------------------|----------------------|------------------------------------------| -| `identifier` | `preferred_username` | Original JWT `sub` | -| `name` | `name` | Extracted `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`. +| 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 | ### Example: UUID Identifier and Subject as Display Name @@ -226,8 +235,8 @@ configure: app: security: authentication: + principal-identifier-claim: uuid user-claim-mappings: - preferred_username: uuid name: sub email: email ``` @@ -242,7 +251,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. IDP-Core does not automatically rename, +> merge, or look up previously provisioned principals when this setting changes. ## Service Account Detection @@ -253,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: @@ -271,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:** @@ -284,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) - -In legacy mode, the system checks multiple optional claims with OR logic: +### Legacy Configuration Compatibility -```yaml -app: - security: - authentication: - service-account-detection: - enabled: true - mode: "legacy" - legacy-fallback-claims: - - "grant_type" - - "service_name" - - "client_id" -``` +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. -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 -that cannot immediately reconfigure their Identity Provider configuration. +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 @@ -404,10 +384,74 @@ 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, 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: + +| 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 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 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: + security: + 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. + +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. +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 -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 +validation described in [Webhook Security](webhooks.md#security-strategies). ### Enabling API Key Authentication @@ -488,8 +532,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/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/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/docs/src/features/entity-dynamic-mapping.md b/docs/src/features/entity-dynamic-mapping.md index 3d16dcf2..eb155a25 100644 --- a/docs/src/features/entity-dynamic-mapping.md +++ b/docs/src/features/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. It is linked to a data integration configuration (Webhook, Kafka, etc) as it describes how to transform incoming payloads. +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/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/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/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..88849506 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationPolicy.java @@ -0,0 +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, + 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/model/authorization/AuthorizationRequest.java b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java new file mode 100644 index 00000000..f9b327f8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationRequest.java @@ -0,0 +1,22 @@ +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, @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..fc3a6845 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/authorization/AuthorizationResource.java @@ -0,0 +1,23 @@ +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 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"); + 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/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 new file mode 100644 index 00000000..f3a5c7ac --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationService.java @@ -0,0 +1,106 @@ +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.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; + +/// 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 = 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 = 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. + /// + /// @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 + || request.action() == AuthorizationAction.UNSUPPORTED) { + throw new PrincipalNotAuthorizedException(identifier); + } + + if (policy.globalPrincipalIdentifiers().contains(identifier)) { + return; + } + + if (request.principalEntity().filter(this::isCatalogAdministrator).isPresent()) { + 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) { + 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 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()) + || ((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) { + 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/AuthorizationRequestFactory.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java new file mode 100644 index 00000000..12fe8a3c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactory.java @@ -0,0 +1,114 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.auth; + +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; +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 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 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) { + Optional handler = resolveHandler(request); + boolean readOnly = handler.map(this::isReadOnly).orElse(false); + return new AuthorizationRequest(principal, principalEntity, + resolveAction(request.getMethod(), readOnly), resolveResource(request, handler)); + } + + 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; + } + + 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(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 Optional resourceType(HandlerMethod handler) { + AuthorizedResource onMethod = handler.getMethodAnnotation(AuthorizedResource.class); + if (onMethod != null && !onMethod.value().isBlank()) { + return Optional.of(onMethod.value()); + } + return Optional + .ofNullable(AnnotatedElementUtils.findMergedAnnotation(handler.getBeanType(), + AuthorizedResource.class)) + .map(AuthorizedResource::value).filter(value -> !value.isBlank()); + } + + @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 new file mode 100644 index 00000000..c26b2800 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilter.java @@ -0,0 +1,119 @@ +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; +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.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; + +/// Enforces the global authorization policy for authenticated API requests. +@Slf4j +@Component +public class GlobalAuthorizationFilter extends OncePerRequestFilter { + + private final PrincipalExtractor principalExtractor; + private final PrincipalProvisioningService provisioningService; + 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. + /// + /// @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 + /// @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, + ApiErrorResponseWriter errorResponseWriter) { + this.principalExtractor = principalExtractor; + this.provisioningService = provisioningService; + this.authorizationService = authorizationService; + this.authorizationPolicy = authorizationPolicy; + this.authorizationRequestFactory = authorizationRequestFactory; + this.errorResponseWriter = errorResponseWriter; + } + + @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; + } + + var context = request.getAttribute(ProvisionedPrincipalContext.REQUEST_ATTRIBUTE); + var principalContext = context instanceof ProvisionedPrincipalContext provisionedContext + ? provisionedContext + : null; + 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()); + AuthorizationRequest authorizationRequest = authorizationRequestFactory.create(request, + principal, principalEntity); + + try { + authorizationService.authorize(authorizationRequest, authorizationPolicy); + } catch (PrincipalNotAuthorizedException _) { + log.warn("Authorization denied for principal {} on {} {}", principal.identifier(), + request.getMethod(), request.getRequestURI()); + 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 5a8a48f4..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 @@ -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.http.server.PathContainer; import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; @@ -15,10 +17,13 @@ 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; 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; @@ -52,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 @@ -61,25 +67,37 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated() - && !isAnonymous(authentication)) { - provisionPrincipalSafely(authentication); + if (authentication != null && authentication.isAuthenticated() && !isAnonymous(authentication) + && !provisionPrincipalSafely(request, response, authentication)) { + return; } filterChain.doFilter(request, response); } - private void provisionPrincipalSafely(Authentication authentication) { + private boolean provisionPrincipalSafely(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + PrincipalInfo principalInfo; try { - PrincipalInfo principalInfo = principalExtractor.extractPrincipalInfo(authentication); - provisioningService.provisionPrincipal(principalInfo); + principalInfo = principalExtractor.extractPrincipalInfo(authentication); + } catch (MissingPrincipalIdentifierException exception) { + log.warn("Authentication rejected: {}", exception.getMessage()); + errorResponseWriter.write(response, HttpStatus.UNAUTHORIZED, exception.getMessage()); + return false; + } + + try { + 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/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/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 875b63b0..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 @@ -1,24 +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; -/// UnifiedUserProvider is a Spring component that implements the UserIdentityProvider interface to provide a consistent way +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 AuditIdentityProviderPort 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 -public class UnifiedUserProvider implements UserIdentityProvider { +@RequiredArgsConstructor +public class UnifiedUserProvider implements AuditIdentityProviderPort { + + private final PrincipalExtractor principalExtractor; @Override public String getAuthId() { @@ -29,25 +29,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/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 bdd62f7b..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 @@ -21,37 +21,28 @@ *
  • 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: + *

    Service Account Detection

    Service accounts (M2M tokens) require one + * configured definitive claim: *
      - *
    • 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: - *
      - *
    • 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 */ @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,23 +50,34 @@ 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( // 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/AuthorizationConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java new file mode 100644 index 00000000..75320d7f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfiguration.java @@ -0,0 +1,42 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.configuration; + +import java.util.Optional; +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()), + 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 new file mode 100644 index 00000000..ba1363aa --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationProperties.java @@ -0,0 +1,24 @@ +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, String requiredPrincipalProperty) { + + 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(); + requiredPrincipalProperty = requiredPrincipalProperty == null + || requiredPrincipalProperty.isBlank() ? null : requiredPrincipalProperty.trim(); + } +} 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 cee1de3a..78f51d02 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; @@ -42,25 +41,18 @@ /// @see JitProvisioningFilter @Configuration @EnableWebSecurity -@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/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..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 @@ -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; @@ -31,7 +32,9 @@ 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; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.AuthenticationProperties; /// Local mock security configuration that mirrors OAuth2/JWT behavior for local development. /// @@ -50,10 +53,17 @@ @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; - public MockFilterChainConfig(JitProvisioningFilter jitProvisioningFilter) { + public MockFilterChainConfig(JitProvisioningFilter jitProvisioningFilter, + GlobalAuthorizationFilter globalAuthorizationFilter, + AuthenticationProperties authenticationProperties) { this.jitProvisioningFilter = jitProvisioningFilter; + this.globalAuthorizationFilter = globalAuthorizationFilter; + this.authenticationProperties = authenticationProperties; } /// Security filter chain for local mocking with JWT-like behavior. @@ -79,10 +89,14 @@ 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); + .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", @@ -102,6 +116,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) @@ -137,10 +161,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/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/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 15b8a8f4..5af76b05 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 @@ -89,6 +89,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.EntityPatchDtoIn; @@ -119,6 +120,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 @@ -372,6 +374,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..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 @@ -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,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; @@ -35,6 +42,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") @@ -45,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) @@ -53,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); @@ -80,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); } @@ -110,12 +127,27 @@ public EntityDynamicMappingDtoOut getEntityDynamicMappingByIdentifier( @PutMapping("/{identifier}") @ResponseStatus(OK) public EntityDynamicMappingDtoOut updateEntityDynamicMapping(@PathVariable String identifier, - @Valid @RequestBody EntityDynamicMappingUpdateDtoIn entityDynamicMappingDtoIn) { + @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()); 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))}) @@ -126,6 +158,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 17e896fe..7a606a37 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 @@ -30,6 +30,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphQueryOptions; 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; @@ -48,6 +49,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..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,8 +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; @@ -81,6 +88,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") @@ -88,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. /// @@ -141,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 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/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 7627cd64..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 @@ -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; @@ -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 @@ -423,6 +433,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/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..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,15 +1,13 @@ 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; 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; @@ -24,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 @@ -46,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); @@ -54,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(); @@ -84,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. @@ -108,76 +87,28 @@ 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(); - // 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 MissingPrincipalIdentifierException(identifierClaim)); + 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..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 @@ -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; @@ -19,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. @@ -63,18 +63,20 @@ 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 MissingPrincipalIdentifierException(identifierClaim)); 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,14 @@ 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 MissingPrincipalIdentifierException(identifierClaim)); 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/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 978d8674..f1c083f4 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -41,15 +41,24 @@ app: # ${app.idp-core-prefix-url}/login/oauth2/code/github in the OAuth app settings. idp-core-prefix-url: http://localhost:8084 security: + authorization: + mode: GLOBAL + # 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: jwt: - enabled: false + # Enable to take in custom JWT tokens for authentication in local development + # this will also enable the IDP_PRINCIPAL_IDENTIFIER_CLAIM to be extracted from the JWT token + # and be setted as identifier of a Principal entity kind + enabled: true oauth2-login: enabled: true mock: enabled: false + principal-identifier-claim: ${IDP_PRINCIPAL_IDENTIFIER_CLAIM:sub} user-claim-mappings: - sub: id preferred_username: login name: name email: email @@ -58,7 +67,7 @@ logging: # Application log level, defaults to INFO. Override per environment via LOG_LEVEL env var. com.decathlon: ${LOG_LEVEL:DEBUG} # Suppresses noisy Spring context startup logs at WARN level. - org.springframework.context.support: WARN + org.springframework.context.support: DEBUG # Suppresses a specific repetitive bean registration delegate log line. org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR org.hibernate.SQL: INFO diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 83ac46c2..69d710fb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -118,13 +118,21 @@ app: # Base URL of this service, used to build self-referencing links. idp-core-prefix-url: ${IDP_CORE_PREFIX_URL} security: - roles: - # Baseline role assigned to all authenticated principals during V1 phase. - baseline-role: ${APP_SECURITY_ROLES_BASELINE_ROLE:*} + authorization: + mode: GLOBAL + # 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 given boolean property in the user's Principal 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 authentication: + # Human identity claim used as the catalog principal identifier. + principal-identifier-claim: ${IDP_PRINCIPAL_IDENTIFIER_CLAIM:sub} + # Enable to take in custom JWT tokens for authentication + # This will also enable the IDP_PRINCIPAL_IDENTIFIER_CLAIM to be extracted from the JWT token + # and be setted as identifier of an entity of kind Principal jwt: enabled: true api-key: @@ -135,7 +143,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 @@ -150,12 +157,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/SecurityConfigurationTest.java b/src/test/java/com/decathlon/idp_core/SecurityConfigurationTest.java index 083083f6..0fbc8a6e 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 new file mode 100644 index 00000000..b2307732 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/authorization/GlobalAuthorizationServiceTest.java @@ -0,0 +1,261 @@ +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.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; +import com.decathlon.idp_core.domain.model.principal.PrincipalKind; + +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( + AuthorizationResource.INBOUND_WEBHOOK_CONFIGURATION, Optional.of("connector"), + Optional.empty()); + + 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"), + Optional.of(REQUIRED_PRINCIPAL_PROPERTY)); + humanPrincipal = principal(PRINCIPAL_IDENTIFIER, PrincipalKind.HUMAN); + } + + @Test + void shouldAllowBreakGlassPrincipalBeforeCheckingCatalogAdmin() { + var request = request(principal("break-glass-user", PrincipalKind.HUMAN), + AuthorizationAction.DELETE, CATALOG, Optional.empty()); + + assertThatCode(() -> authorizationService.authorize(request, policy)) + .doesNotThrowAnyException(); + } + + @Test + 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 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(); + } + + @Test + void shouldRejectServiceAccountWritesToPrincipalRecordsAndTemplate() { + List protectedResources = List.of( + 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(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, + 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.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", "true"))); + + 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"), + new Property(null, REQUIRED_PRINCIPAL_PROPERTY, "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(), + Optional.of(REQUIRED_PRINCIPAL_PROPERTY)); + 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 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), + new Property(null, REQUIRED_PRINCIPAL_PROPERTY, isDigitalTeammate)), + 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..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 @@ -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; @@ -27,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; @@ -201,7 +203,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 +248,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() { @@ -358,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"); @@ -375,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"); @@ -398,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); @@ -419,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"); @@ -439,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 @@ -448,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"); @@ -470,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"); @@ -492,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"); @@ -514,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")); @@ -539,7 +563,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 +592,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 +621,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 +667,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 +745,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 +755,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(MissingPrincipalIdentifierException.class) + .hasMessageContaining("principal identifier claim 'sub' is missing or blank"); } @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 +776,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 +823,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 +841,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 +899,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..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 @@ -115,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/AuthorizationRequestFactoryTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java new file mode 100644 index 00000000..9c312d64 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/AuthorizationRequestFactoryTest.java @@ -0,0 +1,96 @@ +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( + TestHandlerMappings.controllers()); + 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 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"); + + 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 new file mode 100644 index 00000000..08dada70 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/GlobalAuthorizationFilterTest.java @@ -0,0 +1,211 @@ +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.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; +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 { + + 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 final AuthorizationRequestFactory requestFactory = new AuthorizationRequestFactory( + TestHandlerMappings.controllers()); + private final ApiErrorResponseWriter errorResponseWriter = new ApiErrorResponseWriter( + new ObjectMapper()); + private GlobalAuthorizationFilter filter; + private Authentication authentication; + + @BeforeEach + void setUp() { + var policy = new AuthorizationPolicy(AuthorizationMode.GLOBAL, Set.of(), Optional.empty()); + filter = new GlobalAuthorizationFilter(principalExtractor, provisioningService, + authorizationService, policy, requestFactory, errorResponseWriter); + 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().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 { + 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); + 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"); + 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/auth/JitProvisioningFilterTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/auth/JitProvisioningFilterTest.java index 4229ee21..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 @@ -10,6 +10,7 @@ import java.io.IOException; import java.util.List; +import java.util.Optional; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -25,15 +26,19 @@ 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; 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; 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) @@ -48,6 +53,9 @@ class JitProvisioningFilterTest { @Mock private AuthenticationProperties authProperties; + @Mock + private ApiErrorResponseWriter errorResponseWriter; + @Mock private HttpServletRequest request; @@ -82,6 +90,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 +100,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); } @@ -159,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/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; + } +} 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/AuthorizationConfigurationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java new file mode 100644 index 00000000..8038d05d --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/AuthorizationConfigurationTest.java @@ -0,0 +1,49 @@ +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", + "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(); + }); + } + + @Test + 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/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/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..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 @@ -4,9 +4,12 @@ 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; +import java.util.List; +import java.util.Map; import java.util.Objects; import jakarta.servlet.FilterChain; @@ -32,7 +35,10 @@ 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; +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. @@ -78,11 +84,18 @@ 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(AuthenticationProperties.class, this::authenticationProperties) .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 +126,8 @@ void shouldNotLoadConfigurationWhenPropertyIsMissing() { void shouldThrowMockFilterChainConfigExceptionWhenHttpConfigFails() { // Given JitProvisioningFilter jitProvisioningFilterMock = mock(JitProvisioningFilter.class); - MockFilterChainConfig configuration = new MockFilterChainConfig(jitProvisioningFilterMock); + MockFilterChainConfig configuration = new MockFilterChainConfig(jitProvisioningFilterMock, + mock(GlobalAuthorizationFilter.class), authenticationProperties()); HttpSecurity httpSecurityMock = mock(HttpSecurity.class); RuntimeException simulatedError = new RuntimeException("Internal simulated Error"); when(httpSecurityMock.sessionManagement(any())).thenThrow(simulatedError); @@ -124,10 +138,42 @@ 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, + authenticationProperties()); + 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/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/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..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 @@ -1,13 +1,22 @@ 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.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.*; - -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 +33,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"); @@ -144,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 { @@ -815,11 +852,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 +1390,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 +1483,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 +1493,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/EntityDynamicMappingDeletionAuthorizationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java new file mode 100644 index 00000000..d28b2be9 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityDynamicMappingDeletionAuthorizationTest.java @@ -0,0 +1,97 @@ +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/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index d9833076..6ddaab59 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; @@ -24,6 +25,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 +42,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 +78,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 +98,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") @@ -102,10 +107,22 @@ 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: /// - 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 +144,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 +159,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 +181,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() @@ -175,8 +195,32 @@ 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: + /// /// @throws Exception if the MockMvc request fails @Test @DisplayName("Should create template and return 401") @@ -194,6 +238,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 +254,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 +270,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 +286,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 +302,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 +318,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 +334,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 +350,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 +365,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 +380,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 +396,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 +411,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 +426,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 +447,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 +462,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 +477,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 +494,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 +512,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 +777,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 +798,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 +906,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 +927,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 +947,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 +967,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 +988,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 +1008,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 +1079,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 +1097,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 +1120,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 +1143,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 df8a9e06..a45daaaf 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 { @@ -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/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..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 @@ -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; @@ -35,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]. @@ -59,6 +63,30 @@ 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()); + } + + @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]. /// @@ -161,6 +189,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 +502,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/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 22564216..c0ef025d 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 @@ -43,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: 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 1742bcc1..94dad35d 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 @@ -155,7 +156,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 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');