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');