Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 119 additions & 75 deletions docs/src/concepts/authentication.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/src/concepts/entity-dynamic-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ description: Understand Dynamic mappings and JSLT expressions.

A mapping targets one Entity Template and describes how to derive entity fields from the incoming JSON payload with a JSLT filter and entity projections.

Only catalog administrators can create or update mappings that target the protected `principal` template. This
restriction prevents an ingestion mapping from changing principal privileges. Webhook deliveries that use an already
authorized mapping continue to run through the configured connector security strategy.

## Entity Dynamic Mapping Fields

| Field | Required | Description |
Expand Down
3 changes: 3 additions & 0 deletions docs/src/concepts/entity-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 2 additions & 3 deletions docs/src/contributing/code/exception-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.decathlon.idp_core.domain.model.authorization;

/// Authorization strategies supported by the platform.
public enum AuthorizationMode {
GLOBAL, RBAC, ABAC
}
Original file line number Diff line number Diff line change
@@ -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<String> globalPrincipalIdentifiers,
Optional<String> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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<Entity> 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");
}
}
Original file line number Diff line number Diff line change
@@ -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<String> identifier,
@NotNull Optional<String> 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");
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
RVANDO12 marked this conversation as resolved.
if (isPrincipalManagementWrite(request)) {
throw new PrincipalNotAuthorizedException(identifier);
}
if (!isPlatformAdminOnlyCreation(request)) {
return;
Comment thread
Copilot marked this conversation as resolved.
}
}

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());
Comment thread
RVANDO12 marked this conversation as resolved.
}

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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -112,10 +114,14 @@ private Entity createNewPrincipal(PrincipalInfo principalInfo) {
/// @return list of properties for the principal entity
private List<Property> buildProperties(PrincipalInfo principalInfo) {
List<Property> 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));
}
});
Expand Down
Loading
Loading