-
Notifications
You must be signed in to change notification settings - Fork 0
feat(core): add jit principal generation #105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
e3c612e
624e10f
be7e530
3827709
e2e2a19
3c2966f
4617e96
1624c2c
f61294c
7326cca
d01d30e
cf680d3
9a1283e
819b995
8c2c431
9731c40
13e9f10
b934ca8
8ac62d3
00e8857
f3ccb83
4c0bdaf
8ca575d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.decathlon.idp_core.domain.exception.principal; | ||
|
|
||
| import static com.decathlon.idp_core.domain.constant.ValidationMessages.PRINCIPAL_CREATION_FAILED; | ||
|
|
||
| /// Custom exception indicating that principal creation failed due to a transient provider issue. | ||
| /// | ||
| /// **Business purpose:** Represents a transient failure in principal provisioning that does | ||
| /// not block IDP Core usage. The system will automatically retry creation on the next request | ||
| /// when the principal is accessed (Just-In-Time provisioning retry strategy). | ||
| public class PrincipalCreationException extends RuntimeException { | ||
|
|
||
| private final String principalIdentifier; | ||
|
Check warning on line 12 in src/main/java/com/decathlon/idp_core/domain/exception/principal/PrincipalCreationException.java
|
||
|
|
||
| /// Constructs a new exception with the principal identifier. | ||
| /// | ||
| /// @param principalIdentifier the identifier of the principal that failed to be | ||
| /// created | ||
| public PrincipalCreationException(String principalIdentifier) { | ||
| super(String.format(PRINCIPAL_CREATION_FAILED, principalIdentifier)); | ||
| this.principalIdentifier = principalIdentifier; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.decathlon.idp_core.domain.exception.principal; | ||
|
|
||
| /// Custom exception indicating that a principal was not found in the system. | ||
| /// | ||
| /// **Business purpose:** Represents the business rule violation when attempting | ||
| /// to access a principal that doesn't exist in the catalog. This exception is | ||
| /// thrown to signal that the requested principal could not be located, which may | ||
| /// indicate a failure in Just-In-Time (JIT) provisioning or an invalid identifier. | ||
| public class PrincipalNotFoundException extends RuntimeException { | ||
|
|
||
| /// Custom exception for principal not found scenarios. | ||
| /// | ||
| /// **Design rationale:** Specific exception enables tailored HTTP status | ||
| /// mapping | ||
| /// in ApiExceptionHandler (404 instead of generic 500). | ||
| public PrincipalNotFoundException(String identifier) { | ||
| super("Principal with identifier '" + identifier | ||
| + "' not found in catalog. JIT provisioning may have failed."); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package com.decathlon.idp_core.domain.model.principal; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
|
|
||
| /// Domain model representing the identity and attributes of an authenticated principal. | ||
| /// | ||
| /// **Business invariants:** | ||
| /// - [identifier] must be unique and non-blank (subject for humans, client_id for services) | ||
| /// - [kind] determines how claims are interpreted | ||
| /// - [name] provides a human-readable label for both humans and service accounts | ||
| /// | ||
| /// **Ubiquitous language:** A Principal is any authenticated actor (human or machine) | ||
| /// that can interact with the IDP-Core API. This model carries the essential identity | ||
| /// information extracted from the authentication context. | ||
| public record PrincipalInfo(@NotBlank String identifier, @NotNull PrincipalKind kind, | ||
| @NotBlank String name, Map<String, String> attributes, List<String> groups) { | ||
|
|
||
| public PrincipalInfo { | ||
| attributes = attributes != null ? Map.copyOf(attributes) : Map.of(); | ||
| groups = groups != null ? List.copyOf(groups) : List.of(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.decathlon.idp_core.domain.model.principal; | ||
|
|
||
| /// Enumeration defining the type of principal (actor) in the system. | ||
| /// | ||
| /// **Business meaning:** | ||
| /// - HUMAN: A human user authenticated via OAuth2/OIDC | ||
| /// - SERVICE_ACCOUNT: A machine client (webhook, API connector, service token) | ||
| public enum PrincipalKind { | ||
| HUMAN, SERVICE_ACCOUNT | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| package com.decathlon.idp_core.domain.service.principal; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; | ||
| import com.decathlon.idp_core.domain.exception.principal.PrincipalCreationException; | ||
| import com.decathlon.idp_core.domain.model.entity.Entity; | ||
| import com.decathlon.idp_core.domain.model.entity.Property; | ||
| import com.decathlon.idp_core.domain.model.entity.Relation; | ||
| import com.decathlon.idp_core.domain.model.principal.PrincipalInfo; | ||
| import com.decathlon.idp_core.domain.port.EntityRepositoryPort; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| /// Domain service orchestrating Just-In-Time (JIT) provisioning of Principal entities. | ||
| /// | ||
| /// **Business purpose:** Ensures every authenticated principal (human or service account) | ||
| /// has a corresponding Entity in the catalog with template_identifier="principal". | ||
| /// This enables unified identity management and audit tracking across the platform. | ||
| /// | ||
| /// **Key responsibilities:** | ||
| /// - Create Principal entities on first authentication | ||
| /// - Map authentication claims to entity properties and relations | ||
| /// - Maintain referential integrity with team/group entities | ||
| /// | ||
| /// **Design rationale:** Separates authentication (infrastructure concern) from | ||
| /// identity management (business concern). The authentication layer extracts PrincipalInfo, | ||
| /// this service persists it as domain entities. | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class PrincipalProvisioningService { | ||
|
|
||
| private static final String PRINCIPAL_TEMPLATE_IDENTIFIER = "principal"; | ||
|
|
||
| private final EntityRepositoryPort entityRepository; | ||
|
|
||
| /// Provisions a Principal entity based on authentication information. | ||
| /// | ||
| /// **Contract:** Performs JIT provisioning: | ||
| /// - If principal does not exist: Creates new Principal entity | ||
| /// - If principal exists: Returns it without modification (updates handled by | ||
| /// separate endpoint) | ||
| /// - Returns the provisioned Principal entity | ||
| /// | ||
| /// **Thread-safety:** Uses database constraints to handle concurrent first-time | ||
| /// logins. | ||
| /// If two requests race to create the same principal, one will succeed and the | ||
| /// other | ||
| /// will return the existing principal. | ||
| /// | ||
| /// @param principalInfo extracted authentication information | ||
| /// @return the provisioned Principal entity | ||
| @Transactional | ||
| public Entity provisionPrincipal(PrincipalInfo principalInfo) { | ||
| Optional<Entity> existingPrincipal = entityRepository.findByTemplateIdentifierAndIdentifier( | ||
| PRINCIPAL_TEMPLATE_IDENTIFIER, principalInfo.identifier()); | ||
|
|
||
| if (existingPrincipal.isPresent()) { | ||
| return existingPrincipal.get(); | ||
| } | ||
|
|
||
| return createNewPrincipal(principalInfo); | ||
| } | ||
|
|
||
| /// Retrieves a Principal entity by its identifier. | ||
| /// | ||
| /// **Contract:** Returns the Principal entity if it exists, empty otherwise. | ||
| /// | ||
| /// @param identifier unique principal identifier | ||
| /// @return optional containing the principal entity | ||
| @Transactional(readOnly = true) | ||
| public Optional<Entity> getPrincipal(String identifier) { | ||
| return entityRepository.findByTemplateIdentifierAndIdentifier(PRINCIPAL_TEMPLATE_IDENTIFIER, | ||
| identifier); | ||
| } | ||
|
|
||
| /// Creates a new Principal entity in the catalog. | ||
| /// | ||
| /// **Business logic:** Maps PrincipalInfo to entity properties and relations. | ||
| /// If a concurrent creation occurs (race condition during JIT provisioning), | ||
| /// the existing entity is returned instead. | ||
| /// | ||
| /// @param principalInfo extracted authentication information | ||
| /// @return the newly created or existing Principal entity | ||
| private Entity createNewPrincipal(PrincipalInfo principalInfo) { | ||
| Entity newPrincipal = new Entity(null, PRINCIPAL_TEMPLATE_IDENTIFIER, principalInfo.name(), | ||
| principalInfo.identifier(), buildProperties(principalInfo), buildRelations(principalInfo)); | ||
| try { | ||
| return entityRepository.save(newPrincipal); | ||
| } catch (EntityAlreadyExistsException e) { | ||
|
Check warning on line 95 in src/main/java/com/decathlon/idp_core/domain/service/principal/PrincipalProvisioningService.java
|
||
| // Handle race condition: another thread created the principal between our check | ||
| // and save | ||
| // Retry the read to return the existing principal | ||
| return entityRepository | ||
| .findByTemplateIdentifierAndIdentifier(PRINCIPAL_TEMPLATE_IDENTIFIER, | ||
| principalInfo.identifier()) | ||
| .orElseThrow(() -> new PrincipalCreationException( | ||
| "Principal concurrent creation detected but subsequent read failed for identifier: " | ||
| + principalInfo.identifier())); | ||
| } | ||
| } | ||
|
|
||
| /// Builds properties for the principal entity based on the provided | ||
| /// PrincipalInfo. | ||
| /// | ||
| /// **Business logic:** Includes the principal kind and any non-blank | ||
| /// attributes. | ||
| /// | ||
| /// @param principalInfo the principal information containing attributes | ||
| /// @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())); | ||
|
|
||
| principalInfo.attributes().forEach((key, value) -> { | ||
| if (value != null && !value.isBlank()) { | ||
| properties.add(new Property(null, key, value)); | ||
| } | ||
| }); | ||
|
|
||
| return properties; | ||
| } | ||
|
|
||
| /// Builds relations for the principal based on group memberships. | ||
| /// | ||
| /// **Business logic:** Only includes relations to existing teams. If a group | ||
| /// does not correspond to a team entity, it is ignored. | ||
| /// | ||
| /// @param principalInfo the principal information containing group memberships | ||
| /// @return list of relations to existing teams | ||
| private List<Relation> buildRelations(PrincipalInfo principalInfo) { | ||
| if (principalInfo.groups().isEmpty()) { | ||
| return List.of(); | ||
| } | ||
|
|
||
| // Batch query to find all existing teams in one DB round-trip | ||
| List<String> validGroups = entityRepository | ||
| .findAllByTemplateIdentifierAndIdentifierIn("team", principalInfo.groups()).stream() | ||
| .map(Entity::identifier).toList(); | ||
|
|
||
| if (validGroups.isEmpty()) { | ||
| return List.of(); | ||
| } | ||
|
|
||
| return List.of(new Relation(null, "member_of", "team", validGroups)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package com.decathlon.idp_core.infrastructure.adapters.api.auth; | ||
|
|
||
| import java.io.IOException; | ||
|
|
||
| import jakarta.servlet.FilterChain; | ||
| import jakarta.servlet.ServletException; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
|
|
||
| import org.springframework.lang.NonNull; | ||
| import org.springframework.security.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.model.principal.PrincipalInfo; | ||
| import com.decathlon.idp_core.domain.service.principal.PrincipalProvisioningService; | ||
| import com.decathlon.idp_core.infrastructure.adapters.api.principal.PrincipalExtractor; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| /// Infrastructure filter that triggers Just-In-Time (JIT) provisioning of principals. | ||
| /// | ||
| /// **Purpose:** Intercepts every authenticated request to ensure the authenticated | ||
| /// principal has a corresponding Entity in the catalog. This enables: | ||
| /// - Automatic user onboarding (no manual account creation) | ||
| /// - Real-time profile synchronization from identity provider | ||
| /// - Unified identity management for humans and service accounts | ||
| /// | ||
| /// **Design rationale:** Positioned after Spring Security authentication but before | ||
| /// controllers, ensuring JIT provisioning happens transparently for all API endpoints. | ||
| /// Failures are logged but don't block the request (fail-open for availability). | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class JitProvisioningFilter extends OncePerRequestFilter { | ||
|
|
||
| private final PrincipalExtractor principalExtractor; | ||
| private final PrincipalProvisioningService provisioningService; | ||
|
|
||
| @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() | ||
| && !isAnonymous(authentication)) { | ||
| provisionPrincipalSafely(authentication); | ||
| } | ||
|
|
||
| filterChain.doFilter(request, response); | ||
| } | ||
|
|
||
| private void provisionPrincipalSafely(Authentication authentication) { | ||
|
RVANDO12 marked this conversation as resolved.
|
||
| try { | ||
| PrincipalInfo principalInfo = principalExtractor.extractPrincipalInfo(authentication); | ||
| provisioningService.provisionPrincipal(principalInfo); | ||
|
|
||
| log.debug("JIT provisioning successful for principal: {} (kind: {})", | ||
| principalInfo.identifier(), principalInfo.kind()); | ||
| } 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 | ||
| log.warn("JIT provisioning failed for authenticated principal: {}", e.getMessage(), e); | ||
| } | ||
| } | ||
|
|
||
| private boolean isAnonymous(Authentication authentication) { | ||
| return authentication instanceof org.springframework.security.authentication.AnonymousAuthenticationToken; | ||
| } | ||
|
|
||
| @Override | ||
| protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { | ||
|
RVANDO12 marked this conversation as resolved.
|
||
| String path = request.getRequestURI(); | ||
| // Skip JIT provisioning for public endpoints | ||
| return path.startsWith("/actuator/") || path.startsWith("/swagger-ui/") | ||
| || path.startsWith("/v3/api-docs/") || path.equals("/"); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.