From b8a5c74db6be253ccecd7431231832c63b8a215b Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 18 Jun 2026 21:58:15 +0200 Subject: [PATCH 1/2] feat: deliver the security platform through the application starter (secure-by-default resource server + method security) --- pom.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pom.xml b/pom.xml index b1524b4..ec69798 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,20 @@ spring-boot-starter-security + + + org.fireflyframework + fireflyframework-security-resource-server + ${project.version} + + + org.fireflyframework + fireflyframework-security-method-policy + ${project.version} + + org.springframework.boot From 73e563ef017e58bb1e5f1b365ad6078225d2bf81 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 18 Jun 2026 23:02:16 +0200 Subject: [PATCH 2/2] =?UTF-8?q?refactor!:=20de-domain=20the=20application?= =?UTF-8?q?=20layer=20=E2=80=94=20remove=20firefly-oss=20party/contract/pr?= =?UTF-8?q?oduct=20+=20X-Party-Id=20+=20Security=20Center?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppContext is now product-agnostic (subject/tenantId/roles/permissions/attributes); identity is projected from the validated SecurityContextPort (fireflyframework-security), not the trusted X-Party-Id header. Deletes the Security Center SPI (SessionManager/SessionContext/SessionContextMapper + Contract/Product/Role/RoleScope DTOs) and @RequireContext; genericizes the resolvers and the resource controller. 180 tests green. BREAKING (clean break, no shims). --- .../application/aop/SecurityAspect.java | 53 ++-- .../ApplicationLayerAutoConfiguration.java | 35 ++- .../config/ApplicationLayerProperties.java | 32 +-- .../application/context/AppContext.java | 155 +++-------- .../context/AppSecurityContext.java | 88 +++---- .../context/ApplicationExecutionContext.java | 133 +++------- .../AbstractApplicationController.java | 97 ++++--- .../AbstractResourceController.java | 206 ++++++--------- .../resolver/AbstractContextResolver.java | 202 ++------------ .../application/resolver/ContextResolver.java | 94 +------ .../resolver/DefaultContextResolver.java | 242 +++-------------- .../AbstractSecurityAuthorizationService.java | 135 +++++----- .../AbstractSecurityConfiguration.java | 150 +++++------ .../DefaultSecurityAuthorizationService.java | 139 ++-------- .../SecurityAuthorizationService.java | 38 +-- .../security/annotation/RequireContext.java | 78 ------ .../security/annotation/Secure.java | 84 +++--- .../service/AbstractApplicationService.java | 74 ++---- .../application/spi/SessionContext.java | 47 ---- .../application/spi/SessionManager.java | 73 ------ .../application/spi/dto/ContractInfoDTO.java | 27 -- .../application/spi/dto/ProductInfoDTO.java | 24 -- .../application/spi/dto/RoleInfoDTO.java | 28 -- .../application/spi/dto/RoleScopeInfoDTO.java | 26 -- .../util/SessionContextMapper.java | 108 -------- .../ApplicationLayerPropertiesTest.java | 58 ++--- .../application/context/AppContextTest.java | 150 +++++------ .../context/AppSecurityContextTest.java | 98 +++---- .../ApplicationExecutionContextTest.java | 159 +++++------ .../AbstractApplicationControllerTest.java | 96 ++++--- .../AbstractResourceControllerTest.java | 174 +++++-------- .../ControllerIntegrationTest.java | 171 ++++++------ .../SecurityAspectIntegrationTest.java | 120 ++++----- .../SecurityAuthorizationIntegrationTest.java | 246 +++++++++--------- .../resolver/AbstractContextResolverTest.java | 228 +++++----------- ...tractSecurityAuthorizationServiceTest.java | 79 +++--- .../AbstractApplicationServiceTest.java | 151 +++-------- 37 files changed, 1340 insertions(+), 2758 deletions(-) delete mode 100644 src/main/java/org/fireflyframework/common/application/security/annotation/RequireContext.java delete mode 100644 src/main/java/org/fireflyframework/common/application/spi/SessionContext.java delete mode 100644 src/main/java/org/fireflyframework/common/application/spi/SessionManager.java delete mode 100644 src/main/java/org/fireflyframework/common/application/spi/dto/ContractInfoDTO.java delete mode 100644 src/main/java/org/fireflyframework/common/application/spi/dto/ProductInfoDTO.java delete mode 100644 src/main/java/org/fireflyframework/common/application/spi/dto/RoleInfoDTO.java delete mode 100644 src/main/java/org/fireflyframework/common/application/spi/dto/RoleScopeInfoDTO.java delete mode 100644 src/main/java/org/fireflyframework/common/application/util/SessionContextMapper.java diff --git a/src/main/java/org/fireflyframework/common/application/aop/SecurityAspect.java b/src/main/java/org/fireflyframework/common/application/aop/SecurityAspect.java index ff41c2f..298b238 100644 --- a/src/main/java/org/fireflyframework/common/application/aop/SecurityAspect.java +++ b/src/main/java/org/fireflyframework/common/application/aop/SecurityAspect.java @@ -17,7 +17,6 @@ package org.fireflyframework.common.application.aop; import org.fireflyframework.common.application.config.ApplicationLayerProperties; -import org.fireflyframework.common.application.context.AppContext; import org.fireflyframework.common.application.context.AppSecurityContext; import org.fireflyframework.common.application.context.ApplicationExecutionContext; import org.fireflyframework.common.application.security.EndpointSecurityRegistry; @@ -41,10 +40,10 @@ /** * Aspect for intercepting and processing @Secure annotations. * Handles security checks before method execution. - * + * *

This aspect intercepts methods annotated with @Secure and performs * authorization checks using the SecurityAuthorizationService.

- * + * * @author Firefly Development Team * @since 1.0.0 */ @@ -52,14 +51,14 @@ @RequiredArgsConstructor @Slf4j public class SecurityAspect { - + private final SecurityAuthorizationService authorizationService; private final EndpointSecurityRegistry endpointSecurityRegistry; private final ApplicationLayerProperties properties; - + /** * Intercepts methods annotated with @Secure. - * + * * @param joinPoint the join point * @param secure the secure annotation * @return the method result @@ -68,18 +67,18 @@ public class SecurityAspect { @Around("@annotation(secure)") public Object secureMethod(ProceedingJoinPoint joinPoint, Secure secure) throws Throwable { log.debug("Intercepting @Secure method: {}", joinPoint.getSignature().getName()); - + // Extract ApplicationExecutionContext from method arguments ApplicationExecutionContext executionContext = findExecutionContext(joinPoint.getArgs()); if (executionContext == null) { log.warn("No ApplicationExecutionContext found in method arguments, skipping security check"); return joinPoint.proceed(); } - + // Check EndpointSecurityRegistry first (explicit configuration overrides annotations) String endpoint = extractEndpoint(joinPoint); String httpMethod = extractHttpMethod(joinPoint); - + AppSecurityContext securityContext = endpointSecurityRegistry .getEndpointSecurity(endpoint, httpMethod) .map(explicitSecurity -> { @@ -90,7 +89,7 @@ public Object secureMethod(ProceedingJoinPoint joinPoint, Secure secure) throws log.debug("Using ANNOTATION security configuration for {} {}", httpMethod, endpoint); return buildSecurityContext(secure, joinPoint, endpoint, httpMethod); }); - + // Check if security is disabled if (!properties.getSecurity().isEnabled()) { log.debug("Security is disabled, allowing access to {}", endpoint); @@ -102,13 +101,13 @@ public Object secureMethod(ProceedingJoinPoint joinPoint, Secure secure) throws .flatMap(authorizedContext -> { if (!authorizedContext.isAuthorized()) { if (!properties.getSecurity().isEnforce()) { - log.warn("ACCESS WOULD BE DENIED (enforce=false) for party: {} to endpoint: {}, reason: {}", - executionContext.getPartyId(), + log.warn("ACCESS WOULD BE DENIED (enforce=false) for subject: {} to endpoint: {}, reason: {}", + executionContext.getSubject(), securityContext.getEndpoint(), authorizedContext.getAuthorizationFailureReason()); } else { - log.warn("Access denied for party: {} to endpoint: {}, reason: {}", - executionContext.getPartyId(), + log.warn("Access denied for subject: {} to endpoint: {}, reason: {}", + executionContext.getSubject(), securityContext.getEndpoint(), authorizedContext.getAuthorizationFailureReason()); return Mono.error(new AccessDeniedException( @@ -140,10 +139,10 @@ public Object secureMethod(ProceedingJoinPoint joinPoint, Secure secure) throws .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()) .block(); } - + /** * Intercepts classes annotated with @Secure. - * + * * @param joinPoint the join point * @param secure the secure annotation * @return the method result @@ -153,10 +152,10 @@ public Object secureMethod(ProceedingJoinPoint joinPoint, Secure secure) throws public Object secureClass(ProceedingJoinPoint joinPoint, Secure secure) throws Throwable { return secureMethod(joinPoint, secure); } - + /** * Finds ApplicationExecutionContext in method arguments. - * + * * @param args the method arguments * @return the execution context or null if not found */ @@ -164,19 +163,19 @@ private ApplicationExecutionContext findExecutionContext(Object[] args) { if (args == null) { return null; } - + for (Object arg : args) { if (arg instanceof ApplicationExecutionContext) { return (ApplicationExecutionContext) arg; } } - + return null; } - + /** * Builds AppSecurityContext from @Secure annotation. - * + * * @param secure the annotation * @param joinPoint the join point * @param endpoint the endpoint path @@ -188,11 +187,9 @@ private AppSecurityContext buildSecurityContext(Secure secure, ProceedingJoinPoi Set roles = new HashSet<>(Arrays.asList(secure.roles())); Set permissions = new HashSet<>(Arrays.asList(secure.permissions())); - // Use SECURITY_CENTER only if both the global property and annotation agree - boolean useSecurityCenter = properties.getSecurity().isUseSecurityCenter() && secure.useSecurityCenter(); - AppSecurityContext.SecurityConfigSource configSource = useSecurityCenter - ? AppSecurityContext.SecurityConfigSource.SECURITY_CENTER - : AppSecurityContext.SecurityConfigSource.ANNOTATION; + // Authorization is driven by the annotation's declared roles/permissions evaluated against + // the validated principal's authorities (no external Security Center). + AppSecurityContext.SecurityConfigSource configSource = AppSecurityContext.SecurityConfigSource.ANNOTATION; return AppSecurityContext.builder() .endpoint(endpoint) @@ -204,7 +201,7 @@ private AppSecurityContext buildSecurityContext(Secure secure, ProceedingJoinPoi .configSource(configSource) .build(); } - + /** * Extracts endpoint path from join point by reading Spring MVC mapping annotations. * Falls back to class.method signature if no mapping annotations found. diff --git a/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerAutoConfiguration.java b/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerAutoConfiguration.java index 06fb8b5..d85e0bf 100644 --- a/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerAutoConfiguration.java +++ b/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerAutoConfiguration.java @@ -28,11 +28,10 @@ import org.fireflyframework.common.application.security.EndpointSecurityRegistry; import org.fireflyframework.common.application.security.JwtClaimsRoleExtractor; import org.fireflyframework.common.application.security.SecurityAuthorizationService; -import org.fireflyframework.common.application.spi.SessionContext; -import org.fireflyframework.common.application.spi.SessionManager; +import org.fireflyframework.security.spi.SecurityContextPort; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -59,6 +58,11 @@ *
  • Banner (Firefly Application Layer banner)
  • * * + *

    The request context is projected from the validated security principal + * exposed by the {@code fireflyframework-security} platform ({@link SecurityContextPort}). When a + * {@link SecurityContextPort} bean is present the library wires a {@link DefaultContextResolver}; + * otherwise an application may contribute its own {@link ContextResolver}.

    + * * @author Firefly Development Team * @since 1.0.0 */ @@ -152,17 +156,20 @@ public SecurityAspect securityAspect(SecurityAuthorizationService authorizationS /** * Creates the default context resolver bean. - * Uses {@link ObjectProvider} for the optional {@link SessionManager} dependency. * - * @param sessionManagerProvider optional session manager provider + *

    Wired only when a {@link SecurityContextPort} bean is available (typically contributed by the + * {@code fireflyframework-security} platform). The resolver projects the request context from the + * validated security principal — no trusted transport header is read.

    + * + * @param securityContextPort the platform-provided accessor for the current validated principal * @return DefaultContextResolver instance */ @Bean + @ConditionalOnBean(SecurityContextPort.class) @ConditionalOnMissingBean(ContextResolver.class) - public DefaultContextResolver defaultContextResolver( - ObjectProvider> sessionManagerProvider) { - log.info("Creating DefaultContextResolver bean"); - return new DefaultContextResolver(sessionManagerProvider.getIfAvailable()); + public DefaultContextResolver defaultContextResolver(SecurityContextPort securityContextPort) { + log.info("Creating DefaultContextResolver bean (backed by SecurityContextPort)"); + return new DefaultContextResolver(securityContextPort); } /** @@ -179,17 +186,17 @@ public DefaultConfigResolver defaultConfigResolver() { /** * Creates the default security authorization service bean. - * Uses {@link ObjectProvider} for the optional {@link SessionManager} dependency. * - * @param sessionManagerProvider optional session manager provider + *

    Authorization is evaluated solely from the roles and permissions already resolved into the + * {@link org.fireflyframework.common.application.context.AppContext}.

    + * * @return DefaultSecurityAuthorizationService instance */ @Bean @ConditionalOnMissingBean(SecurityAuthorizationService.class) - public DefaultSecurityAuthorizationService defaultSecurityAuthorizationService( - ObjectProvider> sessionManagerProvider) { + public DefaultSecurityAuthorizationService defaultSecurityAuthorizationService() { log.info("Creating DefaultSecurityAuthorizationService bean"); - return new DefaultSecurityAuthorizationService(sessionManagerProvider.getIfAvailable()); + return new DefaultSecurityAuthorizationService(); } /** diff --git a/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerProperties.java b/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerProperties.java index fe0d55b..c64c681 100644 --- a/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerProperties.java +++ b/src/main/java/org/fireflyframework/common/application/config/ApplicationLayerProperties.java @@ -22,19 +22,19 @@ /** * Configuration properties for the Application Layer. - * + * *

    Configure in application.yml:

    *
      * firefly:
      *   application:
      *     security:
      *       enabled: true
    - *       use-security-center: true
    + *       use-policy-engine: true
      *     context:
      *       cache-enabled: true
      *       cache-ttl: 300
      * 
    - * + * * @author Firefly Development Team * @since 1.0.0 */ @@ -42,22 +42,22 @@ @Validated @ConfigurationProperties(prefix = "firefly.application") public class ApplicationLayerProperties { - + /** * Security configuration */ private Security security = new Security(); - + /** * Context resolution configuration */ private Context context = new Context(); - + /** * Configuration management settings */ private Config config = new Config(); - + @Data public static class Security { /** @@ -72,9 +72,11 @@ public static class Security { private boolean enforce = true; /** - * Whether to use SecurityCenter for authorization + * Whether to delegate complex authorization decisions to an external policy engine. + * When {@code false}, authorization is evaluated solely from the roles and permissions + * already resolved into the request {@code AppContext}. */ - private boolean useSecurityCenter = true; + private boolean usePolicyEngine = true; /** * Claim path in JWT token for extracting roles. @@ -97,37 +99,37 @@ public static class Security { */ private boolean failOnMissing = false; } - + @Data public static class Context { /** * Whether context caching is enabled */ private boolean cacheEnabled = true; - + /** * Context cache TTL in seconds */ private int cacheTtl = 300; - + /** * Maximum cache size */ private int cacheMaxSize = 1000; } - + @Data public static class Config { /** * Whether config caching is enabled */ private boolean cacheEnabled = true; - + /** * Config cache TTL in seconds */ private int cacheTtl = 600; - + /** * Whether to refresh config on startup */ diff --git a/src/main/java/org/fireflyframework/common/application/context/AppContext.java b/src/main/java/org/fireflyframework/common/application/context/AppContext.java index 282f358..77fabef 100644 --- a/src/main/java/org/fireflyframework/common/application/context/AppContext.java +++ b/src/main/java/org/fireflyframework/common/application/context/AppContext.java @@ -20,158 +20,71 @@ import lombok.Value; import lombok.With; -import jakarta.validation.constraints.NotNull; +import java.util.Map; import java.util.Set; import java.util.UUID; /** - * Immutable business context container for application requests. - * Contains information about the party (customer), contract, and product involved in the request. - * - *

    This class represents the "who", "what", and "where" of a business operation: - *

      - *
    • partyId: Who is making the request (customer/user)
    • - *
    • contractId: What contract/agreement is involved
    • - *
    • productId: What product is being accessed/modified
    • - *
    - *

    - * - *

    This context is used for authorization decisions and domain logic execution.

    - * - * @author Firefly Development Team - * @since 1.0.0 + * Immutable, product-agnostic request context. It carries the authenticated {@code subject}, an + * optional generic {@code tenantId}, the granted {@code roles}/{@code permissions}, and an open + * {@code attributes} map. It deliberately holds no product-domain concepts — + * products carry their own model (e.g. party/contract/product references) inside {@link #attributes}. + * + *

    The {@code subject} and authorities are a projection of the validated security principal + * (see the {@code fireflyframework-security} platform), not of any trusted transport header. */ @Value @Builder(toBuilder = true) @With public class AppContext { - - /** - * Unique identifier of the party (customer) making the request. - * This comes from common-platform-customer-mgmt. - */ - @NotNull - UUID partyId; - - /** - * Unique identifier of the contract associated with this request. - * This comes from common-platform-contract-mgmt. - * Optional for operations that don't require a contract context. - */ - UUID contractId; - - /** - * Unique identifier of the product being accessed or modified. - * This comes from common-platform-product-mgmt. - * Optional for operations that don't require a product context. - */ - UUID productId; - - /** - * Roles that the party has in the context of this contract/product. - * Used for authorization decisions. - */ + + /** Authenticated subject identifier (OIDC {@code sub}); from the validated security principal. */ + String subject; + + /** Generic tenant discriminator; {@code null} when single-tenant. */ + UUID tenantId; + + /** Granted authorities/roles, used for authorization decisions. */ Set roles; - - /** - * Permissions that the party has in this context. - * Derived from roles and used for fine-grained authorization. - */ + + /** Granted fine-grained permissions/scopes. */ Set permissions; - - /** - * The tenant/organization this context belongs to. - * Links to AppConfig's tenantId. - */ - UUID tenantId; - - /** - * Additional context-specific attributes. - * Can be used to store domain-specific context information. - */ - java.util.Map attributes; - - /** - * Checks if the context has a specific role - * - * @param role the role to check - * @return true if the role is present - */ + + /** Open, product-defined attributes (also the ABAC input bag). */ + Map attributes; + public boolean hasRole(String role) { return roles != null && roles.contains(role); } - - /** - * Checks if the context has any of the specified roles - * - * @param roles the roles to check - * @return true if any of the roles are present - */ - public boolean hasAnyRole(String... roles) { - if (this.roles == null || roles == null) { + + public boolean hasAnyRole(String... candidates) { + if (roles == null || candidates == null) { return false; } - for (String role : roles) { - if (this.roles.contains(role)) { + for (String role : candidates) { + if (roles.contains(role)) { return true; } } return false; } - - /** - * Checks if the context has all of the specified roles - * - * @param roles the roles to check - * @return true if all roles are present - */ - public boolean hasAllRoles(String... roles) { - if (this.roles == null || roles == null) { + + public boolean hasAllRoles(String... candidates) { + if (roles == null || candidates == null) { return false; } - for (String role : roles) { - if (!this.roles.contains(role)) { + for (String role : candidates) { + if (!roles.contains(role)) { return false; } } return true; } - - /** - * Checks if the context has a specific permission - * - * @param permission the permission to check - * @return true if the permission is present - */ + public boolean hasPermission(String permission) { return permissions != null && permissions.contains(permission); } - - /** - * Checks if this context has a contract association - * - * @return true if contractId is present - */ - public boolean hasContract() { - return contractId != null; - } - - /** - * Checks if this context has a product association - * - * @return true if productId is present - */ - public boolean hasProduct() { - return productId != null; - } - - /** - * Gets an attribute from the context - * - * @param key the attribute key - * @param the expected type - * @return the attribute value or null if not present - */ + @SuppressWarnings("unchecked") public T getAttribute(String key) { return attributes != null ? (T) attributes.get(key) : null; diff --git a/src/main/java/org/fireflyframework/common/application/context/AppSecurityContext.java b/src/main/java/org/fireflyframework/common/application/context/AppSecurityContext.java index 3b302ff..e82d0b3 100644 --- a/src/main/java/org/fireflyframework/common/application/context/AppSecurityContext.java +++ b/src/main/java/org/fireflyframework/common/application/context/AppSecurityContext.java @@ -26,18 +26,18 @@ /** * Immutable security context for application requests. * Contains security-related information including endpoint-role mappings and authorization results. - * - *

    This class is used in conjunction with the Firefly SecurityCenter to determine - * whether a party has sufficient rights to perform an operation based on their role - * in a contract/product context.

    - * + * + *

    This class describes the security requirements of an endpoint (the roles and permissions it + * demands) together with the outcome of evaluating those requirements against the authenticated + * subject's granted authorities.

    + * *

    Security context can be configured in two ways: *

      *
    • Declarative: Using @Secure annotation on endpoints/controllers
    • *
    • Programmatic: Explicit endpoint-role mapping registration
    • *
    *

    - * + * * @author Firefly Development Team * @since 1.0.0 */ @@ -45,105 +45,105 @@ @Builder(toBuilder = true) @With public class AppSecurityContext { - + /** * The endpoint being accessed (e.g., "/api/v1/accounts/{id}/transfer") */ String endpoint; - + /** * The HTTP method being used (GET, POST, PUT, DELETE, etc.) */ String httpMethod; - + /** * Roles required to access this endpoint */ Set requiredRoles; - + /** * Permissions required to access this endpoint */ Set requiredPermissions; - + /** * Whether authorization was successful */ boolean authorized; - + /** * Reason for authorization failure (if applicable) */ String authorizationFailureReason; - + /** - * Source of the security configuration (ANNOTATION, EXPLICIT_MAP, SECURITY_CENTER) + * Source of the security configuration (ANNOTATION, EXPLICIT_MAP, POLICY, DEFAULT) */ SecurityConfigSource configSource; - + /** * Additional security attributes */ Map securityAttributes; - + /** * Whether this endpoint requires authentication */ @Builder.Default boolean requiresAuthentication = true; - + /** * Whether this endpoint allows anonymous access */ @Builder.Default boolean allowAnonymous = false; - + /** - * Custom security evaluation result from SecurityCenter + * Custom security evaluation result from the policy engine */ SecurityEvaluationResult evaluationResult; - + /** * Checks if the security context requires any roles - * + * * @return true if roles are required */ public boolean hasRequiredRoles() { return requiredRoles != null && !requiredRoles.isEmpty(); } - + /** * Checks if the security context requires any permissions - * + * * @return true if permissions are required */ public boolean hasRequiredPermissions() { return requiredPermissions != null && !requiredPermissions.isEmpty(); } - + /** * Checks if a specific role is required - * + * * @param role the role to check * @return true if the role is required */ public boolean requiresRole(String role) { return requiredRoles != null && requiredRoles.contains(role); } - + /** * Checks if a specific permission is required - * + * * @param permission the permission to check * @return true if the permission is required */ public boolean requiresPermission(String permission) { return requiredPermissions != null && requiredPermissions.contains(permission); } - + /** * Gets a security attribute - * + * * @param key the attribute key * @param the expected type * @return the attribute value or null if not found @@ -152,7 +152,7 @@ public boolean requiresPermission(String permission) { public T getSecurityAttribute(String key) { return securityAttributes != null ? (T) securityAttributes.get(key) : null; } - + /** * Source of security configuration */ @@ -161,59 +161,59 @@ public enum SecurityConfigSource { * Security configuration from @Secure annotation */ ANNOTATION, - + /** * Security configuration from explicit endpoint-role mapping */ EXPLICIT_MAP, - + /** - * Security configuration from Firefly SecurityCenter + * Security configuration from an external policy engine */ - SECURITY_CENTER, - + POLICY, + /** * Security configuration from default/fallback rules */ DEFAULT } - + /** - * Result of security evaluation from SecurityCenter + * Result of a policy-engine security evaluation */ @Value @Builder(toBuilder = true) @With public static class SecurityEvaluationResult { - + /** * Whether access is granted */ boolean granted; - + /** * Reason for the decision */ String reason; - + /** * Rule or policy that was evaluated */ String evaluatedPolicy; - + /** * Additional evaluation details */ Map evaluationDetails; - + /** * Timestamp of evaluation */ java.time.Instant evaluatedAt; - + /** * Gets an evaluation detail - * + * * @param key the detail key * @param the expected type * @return the detail value or null if not found diff --git a/src/main/java/org/fireflyframework/common/application/context/ApplicationExecutionContext.java b/src/main/java/org/fireflyframework/common/application/context/ApplicationExecutionContext.java index 5d69f5d..6a80937 100644 --- a/src/main/java/org/fireflyframework/common/application/context/ApplicationExecutionContext.java +++ b/src/main/java/org/fireflyframework/common/application/context/ApplicationExecutionContext.java @@ -21,136 +21,63 @@ import lombok.With; import jakarta.validation.constraints.NotNull; +import java.util.UUID; /** - * Complete execution context for an application request. - * Aggregates all contextual information needed for request processing. - * - *

    This is the main context object that flows through the application layer, - * containing all necessary information for:

    - *
      - *
    • Business context and authorization (AppContext)
    • - *
    • Tenant configuration and providers (AppConfig)
    • - *
    • Security and access control (AppSecurityContext)
    • - *
    - * - *

    Note: Application metadata ({@code @FireflyApplication}) is now application-level (singleton), - * not per-request, and accessed via {@code AppMetadataProvider}.

    - * - *

    Usage example:

    - *
    - * ApplicationExecutionContext context = ApplicationExecutionContext.builder()
    - *     .context(appContext)
    - *     .config(appConfig)
    - *     .securityContext(securityContext)
    - *     .build();
    - * 
    - * - * @author Firefly Development Team - * @since 1.0.0 + * Complete execution context for an application request: the product-agnostic {@link AppContext}, + * the tenant {@link AppConfig}, and the {@link AppSecurityContext}. */ @Value @Builder(toBuilder = true) @With public class ApplicationExecutionContext { - - /** - * Business context (partyId, contractId, productId, roles, permissions) - */ + + /** Product-agnostic request context (subject, tenant, roles, permissions, attributes). */ @NotNull AppContext context; - - /** - * Application configuration (tenantId, providers, feature flags) - */ + + /** Application/tenant configuration (tenantId, providers, feature flags). */ @NotNull AppConfig config; - - /** - * Security context (endpoint security, authorization results) - */ + + /** Security context (endpoint security, authorization results). */ AppSecurityContext securityContext; - + /** - * Creates a minimal execution context with only required fields - * - * @param partyId the party ID - * @param tenantId the tenant ID + * Creates a minimal execution context with only the required fields. + * + * @param subject the authenticated subject + * @param tenantId the tenant id * @return a new ApplicationExecutionContext */ - public static ApplicationExecutionContext createMinimal(java.util.UUID partyId, java.util.UUID tenantId) { + public static ApplicationExecutionContext createMinimal(String subject, UUID tenantId) { return ApplicationExecutionContext.builder() - .context(AppContext.builder() - .partyId(partyId) - .tenantId(tenantId) - .build()) - .config(AppConfig.builder() - .tenantId(tenantId) - .build()) + .context(AppContext.builder().subject(subject).tenantId(tenantId).build()) + .config(AppConfig.builder().tenantId(tenantId).build()) .build(); } - - /** - * Gets the tenant ID from the config - * - * @return the tenant ID - */ - public java.util.UUID getTenantId() { + + /** @return the tenant id from the config. */ + public UUID getTenantId() { return config.getTenantId(); } - - /** - * Gets the party ID from the context - * - * @return the party ID - */ - public java.util.UUID getPartyId() { - return context.getPartyId(); - } - - /** - * Gets the contract ID from the context - * - * @return the contract ID (may be null) - */ - public java.util.UUID getContractId() { - return context.getContractId(); - } - - /** - * Gets the product ID from the context - * - * @return the product ID (may be null) - */ - public java.util.UUID getProductId() { - return context.getProductId(); + + /** @return the authenticated subject from the context. */ + public String getSubject() { + return context.getSubject(); } - - /** - * Checks if the context is authorized - * - * @return true if security context exists and is authorized - */ + + /** @return true if a security context exists and authorization succeeded. */ public boolean isAuthorized() { return securityContext != null && securityContext.isAuthorized(); } - - /** - * Checks if the context has a specific role - * - * @param role the role to check - * @return true if the role is present in the context - */ + + /** @return true if the context holds the given role. */ public boolean hasRole(String role) { return context.hasRole(role); } - - /** - * Checks if a feature is enabled for this tenant - * - * @param feature the feature flag name - * @return true if the feature is enabled - */ + + /** @return true if the feature is enabled for this tenant. */ public boolean isFeatureEnabled(String feature) { return config.isFeatureEnabled(feature); } diff --git a/src/main/java/org/fireflyframework/common/application/controller/AbstractApplicationController.java b/src/main/java/org/fireflyframework/common/application/controller/AbstractApplicationController.java index f6724cd..1921b20 100644 --- a/src/main/java/org/fireflyframework/common/application/controller/AbstractApplicationController.java +++ b/src/main/java/org/fireflyframework/common/application/controller/AbstractApplicationController.java @@ -17,6 +17,7 @@ package org.fireflyframework.common.application.controller; import org.fireflyframework.common.application.context.ApplicationExecutionContext; +import org.fireflyframework.common.application.context.AppContext; import org.fireflyframework.common.application.resolver.ContextResolver; import org.fireflyframework.common.application.resolver.ConfigResolver; import lombok.extern.slf4j.Slf4j; @@ -26,100 +27,96 @@ /** *

    Abstract Base Controller for Application-Layer Endpoints

    - * - *

    This base class is for controllers that operate on application-level resources - * without requiring a contract or product context. Perfect for onboarding, product catalogs, - * or any operation that only needs the authenticated party identity.

    - * + * + *

    This base class is for controllers that operate on application-level resources. + * It resolves the authenticated identity and tenant configuration into a single + * {@link ApplicationExecutionContext}, perfect for onboarding, catalogs, or any operation that only + * needs the authenticated subject and tenant.

    + * *

    When to Use

    *

    Extend this class when building REST endpoints for:

    *
      - *
    • Onboarding: Customer registration, KYC verification
    • - *
    • Product Catalog: Listing available products for a party
    • - *
    • Party Profile: Managing party information, preferences
    • - *
    • Contract Creation: Requesting new contracts or products
    • + *
    • Onboarding: Registration and verification flows
    • + *
    • Catalogs: Listing resources available to the subject
    • + *
    • Profile: Managing subject information and preferences
    • *
    - * + * *

    Architecture

    *

    This controller automatically resolves:

    *
      - *
    • Party ID: Extracted from Istio-injected X-Party-Id header
    • - *
    • Tenant ID: Extracted from Istio-injected X-Tenant-Id header
    • - *
    • Roles/Permissions: Enriched from platform SDKs
    • - *
    • Tenant Config: Loaded from configuration service
    • + *
    • Subject: The authenticated subject from the validated security context
    • + *
    • Tenant ID: The tenant the subject belongs to
    • + *
    • Roles/Permissions: Authorities and scopes resolved for the subject
    • + *
    • Tenant Config: Loaded from the configuration service
    • *
    - * + * *

    Quick Example

    *
      * {@code
      * @RestController
      * @RequestMapping("/api/v1/onboarding")
      * public class OnboardingController extends AbstractApplicationController {
    - *     
    + *
      *     @Autowired
      *     private OnboardingApplicationService onboardingService;
    - *     
    + *
      *     @PostMapping("/start")
    - *     @Secure(requireParty = true)
    + *     @Secure
      *     public Mono startOnboarding(
      *             @RequestBody OnboardingRequest request,
      *             ServerWebExchange exchange) {
    - *         
    - *         // Automatically resolved context with party + tenant
    + *
    + *         // Automatically resolved context with subject + tenant
      *         return resolveExecutionContext(exchange)
      *             .flatMap(context -> onboardingService.startOnboarding(context, request));
      *     }
      * }
      * }
      * 
    - * + * *

    What You Get

    *
      *
    • Automatic Context Resolution: {@link #resolveExecutionContext(ServerWebExchange)}
    • - *
    • Party + Tenant Only: No contract or product IDs required
    • + *
    • Subject + Tenant: The authenticated identity and its tenant
    • *
    • Full Config Access: Tenant configuration, feature flags, providers
    • *
    • Security Ready: Works seamlessly with {@code @Secure} annotations
    • *
    - * + * * @author Firefly Development Team * @since 1.0.0 - * @see AbstractResourceController For resource endpoints (contract + product required) + * @see AbstractResourceController For a thin generic base controller */ @Slf4j public abstract class AbstractApplicationController { - + @Autowired private ContextResolver contextResolver; - + @Autowired private ConfigResolver configResolver; - + /** - * Resolves the full application execution context for application-layer endpoints. - * + * Resolves the full {@link ApplicationExecutionContext} for application-layer endpoints. + * *

    This method:

    *
      - *
    1. Extracts party ID and tenant ID from Istio headers
    2. - *
    3. Enriches with roles and permissions from platform SDKs
    4. - *
    5. Loads tenant configuration
    6. - *
    7. Returns complete {@link ApplicationExecutionContext}
    8. + *
    9. Resolves the {@link AppContext} (subject, tenant, roles, permissions) from the + * validated security context
    10. + *
    11. Loads the tenant configuration
    12. + *
    13. Returns the complete {@link ApplicationExecutionContext}
    14. *
    - * - *

    Note: Contract and product IDs will be null - * since this is an application-layer endpoint.

    - * + * * @param exchange the server web exchange - * @return Mono of ApplicationExecutionContext with party and tenant context + * @return Mono of ApplicationExecutionContext with subject and tenant context */ protected Mono resolveExecutionContext(ServerWebExchange exchange) { - log.debug("Resolving application-layer execution context (no contract/product)"); - - // Pass null for contract and product since this is application-layer only - return contextResolver.resolveContext(exchange, null, null) + log.debug("Resolving application-layer execution context"); + + return contextResolver.resolveContext(exchange) .flatMap(appContext -> { - log.debug("Resolved application context: party={}, tenant={}", - appContext.getPartyId(), appContext.getTenantId()); - + log.debug("Resolved application context: subject={}, tenant={}", + appContext.getSubject(), appContext.getTenantId()); + return configResolver.resolveConfig(appContext.getTenantId()) .map(appConfig -> ApplicationExecutionContext.builder() .context(appContext) @@ -129,16 +126,16 @@ protected Mono resolveExecutionContext(ServerWebExc .doOnSuccess(ctx -> log.debug("Successfully resolved application-layer execution context")) .doOnError(error -> log.error("Failed to resolve application-layer execution context", error)); } - + /** - * Logs the current operation with party context. - * + * Logs the current operation. + * *

    Convenience method for consistent, structured logging.

    - * + * * @param exchange the server web exchange * @param operation a short description of the operation (e.g., "startOnboarding", "submitKYC") */ protected void logOperation(ServerWebExchange exchange, String operation) { - log.info("[Party Operation] {}", operation); + log.info("[Operation] {}", operation); } } diff --git a/src/main/java/org/fireflyframework/common/application/controller/AbstractResourceController.java b/src/main/java/org/fireflyframework/common/application/controller/AbstractResourceController.java index 3b38caa..06afcca 100644 --- a/src/main/java/org/fireflyframework/common/application/controller/AbstractResourceController.java +++ b/src/main/java/org/fireflyframework/common/application/controller/AbstractResourceController.java @@ -17,6 +17,7 @@ package org.fireflyframework.common.application.controller; import org.fireflyframework.common.application.context.ApplicationExecutionContext; +import org.fireflyframework.common.application.context.AppContext; import org.fireflyframework.common.application.resolver.ContextResolver; import org.fireflyframework.common.application.resolver.ConfigResolver; import lombok.extern.slf4j.Slf4j; @@ -24,196 +25,139 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; -import java.util.UUID; - /** *

    Abstract Base Controller for Resource Endpoints

    - * - *

    This base class is for controllers that operate on contract and product resources. - * It automatically resolves the full application context including party, tenant, contract, and product. - * Both contractId and productId are REQUIRED - this controller enforces the complete resource hierarchy.

    - * + * + *

    This base class is a thin, product-agnostic foundation for REST controllers. It resolves the + * request {@link AppContext} (subject, tenant, roles, permissions) from the validated security + * context via the {@link ContextResolver}, and loads the tenant {@link org.fireflyframework.common.application.context.AppConfig} + * via the {@link ConfigResolver}, exposing both as a single {@link ApplicationExecutionContext}.

    + * *

    When to Use

    - *

    Extend this class when building REST endpoints that operate on resources within a contract+product scope:

    - *
      - *
    • Accounts: {@code /contracts/{contractId}/products/{productId}/accounts}
    • - *
    • Transactions: {@code /contracts/{contractId}/products/{productId}/transactions}
    • - *
    • Balances: {@code /contracts/{contractId}/products/{productId}/balances}
    • - *
    • Cards: {@code /contracts/{contractId}/products/{productId}/cards}
    • - *
    • Limits: {@code /contracts/{contractId}/products/{productId}/limits}
    • - *
    • Beneficiaries: {@code /contracts/{contractId}/products/{productId}/beneficiaries}
    • - *
    - * + *

    Extend this class when building REST endpoints that need the authenticated identity and tenant + * configuration. There is no built-in resource hierarchy or scoping — model any + * domain-specific path segments with your own {@code @PathVariable} parameters.

    + * *

    Architecture

    *

    This controller automatically resolves:

    *
      - *
    • Party ID: From Istio header X-Party-Id
    • - *
    • Tenant ID: Dynamically fetched from config-mgmt using party ID
    • - *
    • Contract ID: From {@code @PathVariable UUID contractId} (REQUIRED)
    • - *
    • Product ID: From {@code @PathVariable UUID productId} (REQUIRED)
    • - *
    • Roles/Permissions: Enriched from FireflySessionManager based on party+contract+product
    • - *
    • Tenant Config: Loaded from configuration service
    • + *
    • Subject: The authenticated subject from the validated security context
    • + *
    • Tenant ID: The tenant the subject belongs to
    • + *
    • Roles/Permissions: Authorities and scopes resolved for the subject
    • + *
    • Tenant Config: Loaded from the configuration service
    • *
    - * + * *

    Quick Example

    *
      * {@code
      * @RestController
    - * @RequestMapping("/api/v1/contracts/{contractId}/products/{productId}/transactions")
    + * @RequestMapping("/api/v1/transactions")
      * public class TransactionController extends AbstractResourceController {
    - *     
    + *
      *     @Autowired
      *     private TransactionApplicationService transactionService;
    - *     
    + *
      *     @GetMapping
    - *     @Secure(requireParty = true, requireContract = true, requireProduct = true, requireRole = "transaction:viewer")
    - *     public Mono> listTransactions(
    - *             @PathVariable UUID contractId,
    - *             @PathVariable UUID productId,
    - *             ServerWebExchange exchange) {
    - *         
    - *         // Automatically resolved context with party + tenant + contract + product
    - *         return resolveExecutionContext(exchange, contractId, productId)
    + *     @Secure(requireRole = "transaction:viewer")
    + *     public Mono> listTransactions(ServerWebExchange exchange) {
    + *
    + *         // Automatically resolved context with subject + tenant + roles + permissions
    + *         return resolveExecutionContext(exchange)
      *             .flatMap(context -> transactionService.listTransactions(context));
      *     }
      * }
      * }
      * 
    - * + * *

    What You Get

    *
      - *
    • Automatic Context Resolution: {@link #resolveExecutionContext(ServerWebExchange, UUID, UUID)}
    • - *
    • Complete Resource Hierarchy: Party + Tenant + Contract + Product (all required)
    • - *
    • Validation: {@link #requireContext(UUID, UUID)} ensures both IDs are not null
    • + *
    • Automatic Context Resolution: {@link #resolveExecutionContext(ServerWebExchange)}
    • + *
    • Raw Context Access: {@link #resolveContext(ServerWebExchange)}
    • *
    • Full Config Access: Tenant configuration, feature flags, providers
    • *
    • Security Ready: Works seamlessly with {@code @Secure} annotations
    • *
    - * + * * @author Firefly Development Team * @since 1.0.0 - * @see AbstractApplicationController For application-layer endpoints (no contract/product) + * @see AbstractApplicationController For application-layer endpoints */ @Slf4j public abstract class AbstractResourceController { - + @Autowired private ContextResolver contextResolver; - + @Autowired private ConfigResolver configResolver; - + + /** + * Resolves the raw {@link AppContext} for the request. + * + *

    Delegates to the configured {@link ContextResolver}, which derives the subject, tenant, + * roles and permissions from the validated security context.

    + * + * @param exchange the server web exchange + * @return Mono of AppContext (subject, tenant, roles, permissions, attributes) + */ + protected Mono resolveContext(ServerWebExchange exchange) { + return contextResolver.resolveContext(exchange); + } + /** - * Resolves the full application execution context for resource endpoints. - * + * Resolves the full {@link ApplicationExecutionContext} for the request. + * *

    This method:

    *
      - *
    1. Validates contractId and productId are not null (BOTH REQUIRED)
    2. - *
    3. Extracts party ID from Istio-injected X-Party-Id header
    4. - *
    5. Fetches tenant ID from config-mgmt using party ID
    6. - *
    7. Uses the provided contractId and productId from {@code @PathVariable}
    8. - *
    9. Enriches with roles and permissions from FireflySessionManager (party+contract+product scope)
    10. - *
    11. Loads tenant configuration
    12. - *
    13. Returns complete {@link ApplicationExecutionContext}
    14. + *
    15. Resolves the {@link AppContext} (subject, tenant, roles, permissions) from the + * validated security context
    16. + *
    17. Loads the tenant configuration
    18. + *
    19. Returns the complete {@link ApplicationExecutionContext}
    20. *
    - * + * * @param exchange the server web exchange - * @param contractId the contract ID from {@code @PathVariable} (REQUIRED) - * @param productId the product ID from {@code @PathVariable} (REQUIRED) - * @return Mono of ApplicationExecutionContext with complete resource hierarchy - * @throws IllegalArgumentException if contractId or productId is null + * @return Mono of ApplicationExecutionContext with context and config */ - protected Mono resolveExecutionContext( - ServerWebExchange exchange, UUID contractId, UUID productId) { - - requireContext(contractId, productId); - log.debug("Resolving resource execution context for contract: {}, product: {}", - contractId, productId); - - // Pass both contractId and productId (both required) - return contextResolver.resolveContext(exchange, contractId, productId) + protected Mono resolveExecutionContext(ServerWebExchange exchange) { + log.debug("Resolving execution context"); + + return contextResolver.resolveContext(exchange) .flatMap(appContext -> { - log.debug("Resolved resource context: party={}, tenant={}, contract={}, product={}", - appContext.getPartyId(), appContext.getTenantId(), - appContext.getContractId(), appContext.getProductId()); - + log.debug("Resolved context: subject={}, tenant={}", + appContext.getSubject(), appContext.getTenantId()); + return configResolver.resolveConfig(appContext.getTenantId()) .map(appConfig -> ApplicationExecutionContext.builder() .context(appContext) .config(appConfig) .build()); }) - .doOnSuccess(ctx -> log.debug("Successfully resolved resource execution context")) - .doOnError(error -> log.error("Failed to resolve resource execution context", error)); - } - - /** - * Validates that both contractId and productId are not null. - * - *

    IMPORTANT: This controller REQUIRES both contractId and productId. - * Call this method (or let {@link #resolveExecutionContext} call it automatically) to ensure - * both path variables are present.

    - * - *

    Example:

    - *
    -     * {@code
    -     * @GetMapping("/{transactionId}")
    -     * public Mono getTransaction(
    -     *         @PathVariable UUID contractId,
    -     *         @PathVariable UUID productId,
    -     *         @PathVariable UUID transactionId) {
    -     *     requireContext(contractId, productId);  // Validates both IDs are present
    -     *     // ... rest of your logic
    -     * }
    -     * }
    -     * 
    - * - * @param contractId the contract ID from the path variable (REQUIRED) - * @param productId the product ID from the path variable (REQUIRED) - * @throws IllegalArgumentException if contractId or productId is null - */ - protected final void requireContext(UUID contractId, UUID productId) { - if (contractId == null) { - log.error("Missing required path variable: contractId"); - throw new IllegalArgumentException( - "contractId is required but was null. Check your @PathVariable mapping." - ); - } - if (productId == null) { - log.error("Missing required path variable: productId"); - throw new IllegalArgumentException( - "productId is required but was null. Check your @PathVariable mapping." - ); - } - log.trace("Resource context validated - Contract: {}, Product: {}", contractId, productId); + .doOnSuccess(ctx -> log.debug("Successfully resolved execution context")) + .doOnError(error -> log.error("Failed to resolve execution context", error)); } - - + /** - * Logs the current operation with full resource context. - * - *

    This is a convenience method for adding consistent, structured logging - * to your endpoints. It logs at INFO level with both contract and product IDs.

    - * + * Logs the current operation. + * + *

    This is a convenience method for adding consistent, structured logging to your endpoints. + * It logs at INFO level.

    + * *

    Example:

    *
          * {@code
          * @PostMapping
          * public Mono createTransaction(
    -     *         @PathVariable UUID contractId,
    -     *         @PathVariable UUID productId,
    -     *         @RequestBody CreateTransactionRequest request) {
    -     *     logOperation(contractId, productId, "createTransaction");
    -     *     return resolveExecutionContext(exchange, contractId, productId)
    +     *         @RequestBody CreateTransactionRequest request,
    +     *         ServerWebExchange exchange) {
    +     *     logOperation("createTransaction");
    +     *     return resolveExecutionContext(exchange)
          *         .flatMap(context -> transactionService.createTransaction(context, request));
          * }
          * }
          * 
    - * - * @param contractId the contract ID - * @param productId the product ID + * * @param operation a short description of the operation (e.g., "createTransaction", "listAccounts") */ - protected final void logOperation(UUID contractId, UUID productId, String operation) { - log.info("[Resource] Contract: {}, Product: {}, Operation: {}", contractId, productId, operation); + protected final void logOperation(String operation) { + log.info("[Operation] {}", operation); } } diff --git a/src/main/java/org/fireflyframework/common/application/resolver/AbstractContextResolver.java b/src/main/java/org/fireflyframework/common/application/resolver/AbstractContextResolver.java index 0c4fa25..fa9aee8 100644 --- a/src/main/java/org/fireflyframework/common/application/resolver/AbstractContextResolver.java +++ b/src/main/java/org/fireflyframework/common/application/resolver/AbstractContextResolver.java @@ -17,201 +17,43 @@ package org.fireflyframework.common.application.resolver; import org.fireflyframework.common.application.context.AppContext; -import org.fireflyframework.common.application.context.AppMetadata; import lombok.extern.slf4j.Slf4j; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; +import java.util.Optional; import java.util.Set; -import java.util.UUID; /** - * Abstract base implementation of ContextResolver. - * Provides common functionality and template methods for context resolution. - * - *

    Subclasses should implement the abstract methods to provide specific - * resolution strategies for their use case.

    - * - *

    This class integrates with platform SDKs to fetch context data:

    - *
      - *
    • common-platform-customer-mgmt-sdk: For party/customer information
    • - *
    • common-platform-contract-mgmt-sdk: For contract information
    • - *
    • common-platform-product-mgmt: For product information
    • - *
    - * - * @author Firefly Development Team - * @since 1.0.0 + * Template base for {@link ContextResolver}s: assembles an {@link AppContext} from the resolved + * subject, tenant, roles and permissions. Subclasses provide {@link #resolveSubject} and + * {@link #resolveTenantId}; roles/permissions default to empty and can be overridden. */ @Slf4j public abstract class AbstractContextResolver implements ContextResolver { - + @Override public Mono resolveContext(ServerWebExchange exchange) { - log.debug("Resolving application context for request (deprecated - use version with explicit IDs)"); - - return Mono.zip( - resolvePartyId(exchange), - resolveTenantId(exchange), - resolveContractId(exchange).defaultIfEmpty(UUID.randomUUID()), // placeholder UUID if empty - resolveProductId(exchange).defaultIfEmpty(UUID.randomUUID()) // placeholder UUID if empty - ) - .flatMap(tuple -> { - UUID partyId = tuple.getT1(); - UUID tenantId = tuple.getT2(); - UUID contractId = tuple.getT3(); - UUID productId = tuple.getT4(); - - return enrichContext( - AppContext.builder() - .partyId(partyId) - .tenantId(tenantId) - .contractId(contractId) - .productId(productId) - .build(), - exchange - ); - }) - .doOnSuccess(context -> log.debug("Successfully resolved context for party: {}", context.getPartyId())) - .doOnError(error -> log.error("Failed to resolve context", error)); - } - - @Override - public Mono resolveContext(ServerWebExchange exchange, UUID contractId, UUID productId) { - log.debug("Resolving application context with explicit contract: {} and product: {}", contractId, productId); - - return Mono.zip( - resolvePartyId(exchange), - resolveTenantId(exchange) - ) - .flatMap(tuple -> { - UUID partyId = tuple.getT1(); - UUID tenantId = tuple.getT2(); - - return enrichContext( - AppContext.builder() - .partyId(partyId) - .tenantId(tenantId) - .contractId(contractId) // Explicit from controller - .productId(productId) // Explicit from controller - .build(), - exchange - ); - }) - .doOnSuccess(context -> log.debug("Successfully resolved context for party: {}, contract: {}, product: {}", - context.getPartyId(), context.getContractId(), context.getProductId())) - .doOnError(error -> log.error("Failed to resolve context", error)); - } - - /** - * Enriches the basic context with roles, permissions, and additional data. - * This method should fetch data from platform services. - * - * @param basicContext the basic context with IDs - * @param exchange the server web exchange - * @return Mono of enriched AppContext - */ - protected Mono enrichContext(AppContext basicContext, - ServerWebExchange exchange) { - return Mono.zip( - resolveRoles(basicContext, exchange), - resolvePermissions(basicContext, exchange) - ) - .map(tuple -> basicContext.toBuilder() - .roles(tuple.getT1()) - .permissions(tuple.getT2()) - .build()) - .defaultIfEmpty(basicContext); + return resolveSubject(exchange).flatMap(subject -> Mono.zip( + resolveTenantId(exchange).map(Optional::of).defaultIfEmpty(Optional.empty()), + resolveRoles(subject, exchange), + resolvePermissions(subject, exchange)) + .map(tuple -> AppContext.builder() + .subject(subject) + .tenantId(tuple.getT1().orElse(null)) + .roles(tuple.getT2()) + .permissions(tuple.getT3()) + .build())) + .doOnError(error -> log.error("Failed to resolve application context", error)); } - - /** - * Resolves roles for the party in the context of the contract/product. - * - *

    TODO: Implementation should use common-platform-customer-mgmt-sdk and - * common-platform-contract-mgmt-sdk to fetch the party's roles in the contract.

    - * - * @param context the application context - * @param exchange the server web exchange - * @return Mono of role set - */ - protected Mono> resolveRoles(AppContext context, ServerWebExchange exchange) { - // TODO: Implement role resolution using platform SDKs - // Example: - // return customerManagementClient.getPartyRoles(context.getPartyId(), context.getContractId()) - // .map(response -> response.getRoles()); - - log.debug("Resolving roles for party: {} in contract: {}", - context.getPartyId(), context.getContractId()); + + /** Resolve roles for the subject (default empty; override to enrich). */ + protected Mono> resolveRoles(String subject, ServerWebExchange exchange) { return Mono.just(Set.of()); } - - /** - * Resolves permissions for the party in the context of the contract/product. - * - *

    TODO: Implementation should use common-platform-contract-mgmt-sdk to fetch - * the party's permissions based on their roles in the contract.

    - * - * @param context the application context - * @param exchange the server web exchange - * @return Mono of permission set - */ - protected Mono> resolvePermissions(AppContext context, ServerWebExchange exchange) { - // TODO: Implement permission resolution using platform SDKs - // Example: - // return contractManagementClient.getPartyPermissions( - // context.getPartyId(), - // context.getContractId(), - // context.getProductId() - // ).map(response -> response.getPermissions()); - - log.debug("Resolving permissions for party: {} in contract: {}, product: {}", - context.getPartyId(), context.getContractId(), context.getProductId()); + + /** Resolve permissions for the subject (default empty; override to enrich). */ + protected Mono> resolvePermissions(String subject, ServerWebExchange exchange) { return Mono.just(Set.of()); } - - /** - * Extracts UUID from request attribute or header. - * - * @param exchange the server web exchange - * @param attributeName the attribute name - * @param headerName the header name - * @return Mono of UUID - */ - protected Mono extractUUID(ServerWebExchange exchange, String attributeName, String headerName) { - // Try to get from attribute first - UUID fromAttribute = exchange.getAttribute(attributeName); - if (fromAttribute != null) { - return Mono.just(fromAttribute); - } - - // Try to get from header - String headerValue = exchange.getRequest().getHeaders().getFirst(headerName); - if (headerValue != null && !headerValue.isEmpty()) { - try { - return Mono.just(UUID.fromString(headerValue)); - } catch (IllegalArgumentException e) { - log.warn("Invalid UUID format in header {}: {}", headerName, headerValue); - } - } - - return Mono.empty(); - } - - /** - * Extracts UUID from path variable. - * - * @param exchange the server web exchange - * @param variableName the path variable name - * @return Mono of UUID - */ - protected Mono extractUUIDFromPath(ServerWebExchange exchange, String variableName) { - try { - String value = exchange.getRequest().getPath().value(); - // This is a simplified implementation - // In practice, you'd use a proper path matcher or get it from request attributes - return Mono.empty(); - } catch (Exception e) { - log.warn("Failed to extract UUID from path variable: {}", variableName, e); - return Mono.empty(); - } - } } diff --git a/src/main/java/org/fireflyframework/common/application/resolver/ContextResolver.java b/src/main/java/org/fireflyframework/common/application/resolver/ContextResolver.java index 8c052d7..e4304be 100644 --- a/src/main/java/org/fireflyframework/common/application/resolver/ContextResolver.java +++ b/src/main/java/org/fireflyframework/common/application/resolver/ContextResolver.java @@ -23,93 +23,27 @@ import java.util.UUID; /** - * Interface for resolving application context from incoming requests. - * Implementations are responsible for extracting and enriching context information - * such as partyId, contractId, productId, roles, and permissions. - * - *

    This is the main entry point for context resolution in the application layer.

    - * - * @author Firefly Development Team - * @since 1.0.0 + * Resolves the product-agnostic {@link AppContext} for a request from the validated security + * context. Implementations derive the subject, tenant, roles and permissions — never from a trusted + * transport header. */ public interface ContextResolver { - - /** - * Resolves the complete application context from the request. - * This method extracts all IDs automatically (party, tenant, contract, product). - * - * @param exchange the server web exchange - * @return Mono of resolved AppContext - */ + + /** Resolve the full context (subject + tenant + roles + permissions). */ Mono resolveContext(ServerWebExchange exchange); - - /** - * Resolves the application context with explicit contractId and productId. - * This is the method controllers should use to pass IDs extracted from {@code @PathVariable}. - * - *

    Party and tenant IDs are still extracted from Istio headers (X-Party-Id, X-Tenant-Id), - * but contract and product IDs are provided explicitly by the controller.

    - * - * @param exchange the server web exchange - * @param contractId the contract ID from {@code @PathVariable} (nullable) - * @param productId the product ID from {@code @PathVariable} (nullable) - * @return Mono of resolved AppContext - */ - Mono resolveContext(ServerWebExchange exchange, UUID contractId, UUID productId); - - /** - * Resolves the party ID from the request. - * This should extract the authenticated user/customer identifier. - * - * @param exchange the server web exchange - * @return Mono of party UUID - */ - Mono resolvePartyId(ServerWebExchange exchange); - - /** - * Resolves the contract ID from the request. - * This may come from path parameters, query parameters, or headers. - * - * @param exchange the server web exchange - * @return Mono of contract UUID (may be empty) - */ - Mono resolveContractId(ServerWebExchange exchange); - - /** - * Resolves the product ID from the request. - * This may come from path parameters, query parameters, or headers. - * - * @param exchange the server web exchange - * @return Mono of product UUID (may be empty) - */ - Mono resolveProductId(ServerWebExchange exchange); - - /** - * Resolves the tenant ID from the request. - * This typically comes from authentication tokens or subdomain. - * - * @param exchange the server web exchange - * @return Mono of tenant UUID - */ + + /** Resolve the authenticated subject identifier. */ + Mono resolveSubject(ServerWebExchange exchange); + + /** Resolve the generic tenant id (may be empty when single-tenant). */ Mono resolveTenantId(ServerWebExchange exchange); - - /** - * Checks if this resolver supports the given request. - * Allows for multiple resolver implementations with different strategies. - * - * @param exchange the server web exchange - * @return true if this resolver can handle the request - */ + + /** Whether this resolver supports the given request. */ default boolean supports(ServerWebExchange exchange) { return true; } - - /** - * Priority of this resolver (higher values take precedence). - * Used when multiple resolvers support the same request. - * - * @return priority value - */ + + /** Priority of this resolver (higher wins) when multiple support a request. */ default int getPriority() { return 0; } diff --git a/src/main/java/org/fireflyframework/common/application/resolver/DefaultContextResolver.java b/src/main/java/org/fireflyframework/common/application/resolver/DefaultContextResolver.java index c513d6b..4fa8eb2 100644 --- a/src/main/java/org/fireflyframework/common/application/resolver/DefaultContextResolver.java +++ b/src/main/java/org/fireflyframework/common/application/resolver/DefaultContextResolver.java @@ -16,13 +16,10 @@ package org.fireflyframework.common.application.resolver; -import org.fireflyframework.common.application.context.AppContext; -import org.fireflyframework.common.application.util.SessionContextMapper; -import org.fireflyframework.common.application.spi.SessionContext; -import org.fireflyframework.common.application.spi.SessionManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.security.spi.SecurityContextPort; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @@ -30,218 +27,53 @@ import java.util.UUID; /** - * Default implementation of ContextResolver. - * - *

    This is provided by the library - microservices don't need to implement anything.

    - * - *

    This resolver automatically:

    - *
      - *
    • Extracts partyId from Istio-injected HTTP header ({@code X-Party-Id})
    • - *
    • Resolves tenantId by calling {@code common-platform-config-mgmt} with the partyId
    • - *
    • Enriches context with roles and permissions from platform SDKs
    • - *
    • Caches results for performance
    • - *
    - * - *

    Important: ContractId and ProductId are NOT extracted here. - * They must be extracted from {@code @PathVariable} in your controllers and passed explicitly.

    - * - *

    Architecture

    - *
      - *
    • Istio Gateway: Validates JWT, injects X-Party-Id header (from JWT subject)
    • - *
    • This Resolver: Uses partyId to fetch tenantId from config-mgmt microservice
    • - *
    • Controllers: Extract contractId/productId from {@code @PathVariable} in REST path
    • - *
    • SDK Enrichment: Fetch roles/permissions from platform SDKs
    • - *
    - * - *

    Expected HTTP Headers (Injected by Istio)

    - *
      - *
    • X-Party-Id - Party UUID (required) - Extracted from authenticated JWT subject
    • - *
    - * - *

    Tenant Resolution

    - *

    The tenant ID is NOT in the JWT or headers. Instead, it is resolved dynamically:

    - *
    - * {@code
    - * // Call common-platform-config-mgmt microservice
    - * GET /api/v1/parties/{partyId}/tenant
    - * Response: { "tenantId": "uuid", "tenantName": "...", ... }
    - * }
    - * 
    - * - *

    Role & Permission Resolution (SessionManager)

    - *

    Roles and permissions are NOT fetched from individual platform services. - * Instead, they come from the SessionManager in Security Center:

    - *
      - *
    • Session Management: Tracks which contracts a party has access to
    • - *
    • Role Mapping: Provides party roles in each contract/product
    • - *
    • Role Scopes: Supports party-level, contract-level, and product-level roles
    • - *
    • Permission Derivation: Converts roles to permissions using role mappings
    • - *
    - *
    - * {@code
    - * // Call SessionManager from Security Center
    - * PartySession session = sessionManager.getPartySession(partyId, tenantId);
    - * 
    - * // Get contract-specific roles
    - * Set roles = session.getContractRoles(contractId, productId);
    - * // e.g., ["owner", "account:viewer", "transaction:creator"]
    - * 
    - * // Derive permissions from roles
    - * Set permissions = session.getPermissionsForRoles(roles);
    - * // e.g., ["account:read", "account:update", "transaction:create"]
    - * }
    - * 
    - * - *

    Controller Responsibility

    - *

    Controllers must extract contractId and productId from path variables:

    - *
    - * {@code
    - * @GetMapping("/contracts/{contractId}/accounts")
    - * public Mono> getAccounts(@PathVariable UUID contractId, ServerWebExchange exchange) {
    - *     // Controller extracts contractId from path, passes to service
    - * }
    - * }
    - * 
    - * - * @author Firefly Development Team - * @since 1.0.0 + * Default {@link ContextResolver} provided by the library: it projects the request context from the + * validated security principal exposed by the {@code fireflyframework-security} + * platform ({@link SecurityContextPort}). The subject is the principal's subject, roles are its + * authorities, and permissions are its scopes — no trusted {@code X-Party-Id}-style header is read. */ @Slf4j @RequiredArgsConstructor public class DefaultContextResolver extends AbstractContextResolver { - - @Autowired(required = false) - private final SessionManager sessionManager; - - // TODO: Inject platform SDK clients when available - // private final ConfigManagementClient configMgmtClient; // For tenant resolution - + + private final SecurityContextPort securityContextPort; + @Override - public Mono resolvePartyId(ServerWebExchange exchange) { - log.debug("Resolving party ID from Istio-injected header"); - - // Party ID is injected by Istio as X-Party-Id header - return extractUUID(exchange, "partyId", "X-Party-Id") - .doOnNext(id -> log.debug("Resolved party ID from Istio header: {}", id)) + public Mono resolveSubject(ServerWebExchange exchange) { + return securityContextPort.currentPrincipal() + .map(SecurityPrincipal::subject) .switchIfEmpty(Mono.error(new IllegalStateException( - "X-Party-Id header not found. Ensure request passes through Istio gateway."))); + "No authenticated security principal available to resolve the request subject"))); } - + @Override public Mono resolveTenantId(ServerWebExchange exchange) { - log.debug("Resolving tenant ID from config-mgmt using party ID"); - - // Tenant ID is NOT in headers - must be resolved from config-mgmt microservice - // First, get the party ID from the header - return resolvePartyId(exchange) - .flatMap(partyId -> { - log.debug("Fetching tenant ID for party: {} from config-mgmt", partyId); - - // TODO: Implement using common-platform-config-mgmt-sdk - // When SDK is available, call: - /* - return configMgmtClient.getPartyTenant(partyId) - .map(response -> response.getTenantId()) - .doOnNext(tenantId -> log.debug("Resolved tenant ID: {} for party: {}", tenantId, partyId)); - */ - - // Temporary: Try to get from header first (for backwards compatibility during migration) - // Then fallback to error if not available - return extractUUID(exchange, "tenantId", "X-Tenant-Id") - .doOnNext(id -> log.warn("Using X-Tenant-Id header (deprecated) - should fetch from config-mgmt: {}", id)) - .switchIfEmpty(Mono.error(new IllegalStateException( - "Tenant resolution not implemented. Need to integrate common-platform-config-mgmt-sdk. " - + "SDK should call: GET /api/v1/parties/" + partyId + "/tenant"))); - }) - .doOnError(error -> log.error("Failed to resolve tenant ID", error)); - } - - @Override - public Mono resolveContractId(ServerWebExchange exchange) { - // Contract ID is not extracted here - it must be passed explicitly by controllers - // Controllers extract contractId from @PathVariable and pass it to services - log.debug("Contract ID resolution delegated to controller layer"); - return Mono.empty(); - } - - @Override - public Mono resolveProductId(ServerWebExchange exchange) { - // Product ID is not extracted here - it must be passed explicitly by controllers - // Controllers extract productId from @PathVariable and pass it to services - log.debug("Product ID resolution delegated to controller layer"); - return Mono.empty(); + return securityContextPort.currentPrincipal() + .flatMap(principal -> { + String tenant = principal.tenantId(); + if (tenant == null || tenant.isBlank()) { + return Mono.empty(); + } + try { + return Mono.just(UUID.fromString(tenant)); + } catch (IllegalArgumentException ex) { + log.debug("Principal tenantId '{}' is not a UUID; treating as single-tenant", tenant); + return Mono.empty(); + } + }); } - - @Override - protected Mono> resolveRoles(AppContext context, ServerWebExchange exchange) { - log.debug("Resolving roles for party: {} in contract: {}, product: {}", - context.getPartyId(), context.getContractId(), context.getProductId()); - - // Check if SessionManager is available - if (sessionManager == null) { - log.warn("SessionManager not available - returning empty roles. " + - "Ensure common-platform-security-center is deployed and accessible."); - return Mono.just(Set.of()); - } - - // Use SessionManager to get session with enriched contract/role data - return sessionManager.createOrGetSession(exchange) - .map(session -> { - // Extract roles using SessionContextMapper based on context scope - Set roles = SessionContextMapper.extractRoles( - session, - context.getContractId(), - context.getProductId() - ); - - log.debug("Resolved {} roles for party {}: {}", roles.size(), context.getPartyId(), roles); - return roles; - }) - .doOnError(error -> log.error("Failed to resolve roles from SessionManager: {}", - error.getMessage(), error)) - .onErrorReturn(Set.of()); // Graceful degradation on error - } - - @Override - protected Mono> resolvePermissions(AppContext context, ServerWebExchange exchange) { - log.debug("Resolving permissions for party: {} in contract: {}, product: {}", - context.getPartyId(), context.getContractId(), context.getProductId()); - - // Check if SessionManager is available - if (sessionManager == null) { - log.warn("SessionManager not available - returning empty permissions. " + - "Ensure common-platform-security-center is deployed and accessible."); - return Mono.just(Set.of()); - } - - // Use SessionManager to get session with enriched contract/role/permission data - return sessionManager.createOrGetSession(exchange) - .map(session -> { - // Extract permissions from role scopes using SessionContextMapper - Set permissions = SessionContextMapper.extractPermissions( - session, - context.getContractId(), - context.getProductId() - ); - - log.debug("Resolved {} permissions for party {}: {}", - permissions.size(), context.getPartyId(), permissions); - return permissions; - }) - .doOnError(error -> log.error("Failed to resolve permissions from SessionManager: {}", - error.getMessage(), error)) - .onErrorReturn(Set.of()); // Graceful degradation on error - } - + @Override - public boolean supports(ServerWebExchange exchange) { - // This default resolver supports all requests - return true; + protected Mono> resolveRoles(String subject, ServerWebExchange exchange) { + return securityContextPort.currentPrincipal() + .map(SecurityPrincipal::authorities) + .defaultIfEmpty(Set.of()); } - + @Override - public int getPriority() { - // Default priority - return 0; + protected Mono> resolvePermissions(String subject, ServerWebExchange exchange) { + return securityContextPort.currentPrincipal() + .map(SecurityPrincipal::scopes) + .defaultIfEmpty(Set.of()); } } diff --git a/src/main/java/org/fireflyframework/common/application/security/AbstractSecurityAuthorizationService.java b/src/main/java/org/fireflyframework/common/application/security/AbstractSecurityAuthorizationService.java index 22751be..65e79e9 100644 --- a/src/main/java/org/fireflyframework/common/application/security/AbstractSecurityAuthorizationService.java +++ b/src/main/java/org/fireflyframework/common/application/security/AbstractSecurityAuthorizationService.java @@ -26,15 +26,17 @@ import org.springframework.expression.spel.support.StandardEvaluationContext; import reactor.core.publisher.Mono; -import java.time.Instant; - /** - * Abstract implementation of SecurityAuthorizationService. - * Provides integration with Firefly SecurityCenter for authorization decisions. - * - *

    This class handles the core authorization logic and delegates to SecurityCenter - * when needed. Subclasses can override specific methods for custom authorization logic.

    - * + * Abstract implementation of {@link SecurityAuthorizationService}. + * + *

    Authorization decisions are derived solely from the roles and permissions already + * resolved into the {@link AppContext}. This keeps the authorization layer fully + * product-agnostic: the validated identity (subject, tenant, roles, permissions) is + * resolved up-front by the context resolution layer, and this service simply evaluates + * the declared requirements against it.

    + * + *

    Subclasses can override specific hook methods for custom authorization logic.

    + * * @author Firefly Development Team * @since 1.0.0 */ @@ -45,15 +47,15 @@ public abstract class AbstractSecurityAuthorizationService implements SecurityAu @Override public Mono authorize(AppContext context, AppSecurityContext securityContext) { - log.debug("Authorizing request for endpoint: {} {} by party: {}", - securityContext.getHttpMethod(), securityContext.getEndpoint(), context.getPartyId()); - + log.debug("Authorizing request for endpoint: {} {} by subject: {}", + securityContext.getHttpMethod(), securityContext.getEndpoint(), context.getSubject()); + // If anonymous access is allowed, grant immediately if (securityContext.isAllowAnonymous()) { return Mono.just(securityContext.withAuthorized(true) .withConfigSource(AppSecurityContext.SecurityConfigSource.DEFAULT)); } - + // Check if roles are required if (securityContext.hasRequiredRoles()) { return checkRoles(context, securityContext) @@ -68,32 +70,32 @@ public Mono authorize(AppContext context, AppSecurityContext return Mono.just(createAuthorizedContext(securityContext)); }); } - + // Check permissions if specified if (securityContext.hasRequiredPermissions()) { return checkPermissions(context, securityContext); } - - // If SecurityCenter should be used, delegate to it - if (securityContext.getConfigSource() == AppSecurityContext.SecurityConfigSource.SECURITY_CENTER) { - return authorizeWithSecurityCenter(context, securityContext); + + // If a policy source is configured, delegate to it + if (securityContext.getConfigSource() == AppSecurityContext.SecurityConfigSource.POLICY) { + return authorizeWithPolicy(context, securityContext); } - + // Default: allow access if no specific requirements log.debug("No specific security requirements, allowing access"); return Mono.just(createAuthorizedContext(securityContext)); } - + @Override public Mono hasRole(AppContext context, String role) { return Mono.just(context.hasRole(role)); } - + @Override public Mono hasPermission(AppContext context, String permission) { return Mono.just(context.hasPermission(permission)); } - + @Override public Mono evaluateExpression(AppContext context, String expression) { if (expression == null || expression.isBlank()) { @@ -112,10 +114,10 @@ public Mono evaluateExpression(AppContext context, String expression) { return Mono.just(false); } } - + /** * Checks if the context has the required roles. - * + * * @param context the application context * @param securityContext the security context * @return Mono of boolean indicating if roles are satisfied @@ -124,16 +126,16 @@ protected Mono checkRoles(AppContext context, AppSecurityContext securi if (securityContext.getRequiredRoles() == null || securityContext.getRequiredRoles().isEmpty()) { return Mono.just(true); } - + boolean hasRequiredRoles = securityContext.getRequiredRoles().stream() .anyMatch(context::hasRole); - + return Mono.just(hasRequiredRoles); } - + /** * Checks if the context has the required permissions. - * + * * @param context the application context * @param securityContext the security context * @return Mono of AppSecurityContext with authorization result @@ -142,66 +144,53 @@ protected Mono checkPermissions(AppContext context, AppSecur if (securityContext.getRequiredPermissions() == null || securityContext.getRequiredPermissions().isEmpty()) { return Mono.just(createAuthorizedContext(securityContext)); } - + boolean hasRequiredPermissions = securityContext.getRequiredPermissions().stream() .anyMatch(context::hasPermission); - + if (hasRequiredPermissions) { return Mono.just(createAuthorizedContext(securityContext)); } else { return Mono.just(createUnauthorizedContext(securityContext, "Required permissions not granted")); } } - + /** - * Authorizes a request using the Firefly SecurityCenter. - * - *

    TODO: Implementation should integrate with SecurityCenter to evaluate - * authorization policies based on the party, contract, product, and endpoint.

    - * + * Authorizes a request using an external policy source. + * + *

    The default implementation evaluates the declared role and permission requirements + * against the roles and permissions already present in the {@link AppContext}. Subclasses + * may override this method to integrate with a dedicated policy decision point (PDP) for + * attribute-based or policy-based access control.

    + * * @param context the application context * @param securityContext the security context * @return Mono of AppSecurityContext with authorization result */ - protected Mono authorizeWithSecurityCenter(AppContext context, - AppSecurityContext securityContext) { - // TODO: Implement SecurityCenter integration - // Example: - // return securityCenterClient.authorize( - // AuthorizationRequest.builder() - // .partyId(context.getPartyId()) - // .contractId(context.getContractId()) - // .productId(context.getProductId()) - // .endpoint(securityContext.getEndpoint()) - // .httpMethod(securityContext.getHttpMethod()) - // .roles(context.getRoles()) - // .permissions(context.getPermissions()) - // .build() - // ).map(response -> { - // AppSecurityContext.SecurityEvaluationResult evaluationResult = - // AppSecurityContext.SecurityEvaluationResult.builder() - // .granted(response.isGranted()) - // .reason(response.getReason()) - // .evaluatedPolicy(response.getPolicyName()) - // .evaluationDetails(response.getDetails()) - // .evaluatedAt(Instant.now()) - // .build(); - // - // return securityContext.toBuilder() - // .authorized(response.isGranted()) - // .authorizationFailureReason(response.isGranted() ? null : response.getReason()) - // .configSource(AppSecurityContext.SecurityConfigSource.SECURITY_CENTER) - // .evaluationResult(evaluationResult) - // .build(); - // }); - - log.warn("SecurityCenter integration not implemented, denying access by default"); - return Mono.just(createUnauthorizedContext(securityContext, "SecurityCenter integration not implemented")); + protected Mono authorizeWithPolicy(AppContext context, + AppSecurityContext securityContext) { + // Default policy evaluation falls back to role/permission checks against the resolved context. + if (securityContext.hasRequiredRoles()) { + return checkRoles(context, securityContext) + .flatMap(rolesOk -> { + if (!rolesOk) { + return Mono.just(createUnauthorizedContext(securityContext, "Required roles not present")); + } + if (securityContext.hasRequiredPermissions()) { + return checkPermissions(context, securityContext); + } + return Mono.just(createAuthorizedContext(securityContext)); + }); + } + if (securityContext.hasRequiredPermissions()) { + return checkPermissions(context, securityContext); + } + return Mono.just(createAuthorizedContext(securityContext)); } - + /** * Creates an authorized security context. - * + * * @param original the original security context * @return authorized security context */ @@ -209,10 +198,10 @@ protected AppSecurityContext createAuthorizedContext(AppSecurityContext original return original.withAuthorized(true) .withAuthorizationFailureReason(null); } - + /** * Creates an unauthorized security context with a reason. - * + * * @param original the original security context * @param reason the reason for denial * @return unauthorized security context diff --git a/src/main/java/org/fireflyframework/common/application/security/AbstractSecurityConfiguration.java b/src/main/java/org/fireflyframework/common/application/security/AbstractSecurityConfiguration.java index 3527d38..5f0a728 100644 --- a/src/main/java/org/fireflyframework/common/application/security/AbstractSecurityConfiguration.java +++ b/src/main/java/org/fireflyframework/common/application/security/AbstractSecurityConfiguration.java @@ -25,12 +25,12 @@ /** *

    Abstract Base Class for Declarative Endpoint Security Configuration

    - * + * *

    This abstract class simplifies the process of configuring endpoint security using * the {@link EndpointSecurityRegistry}. Instead of manually calling registry methods, * you can extend this class and override {@link #configureEndpointSecurity()} to * define security rules in a clean, declarative way.

    - * + * *

    Why Use This?

    *
      *
    • Cleaner code: Declarative API vs manual registry calls
    • @@ -38,36 +38,36 @@ *
    • Readability: Security rules are clear and organized
    • *
    • Flexibility: Override annotations dynamically based on environment
    • *
    - * + * *

    Quick Example

    *
      * {@code
      * @Configuration
    - * public class AccountSecurityConfig extends AbstractSecurityConfiguration {
    - *     
    + * public class ResourceSecurityConfig extends AbstractSecurityConfiguration {
    + *
      *     @Override
      *     protected void configureEndpointSecurity() {
      *         // Simple role-based access
    - *         protect("/api/v1/contracts/{contractId}/accounts")
    + *         protect("/api/v1/resources")
      *             .onMethod("GET")
    - *             .requireRoles("ACCOUNT_VIEWER")
    + *             .requireRoles("RESOURCE_VIEWER")
      *             .register();
    - *         
    + *
      *         // Role + Permission
    - *         protect("/api/v1/contracts/{contractId}/accounts")
    + *         protect("/api/v1/resources")
      *             .onMethod("POST")
    - *             .requireRoles("ACCOUNT_CREATOR")
    - *             .requirePermissions("CREATE_ACCOUNT")
    + *             .requireRoles("RESOURCE_CREATOR")
    + *             .requirePermissions("CREATE_RESOURCE")
      *             .register();
    - *         
    + *
      *         // Multiple roles (user needs ALL of them)
    - *         protect("/api/v1/contracts/{contractId}/accounts/{accountId}")
    + *         protect("/api/v1/resources/{resourceId}")
      *             .onMethod("DELETE")
    - *             .requireAllRoles("ACCOUNT_ADMIN", "DELETE_AUTHORIZED")
    + *             .requireAllRoles("RESOURCE_ADMIN", "DELETE_AUTHORIZED")
      *             .register();
    - *         
    + *
      *         // Public endpoint
    - *         protect("/api/v1/public/rates")
    + *         protect("/api/v1/public/info")
      *             .onMethod("GET")
      *             .allowAnonymous()
      *             .register();
    @@ -75,38 +75,38 @@
      * }
      * }
      * 
    - * + * *

    Complete Example with Feature Flags

    *
      * {@code
      * @Configuration
    - * public class TransactionSecurityConfig extends AbstractSecurityConfiguration {
    - *     
    + * public class OperationSecurityConfig extends AbstractSecurityConfiguration {
    + *
      *     @Value("${security.strict-mode:false}")
      *     private boolean strictMode;
    - *     
    + *
      *     @Override
      *     protected void configureEndpointSecurity() {
      *         if (strictMode) {
      *             // Production: Strict security
    - *             protect("/api/v1/contracts/{contractId}/products/{productId}/transactions")
    + *             protect("/api/v1/operations")
      *                 .onMethod("POST")
    - *                 .requireRoles("ACCOUNT_HOLDER")
    - *                 .requireAllPermissions("TRANSFER_FUNDS", "HIGH_VALUE_TRANSFER")
    + *                 .requireRoles("OPERATOR")
    + *                 .requireAllPermissions("EXECUTE_OPERATION", "HIGH_VALUE_OPERATION")
      *                 .register();
      *         } else {
      *             // Development: Relaxed security
    - *             protect("/api/v1/contracts/{contractId}/products/{productId}/transactions")
    + *             protect("/api/v1/operations")
      *                 .onMethod("POST")
    - *                 .requireRoles("ACCOUNT_HOLDER")
    - *                 .requirePermissions("TRANSFER_FUNDS")
    + *                 .requireRoles("OPERATOR")
    + *                 .requirePermissions("EXECUTE_OPERATION")
      *                 .register();
      *         }
      *     }
      * }
      * }
      * 
    - * + * *

    How It Works

    *
      *
    1. Extend this class in your {@code @Configuration} class
    2. @@ -115,11 +115,11 @@ *
    3. Chain methods to configure roles, permissions, authentication requirements
    4. *
    5. Call {@link EndpointProtectionBuilder#register()} to register the rule
    6. *
    - * + * *

    Priority

    *

    Remember: Configuration defined here (via {@link EndpointSecurityRegistry}) * ALWAYS overrides {@code @Secure} annotations on controller methods.

    - * + * * @author Firefly Development Team * @since 1.0.0 * @see EndpointSecurityRegistry @@ -127,35 +127,35 @@ */ @Slf4j public abstract class AbstractSecurityConfiguration { - + @Autowired private EndpointSecurityRegistry securityRegistry; - + /** * Override this method to define your endpoint security rules. - * + * *

    This method is automatically called after the Spring context is initialized * ({@code @PostConstruct}). Use the {@link #protect(String)} method to start * defining security rules for your endpoints.

    - * + * *

    Example:

    *
          * {@code
          * @Override
          * protected void configureEndpointSecurity() {
    -     *     protect("/api/v1/accounts")
    +     *     protect("/api/v1/resources")
          *         .onMethod("POST")
    -     *         .requireRoles("ACCOUNT_CREATOR")
    +     *         .requireRoles("RESOURCE_CREATOR")
          *         .register();
          * }
          * }
          * 
    */ protected abstract void configureEndpointSecurity(); - + /** * Initializes security configuration. - * + * *

    This method is called automatically by Spring after the bean is constructed. * It calls {@link #configureEndpointSecurity()} to allow subclasses to define * their security rules.

    @@ -164,40 +164,40 @@ public abstract class AbstractSecurityConfiguration { private void initialize() { log.info("Initializing endpoint security configuration: {}", getClass().getSimpleName()); configureEndpointSecurity(); - log.info("Endpoint security configuration completed: {} endpoints registered", + log.info("Endpoint security configuration completed: {} endpoints registered", securityRegistry.getAllEndpoints().size()); } - + /** * Starts building a security rule for the given endpoint path. - * + * *

    This is the entry point for defining endpoint security. Chain additional * methods to configure the security requirements.

    - * + * *

    Example:

    *
          * {@code
    -     * protect("/api/v1/contracts/{contractId}/accounts")
    +     * protect("/api/v1/resources")
          *     .onMethod("POST")
    -     *     .requireRoles("ACCOUNT_CREATOR")
    +     *     .requireRoles("RESOURCE_CREATOR")
          *     .register();
          * }
          * 
    - * - * @param endpointPath the endpoint path (with path variables like {@code {contractId}}) + * + * @param endpointPath the endpoint path (with path variables like {@code {resourceId}}) * @return a builder to continue configuring the security rule */ protected final EndpointProtectionBuilder protect(String endpointPath) { return new EndpointProtectionBuilder(endpointPath, securityRegistry); } - + /** *

    Fluent Builder for Endpoint Security Configuration

    - * + * *

    This builder provides a clean, fluent API for configuring endpoint security. * Chain methods to define the security requirements, then call {@link #register()} * to register the configuration.

    - * + * *

    Available Methods

    *
      *
    • {@link #onMethod(String)} - Set the HTTP method (GET, POST, PUT, DELETE, etc.)
    • @@ -213,7 +213,7 @@ protected final EndpointProtectionBuilder protect(String endpointPath) { protected static final class EndpointProtectionBuilder { private final String endpointPath; private final EndpointSecurityRegistry registry; - + private String httpMethod = "GET"; private Set roles = Set.of(); private Set permissions = Set.of(); @@ -221,15 +221,15 @@ protected static final class EndpointProtectionBuilder { private boolean requireAllPermissions = false; private boolean allowAnonymous = false; private boolean requiresAuthentication = true; - + private EndpointProtectionBuilder(String endpointPath, EndpointSecurityRegistry registry) { this.endpointPath = endpointPath; this.registry = registry; } - + /** * Sets the HTTP method for this security rule. - * + * * @param method the HTTP method (e.g., "GET", "POST", "PUT", "DELETE") * @return this builder for method chaining */ @@ -237,12 +237,12 @@ public EndpointProtectionBuilder onMethod(String method) { this.httpMethod = method.toUpperCase(); return this; } - + /** * Requires the user to have ANY of the specified roles. - * + * *

      The user needs at least one of these roles to access the endpoint.

      - * + * * @param roles the required roles * @return this builder for method chaining */ @@ -251,12 +251,12 @@ public EndpointProtectionBuilder requireRoles(String... roles) { this.requireAllRoles = false; return this; } - + /** * Requires the user to have ALL of the specified roles. - * + * *

      The user must have every single one of these roles to access the endpoint.

      - * + * * @param roles the required roles * @return this builder for method chaining */ @@ -265,12 +265,12 @@ public EndpointProtectionBuilder requireAllRoles(String... roles) { this.requireAllRoles = true; return this; } - + /** * Requires the user to have ANY of the specified permissions. - * + * *

      The user needs at least one of these permissions to access the endpoint.

      - * + * * @param permissions the required permissions * @return this builder for method chaining */ @@ -279,12 +279,12 @@ public EndpointProtectionBuilder requirePermissions(String... permissions) { this.requireAllPermissions = false; return this; } - + /** * Requires the user to have ALL of the specified permissions. - * + * *

      The user must have every single one of these permissions to access the endpoint.

      - * + * * @param permissions the required permissions * @return this builder for method chaining */ @@ -293,12 +293,12 @@ public EndpointProtectionBuilder requireAllPermissions(String... permissions) { this.requireAllPermissions = true; return this; } - + /** * Explicitly requires authentication for this endpoint. - * + * *

      This is the default behavior, so you don't usually need to call this method.

      - * + * * @return this builder for method chaining */ public EndpointProtectionBuilder requireAuthentication() { @@ -306,12 +306,12 @@ public EndpointProtectionBuilder requireAuthentication() { this.allowAnonymous = false; return this; } - + /** * Allows unauthenticated (anonymous) access to this endpoint. - * + * *

      Use this for public endpoints that don't require authentication.

      - * + * * @return this builder for method chaining */ public EndpointProtectionBuilder allowAnonymous() { @@ -319,10 +319,10 @@ public EndpointProtectionBuilder allowAnonymous() { this.requiresAuthentication = false; return this; } - + /** * Registers this security configuration with the {@link EndpointSecurityRegistry}. - * + * *

      Call this method after you've finished configuring the security rule. * The configuration will be registered and will override any {@code @Secure} * annotation on the controller method.

      @@ -336,10 +336,10 @@ public void register() { .allowAnonymous(allowAnonymous) .requiresAuthentication(requiresAuthentication) .build(); - + registry.registerEndpoint(endpointPath, httpMethod, security); - - log.debug("Registered security for {} {} - Roles: {}, Permissions: {}, Auth required: {}", + + log.debug("Registered security for {} {} - Roles: {}, Permissions: {}, Auth required: {}", httpMethod, endpointPath, roles, permissions, requiresAuthentication); } } diff --git a/src/main/java/org/fireflyframework/common/application/security/DefaultSecurityAuthorizationService.java b/src/main/java/org/fireflyframework/common/application/security/DefaultSecurityAuthorizationService.java index 06adab3..b292bbd 100644 --- a/src/main/java/org/fireflyframework/common/application/security/DefaultSecurityAuthorizationService.java +++ b/src/main/java/org/fireflyframework/common/application/security/DefaultSecurityAuthorizationService.java @@ -16,147 +16,50 @@ package org.fireflyframework.common.application.security; -import org.fireflyframework.common.application.context.AppContext; -import org.fireflyframework.common.application.context.AppSecurityContext; -import org.fireflyframework.common.application.util.SessionContextMapper; -import org.fireflyframework.common.application.spi.SessionContext; -import org.fireflyframework.common.application.spi.SessionManager; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import reactor.core.publisher.Mono; /** - * Default implementation of SecurityAuthorizationService. - * + * Default implementation of {@link SecurityAuthorizationService}. + * *

      This is provided by the library - microservices don't need to implement anything.

      - * + * *

      This service automatically:

      *
        *
      • Performs role-based authorization
      • *
      • Performs permission-based authorization
      • *
      • Supports requireAll vs requireAny semantics
      • - *
      • Optionally integrates with SecurityCenter for complex policies
      • *
      - * + * *

      What Microservices Need to Do

      *

      Nothing. Authorization works automatically based on:

      *
        *
      • @Secure annotations on controllers/methods
      • *
      • Programmatic security rules in EndpointSecurityRegistry
      • *
      - * + * *

      Authorization Logic

      - *

      By default, this service checks if:

      + *

      Authorization is evaluated entirely against the roles and permissions already resolved + * into the {@code AppContext}:

      *
        - *
      1. User's roles (from context) match required roles
      2. - *
      3. User's permissions (from context) match required permissions
      4. - *
      5. If SecurityCenter is enabled, delegates complex policy evaluation
      6. + *
      7. The subject's roles (from context) are matched against the required roles
      8. + *
      9. The subject's permissions (from context) are matched against the required permissions
      10. *
      - * - *

      SecurityCenter Integration

      - *

      When SecurityCenter SDK is available, complex authorization policies - * will be evaluated by SecurityCenter for:

      - *
        - *
      • Attribute-Based Access Control (ABAC)
      • - *
      • Policy-based decisions
      • - *
      • Audit trail
      • - *
      - * + * + *

      All decisioning is product-agnostic and relies exclusively on the validated identity + * resolved by the context resolution layer; there is no dependency on any external session + * store or domain-specific resource scoping.

      + * * @author Firefly Development Team * @since 1.0.0 */ @Slf4j -@RequiredArgsConstructor public class DefaultSecurityAuthorizationService extends AbstractSecurityAuthorizationService { - - @Autowired(required = false) - private final SessionManager sessionManager; - - // The parent AbstractSecurityAuthorizationService already provides: - // - Role checking (hasRole, hasAnyRole, hasAllRoles) - // - Permission checking (hasPermission, hasAnyPermission, hasAllPermissions) + + // The parent AbstractSecurityAuthorizationService already provides everything needed: + // - Role checking (checkRoles) + // - Permission checking (checkPermissions) // - Authorization with requireAll/requireAny semantics - - /** - * Enhanced authorization using SessionManager for product access validation. - * - *

      This method integrates with the Security Center's SessionManager to:

      - *
        - *
      • Validate party has access to specific products/contracts
      • - *
      • Check granular permissions (action + resource)
      • - *
      • Provide graceful degradation if SecurityCenter is unavailable
      • - *
      - */ - @Override - protected Mono authorizeWithSecurityCenter( - AppContext context, - AppSecurityContext securityContext) { - - if (sessionManager == null) { - log.warn("SessionManager not available - falling back to basic role/permission checks. " + - "Deploy common-platform-security-center for enhanced authorization."); - return super.authorizeWithSecurityCenter(context, securityContext); - } - - log.debug("Using SessionManager for authorization: party={}, contract={}, product={}", - context.getPartyId(), context.getContractId(), context.getProductId()); - - // If product access needs to be validated - if (context.getProductId() != null) { - return sessionManager.hasAccessToProduct(context.getPartyId(), context.getProductId()) - .flatMap(hasAccess -> { - if (!hasAccess) { - log.warn("Party {} does not have access to product {}", - context.getPartyId(), context.getProductId()); - return Mono.just(createUnauthorizedContext(securityContext, - "No access to requested product")); - } - - // Product access OK - now check role/permission requirements - return performRolePermissionChecks(context, securityContext); - }) - .doOnError(error -> log.error("Error checking product access via SessionManager: {}", - error.getMessage(), error)) - .onErrorResume(error -> { - // Graceful degradation on error - log.warn("Falling back to basic checks due to error: {}", error.getMessage()); - return performRolePermissionChecks(context, securityContext); - }); - } - - // No product context - just perform role/permission checks - return performRolePermissionChecks(context, securityContext); - } - - /** - * Performs standard role and permission checks using the AppContext. - */ - private Mono performRolePermissionChecks( - AppContext context, AppSecurityContext securityContext) { - - // Check required roles - if (securityContext.hasRequiredRoles()) { - return checkRoles(context, securityContext) - .flatMap(rolesOk -> { - if (!rolesOk) { - return Mono.just(createUnauthorizedContext(securityContext, - "Required roles not present")); - } - // Roles OK - check permissions if needed - if (securityContext.hasRequiredPermissions()) { - return checkPermissions(context, securityContext); - } - return Mono.just(createAuthorizedContext(securityContext)); - }); - } - - // Check permissions - if (securityContext.hasRequiredPermissions()) { - return checkPermissions(context, securityContext); - } - - // No specific requirements - grant access - return Mono.just(createAuthorizedContext(securityContext)); - } + // + // Authorization is performed solely from the roles and permissions already resolved + // into the AppContext, so no additional behaviour is required here. } diff --git a/src/main/java/org/fireflyframework/common/application/security/SecurityAuthorizationService.java b/src/main/java/org/fireflyframework/common/application/security/SecurityAuthorizationService.java index c63f71f..420ebd3 100644 --- a/src/main/java/org/fireflyframework/common/application/security/SecurityAuthorizationService.java +++ b/src/main/java/org/fireflyframework/common/application/security/SecurityAuthorizationService.java @@ -25,52 +25,54 @@ /** * Service for authorization decisions. - * Integrates with Firefly SecurityCenter to determine access rights. - * + * + *

      Authorization is evaluated against the roles and permissions already resolved into the + * {@link AppContext} by the context resolution layer. The service is fully product-agnostic.

      + * * @author Firefly Development Team * @since 1.0.0 */ public interface SecurityAuthorizationService { - + /** * Authorizes an operation based on the application context and security requirements. - * + * * @param context the application context * @param securityContext the security context with requirements * @return Mono of updated AppSecurityContext with authorization result */ Mono authorize(AppContext context, AppSecurityContext securityContext); - + /** - * Checks if a party has a specific role in a contract/product context. - * + * Checks if the current subject has a specific role. + * * @param context the application context * @param role the role to check * @return Mono of boolean indicating if role is present */ Mono hasRole(AppContext context, String role); - + /** - * Checks if a party has a specific permission in a contract/product context. - * + * Checks if the current subject has a specific permission. + * * @param context the application context * @param permission the permission to check * @return Mono of boolean indicating if permission is granted */ Mono hasPermission(AppContext context, String permission); - + /** * Evaluates a custom security expression. - * + * * @param context the application context * @param expression the SpEL expression to evaluate * @return Mono of boolean result */ Mono evaluateExpression(AppContext context, String expression); - + /** - * Checks if a party has all specified permissions. - * + * Checks if the current subject has all specified permissions. + * * @param context the application context * @param permissions the permissions to check * @return Mono of boolean indicating if all permissions are granted @@ -83,10 +85,10 @@ default Mono hasAllPermissions(AppContext context, Set permissi .flatMap(p -> hasPermission(context, p)) .all(Boolean::booleanValue); } - + /** - * Checks if a party has any of the specified roles. - * + * Checks if the current subject has any of the specified roles. + * * @param context the application context * @param roles the roles to check * @return Mono of boolean indicating if any role is present diff --git a/src/main/java/org/fireflyframework/common/application/security/annotation/RequireContext.java b/src/main/java/org/fireflyframework/common/application/security/annotation/RequireContext.java deleted file mode 100644 index 210413a..0000000 --- a/src/main/java/org/fireflyframework/common/application/security/annotation/RequireContext.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2024-2026 Firefly Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.fireflyframework.common.application.security.annotation; - -import java.lang.annotation.*; - -/** - * Annotation to indicate that a method requires specific context components. - * Used to ensure that required context information (contract, product, etc.) is present. - * - *

      Usage example:

      - *
      - * {@literal @}PostMapping("/transfer")
      - * {@literal @}RequireContext(contract = true, product = true)
      - * public Mono<Transfer> transfer(@RequestBody TransferRequest request) {
      - *     // This method requires both contractId and productId in the context
      - * }
      - * 
      - * - * @author Firefly Development Team - * @since 1.0.0 - */ -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface RequireContext { - - /** - * Whether a contract ID is required in the context. - * - * @return true if contractId must be present - */ - boolean contract() default false; - - /** - * Whether a product ID is required in the context. - * - * @return true if productId must be present - */ - boolean product() default false; - - /** - * Whether tenant configuration must be loaded. - * - * @return true if tenant config must be present - */ - boolean tenantConfig() default true; - - /** - * Specific providers that must be configured for the tenant. - * - * @return array of required provider types - */ - String[] requiredProviders() default {}; - - /** - * Whether to fail fast if context requirements are not met. - * If true, throw an exception immediately. - * If false, log a warning and continue. - * - * @return true to fail fast - */ - boolean failFast() default true; -} diff --git a/src/main/java/org/fireflyframework/common/application/security/annotation/Secure.java b/src/main/java/org/fireflyframework/common/application/security/annotation/Secure.java index cb09ed5..a9dd15a 100644 --- a/src/main/java/org/fireflyframework/common/application/security/annotation/Secure.java +++ b/src/main/java/org/fireflyframework/common/application/security/annotation/Secure.java @@ -21,36 +21,36 @@ /** * Annotation for declarative endpoint security configuration. * Can be applied to controller classes or individual methods. - * + * *

      When applied at class level, security rules apply to all methods in the class. * Method-level annotations override class-level security configuration.

      - * + * *

      Usage example:

      *
        * {@literal @}RestController
      - * {@literal @}RequestMapping("/api/v1/accounts")
      - * {@literal @}Secure(roles = {"ACCOUNT_OWNER", "ACCOUNT_ADMIN"})
      - * public class AccountController {
      - *     
      + * {@literal @}RequestMapping("/api/v1/resources")
      + * {@literal @}Secure(roles = {"RESOURCE_OWNER", "RESOURCE_ADMIN"})
      + * public class ResourceController {
      + *
        *     {@literal @}GetMapping("/{id}")
      - *     public Mono<Account> getAccount(@PathVariable UUID id) {
      - *         // Only accessible by users with ACCOUNT_OWNER or ACCOUNT_ADMIN roles
      + *     public Mono<Resource> getResource(@PathVariable UUID id) {
      + *         // Only accessible by users with RESOURCE_OWNER or RESOURCE_ADMIN roles
        *     }
      - *     
      - *     {@literal @}PostMapping("/{id}/transfer")
      - *     {@literal @}Secure(roles = "ACCOUNT_OWNER", permissions = "TRANSFER_FUNDS")
      - *     public Mono<Transfer> transfer(@PathVariable UUID id, @RequestBody TransferRequest request) {
      - *         // Requires both ACCOUNT_OWNER role AND TRANSFER_FUNDS permission
      + *
      + *     {@literal @}PostMapping("/{id}/action")
      + *     {@literal @}Secure(roles = "RESOURCE_OWNER", permissions = "EXECUTE_ACTION")
      + *     public Mono<ActionResult> action(@PathVariable UUID id, @RequestBody ActionRequest request) {
      + *         // Requires both RESOURCE_OWNER role AND EXECUTE_ACTION permission
        *     }
      - *     
      + *
        *     {@literal @}GetMapping("/public")
        *     {@literal @}Secure(allowAnonymous = true)
      - *     public Mono<List<Account>> listPublicAccounts() {
      + *     public Mono<List<Resource>> listPublicResources() {
        *         // Accessible without authentication
        *     }
        * }
        * 
      - * + * * @author Firefly Development Team * @since 1.0.0 */ @@ -58,57 +58,57 @@ @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Secure { - + /** * Roles required to access this endpoint. * If multiple roles are specified, the user must have at least one of them (OR logic). - * + * * @return array of required role names */ String[] roles() default {}; - + /** * Permissions required to access this endpoint. * If multiple permissions are specified, the user must have at least one of them (OR logic). - * + * * @return array of required permission names */ String[] permissions() default {}; - + /** * Whether all specified roles are required (AND logic) instead of any (OR logic). - * + * * @return true to require all roles, false to require any role */ boolean requireAllRoles() default false; - + /** * Whether all specified permissions are required (AND logic) instead of any (OR logic). - * + * * @return true to require all permissions, false to require any permission */ boolean requireAllPermissions() default false; - + /** * Whether anonymous access is allowed. * If true, no authentication is required. - * + * * @return true to allow anonymous access */ boolean allowAnonymous() default false; - + /** * Whether authentication is required. * If false, the endpoint can be accessed without authentication. - * + * * @return true if authentication is required */ boolean requiresAuthentication() default true; - + /** * Custom security expression using SpEL. * Allows for complex authorization logic beyond simple role/permission checks. - * + * *

      Available variables in expressions:

      *
        *
      • context: The current AppContext
      • @@ -116,34 +116,26 @@ *
      • metadata: The current AppMetadata
      • *
      • principal: The authenticated principal
      • *
      - * - *

      Example: {@code expression = "context.hasRole('ADMIN') or context.partyId == #accountId"}

      - * + * + *

      Example: {@code expression = "context.hasRole('ADMIN') or context.subject == #ownerId"}

      + * * @return SpEL expression for custom authorization logic */ String expression() default ""; - - /** - * Whether to delegate authorization to the Firefly SecurityCenter. - * If true, the SecurityCenter will be consulted for authorization decisions. - * - * @return true to use SecurityCenter for authorization - */ - boolean useSecurityCenter() default true; - + /** * Additional security attributes as key=value pairs. * Can be used for custom security extensions. - * + * *

      Example: {@code attributes = {"rateLimit=100", "ipWhitelist=true"}}

      - * + * * @return array of key=value security attribute pairs */ String[] attributes() default {}; - + /** * Description of the security requirement for documentation purposes. - * + * * @return human-readable description of security requirements */ String description() default ""; diff --git a/src/main/java/org/fireflyframework/common/application/service/AbstractApplicationService.java b/src/main/java/org/fireflyframework/common/application/service/AbstractApplicationService.java index f3c5bed..24997c7 100644 --- a/src/main/java/org/fireflyframework/common/application/service/AbstractApplicationService.java +++ b/src/main/java/org/fireflyframework/common/application/service/AbstractApplicationService.java @@ -17,8 +17,6 @@ package org.fireflyframework.common.application.service; import org.fireflyframework.common.application.context.AppConfig; -import org.fireflyframework.common.application.context.AppContext; -import org.fireflyframework.common.application.context.AppMetadata; import org.fireflyframework.common.application.context.ApplicationExecutionContext; import org.fireflyframework.common.application.resolver.ConfigResolver; import org.fireflyframework.common.application.resolver.ContextResolver; @@ -30,19 +28,19 @@ /** * Abstract base class for application layer services. * Provides common functionality for context resolution, security, and business process orchestration. - * + * *

      Application layer services are responsible for:

      *
        *
      • Orchestrating business processes across multiple domain services
      • - *
      • Managing application context (party, contract, product)
      • + *
      • Managing the application context (subject, tenant, roles, permissions)
      • *
      • Enforcing security and authorization policies
      • *
      • Coordinating with external platform services
      • *
      - * + * *

      Typical usage:

      *
        * public class AccountApplicationService extends AbstractApplicationService {
      - *     
      + *
        *     public Mono<Transfer> transferFunds(ServerWebExchange exchange, TransferRequest request) {
        *         return resolveExecutionContext(exchange)
        *             .flatMap(context -> {
      @@ -52,20 +50,20 @@
        *     }
        * }
        * 
      - * + * * @author Firefly Development Team * @since 1.0.0 */ @Slf4j public abstract class AbstractApplicationService { - + protected final ContextResolver contextResolver; protected final ConfigResolver configResolver; protected final SecurityAuthorizationService authorizationService; - + /** * Constructor with required dependencies. - * + * * @param contextResolver the context resolver * @param configResolver the config resolver * @param authorizationService the authorization service @@ -77,54 +75,32 @@ protected AbstractApplicationService(ContextResolver contextResolver, this.configResolver = configResolver; this.authorizationService = authorizationService; } - + /** * Resolves the complete application execution context from the request. - * This includes metadata, business context, and configuration. - * + * This includes the request context, tenant configuration, and security context. + * * @param exchange the server web exchange * @return Mono of ApplicationExecutionContext */ protected Mono resolveExecutionContext(ServerWebExchange exchange) { log.debug("Resolving execution context for request"); - + return contextResolver.resolveContext(exchange) - .flatMap(appContext -> + .flatMap(appContext -> configResolver.resolveConfig(appContext.getTenantId()) .map(appConfig -> ApplicationExecutionContext.builder() .context(appContext) .config(appConfig) .build()) ) - .doOnSuccess(ctx -> log.debug("Successfully resolved execution context for party: {}", ctx.getPartyId())) + .doOnSuccess(ctx -> log.debug("Successfully resolved execution context for subject: {}", ctx.getSubject())) .doOnError(error -> log.error("Failed to resolve execution context", error)); } - - /** - * Validates that the execution context has required components. - * - * @param context the execution context - * @param requireContract whether contract ID is required - * @param requireProduct whether product ID is required - * @return Mono of validated context - */ - protected Mono validateContext(ApplicationExecutionContext context, - boolean requireContract, - boolean requireProduct) { - if (requireContract && !context.getContext().hasContract()) { - return Mono.error(new IllegalStateException("Contract ID is required but not present in context")); - } - - if (requireProduct && !context.getContext().hasProduct()) { - return Mono.error(new IllegalStateException("Product ID is required but not present in context")); - } - - return Mono.just(context); - } - + /** - * Checks if the party has the required role. - * + * Checks if the subject has the required role. + * * @param context the execution context * @param role the required role * @return Mono that completes if role is present, errors otherwise @@ -139,10 +115,10 @@ protected Mono requireRole(ApplicationExecutionContext context, String rol return Mono.empty(); }); } - + /** - * Checks if the party has the required permission. - * + * Checks if the subject has the required permission. + * * @param context the execution context * @param permission the required permission * @return Mono that completes if permission is granted, errors otherwise @@ -157,24 +133,24 @@ protected Mono requirePermission(ApplicationExecutionContext context, Stri return Mono.empty(); }); } - + /** * Gets a provider configuration for the tenant. - * + * * @param context the execution context * @param providerType the provider type * @return Mono of provider config */ - protected Mono getProviderConfig(ApplicationExecutionContext context, + protected Mono getProviderConfig(ApplicationExecutionContext context, String providerType) { return Mono.justOrEmpty(context.getConfig().getProvider(providerType)) .switchIfEmpty(Mono.error(new IllegalStateException( "Provider not configured: " + providerType))); } - + /** * Checks if a feature is enabled for the tenant. - * + * * @param context the execution context * @param feature the feature flag name * @return Mono of boolean diff --git a/src/main/java/org/fireflyframework/common/application/spi/SessionContext.java b/src/main/java/org/fireflyframework/common/application/spi/SessionContext.java deleted file mode 100644 index a5e934e..0000000 --- a/src/main/java/org/fireflyframework/common/application/spi/SessionContext.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.fireflyframework.common.application.spi; - -import org.fireflyframework.common.application.spi.dto.ContractInfoDTO; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -/** - * Represents a user session context. - *

      - * Carries session metadata, user roles, scopes, contracts, and any domain-specific context. - * Platform-specific implementations can extend this class to add additional fields. - *

      - * - * @author Firefly Development Team - * @since 1.0.0 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class SessionContext { - - private String sessionId; - private UUID partyId; - private String userId; - private String tenantId; - private List roles; - private List scopes; - private Map attributes; - private List activeContracts; - private LocalDateTime createdAt; - private SessionStatus status; - - /** - * Session lifecycle status. - */ - public enum SessionStatus { - ACTIVE, EXPIRED, INVALIDATED - } -} diff --git a/src/main/java/org/fireflyframework/common/application/spi/SessionManager.java b/src/main/java/org/fireflyframework/common/application/spi/SessionManager.java deleted file mode 100644 index f66cb7e..0000000 --- a/src/main/java/org/fireflyframework/common/application/spi/SessionManager.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.fireflyframework.common.application.spi; - -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -import java.util.UUID; - -/** - * SPI interface for session management. - *

      - * Implementations of this interface provide the mechanism for retrieving and managing - * user session contexts. Platform-specific implementations (e.g., for banking, e-commerce) - * should implement this interface to integrate with their identity/session infrastructure. - *

      - */ -public interface SessionManager { - - /** - * Retrieves the current session context for the given token. - * - * @param token the authentication token - * @return a Mono emitting the session context - */ - Mono getSessionContext(String token); - - /** - * Creates or retrieves the session associated with the current web exchange. - *

      - * Extracts the authentication token from the exchange and returns the enriched - * session context. If no active session exists, a new one may be created. - *

      - * - * @param exchange the current server web exchange - * @return a Mono emitting the session context - */ - Mono createOrGetSession(ServerWebExchange exchange); - - /** - * Validates whether the given token represents a valid session. - * - * @param token the authentication token - * @return a Mono emitting true if the session is valid - */ - Mono isSessionValid(String token); - - /** - * Checks whether the given party has access to the specified product. - * - * @param partyId the party identifier - * @param productId the product identifier - * @return a Mono emitting true if the party has access to the product - */ - Mono hasAccessToProduct(UUID partyId, UUID productId); - - /** - * Checks whether the given party has a specific permission on a product. - * - * @param partyId the party identifier - * @param productId the product identifier - * @param actionType the action type (e.g., READ, WRITE, DELETE) - * @param resourceType the resource type (e.g., BALANCE, TRANSACTION) - * @return a Mono emitting true if the party has the permission - */ - Mono hasPermission(UUID partyId, UUID productId, String actionType, String resourceType); - - /** - * Invalidates the session associated with the given token. - * - * @param token the authentication token - * @return a Mono completing when the session is invalidated - */ - Mono invalidateSession(String token); -} diff --git a/src/main/java/org/fireflyframework/common/application/spi/dto/ContractInfoDTO.java b/src/main/java/org/fireflyframework/common/application/spi/dto/ContractInfoDTO.java deleted file mode 100644 index b7cd888..0000000 --- a/src/main/java/org/fireflyframework/common/application/spi/dto/ContractInfoDTO.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.fireflyframework.common.application.spi.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -/** - * DTO representing a contract within a user session. - * - * @author Firefly Development Team - * @since 1.0.0 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ContractInfoDTO { - - private UUID contractId; - private String contractNumber; - private RoleInfoDTO roleInContract; - private ProductInfoDTO product; - private boolean isActive; -} diff --git a/src/main/java/org/fireflyframework/common/application/spi/dto/ProductInfoDTO.java b/src/main/java/org/fireflyframework/common/application/spi/dto/ProductInfoDTO.java deleted file mode 100644 index 9e6c201..0000000 --- a/src/main/java/org/fireflyframework/common/application/spi/dto/ProductInfoDTO.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.fireflyframework.common.application.spi.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -/** - * DTO representing a product within a contract. - * - * @author Firefly Development Team - * @since 1.0.0 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class ProductInfoDTO { - - private UUID productId; - private String productName; -} diff --git a/src/main/java/org/fireflyframework/common/application/spi/dto/RoleInfoDTO.java b/src/main/java/org/fireflyframework/common/application/spi/dto/RoleInfoDTO.java deleted file mode 100644 index e78f363..0000000 --- a/src/main/java/org/fireflyframework/common/application/spi/dto/RoleInfoDTO.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.fireflyframework.common.application.spi.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.UUID; - -/** - * DTO representing a role within a contract. - * - * @author Firefly Development Team - * @since 1.0.0 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class RoleInfoDTO { - - private UUID roleId; - private String roleCode; - private String name; - private boolean isActive; - private List scopes; -} diff --git a/src/main/java/org/fireflyframework/common/application/spi/dto/RoleScopeInfoDTO.java b/src/main/java/org/fireflyframework/common/application/spi/dto/RoleScopeInfoDTO.java deleted file mode 100644 index 5bad01a..0000000 --- a/src/main/java/org/fireflyframework/common/application/spi/dto/RoleScopeInfoDTO.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.fireflyframework.common.application.spi.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -/** - * DTO representing a role scope (permission) within a role. - * - * @author Firefly Development Team - * @since 1.0.0 - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class RoleScopeInfoDTO { - - private UUID scopeId; - private String actionType; - private String resourceType; - private boolean isActive; -} diff --git a/src/main/java/org/fireflyframework/common/application/util/SessionContextMapper.java b/src/main/java/org/fireflyframework/common/application/util/SessionContextMapper.java deleted file mode 100644 index f49ae3d..0000000 --- a/src/main/java/org/fireflyframework/common/application/util/SessionContextMapper.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.fireflyframework.common.application.util; - -import org.fireflyframework.common.application.spi.SessionContext; -import lombok.extern.slf4j.Slf4j; - -import java.util.*; -import java.util.stream.Collectors; - -/** - * Utility class for mapping SessionContext to roles and permissions. - *

      - * This mapper extracts roles and permissions from the generic {@link SessionContext} SPI. - * Platform-specific implementations should provide a {@link SessionContext} that - * populates roles, scopes, and attributes according to their domain model. - *

      - * - * @author Firefly Framework Team - * @since 1.0.0 - */ -@Slf4j -public final class SessionContextMapper { - - private SessionContextMapper() { - // Utility class - } - - /** - * Extracts roles from the session context. - * - * @param sessionContext the session context - * @param scopeKey optional scope key for filtering (e.g., contractId) - * @param subScopeKey optional sub-scope key for filtering (e.g., productId) - * @return set of role strings - */ - public static Set extractRoles(SessionContext sessionContext, UUID scopeKey, UUID subScopeKey) { - if (sessionContext == null) { - log.debug("Session context is null, returning empty roles"); - return Collections.emptySet(); - } - - List roles = sessionContext.getRoles(); - if (roles == null || roles.isEmpty()) { - return Collections.emptySet(); - } - - Set result = new HashSet<>(roles); - log.debug("Extracted {} roles from session context", result.size()); - return result; - } - - /** - * Extracts permissions/scopes from the session context. - * - * @param sessionContext the session context - * @param scopeKey optional scope key for filtering - * @param subScopeKey optional sub-scope key for filtering - * @return set of permission strings - */ - public static Set extractPermissions(SessionContext sessionContext, UUID scopeKey, UUID subScopeKey) { - if (sessionContext == null) { - log.debug("Session context is null, returning empty permissions"); - return Collections.emptySet(); - } - - List scopes = sessionContext.getScopes(); - if (scopes == null || scopes.isEmpty()) { - return Collections.emptySet(); - } - - Set result = new HashSet<>(scopes); - log.debug("Extracted {} permissions from session context", result.size()); - return result; - } - - /** - * Checks if the session has a specific attribute. - * - * @param sessionContext the session context - * @param attributeKey the attribute key to check - * @return true if the attribute exists - */ - public static boolean hasAttribute(SessionContext sessionContext, String attributeKey) { - if (sessionContext == null || sessionContext.getAttributes() == null) { - return false; - } - return sessionContext.getAttributes().containsKey(attributeKey); - } - - /** - * Retrieves an attribute value from the session context. - * - * @param sessionContext the session context - * @param attributeKey the attribute key - * @param type the expected type - * @return the attribute value, or null if not found - */ - @SuppressWarnings("unchecked") - public static T getAttribute(SessionContext sessionContext, String attributeKey, Class type) { - if (sessionContext == null || sessionContext.getAttributes() == null) { - return null; - } - Object value = sessionContext.getAttributes().get(attributeKey); - if (value != null && type.isInstance(value)) { - return (T) value; - } - return null; - } -} diff --git a/src/test/java/org/fireflyframework/common/application/config/ApplicationLayerPropertiesTest.java b/src/test/java/org/fireflyframework/common/application/config/ApplicationLayerPropertiesTest.java index e7592fc..bf5435d 100644 --- a/src/test/java/org/fireflyframework/common/application/config/ApplicationLayerPropertiesTest.java +++ b/src/test/java/org/fireflyframework/common/application/config/ApplicationLayerPropertiesTest.java @@ -7,134 +7,134 @@ @DisplayName("ApplicationLayerProperties Tests") class ApplicationLayerPropertiesTest { - + @Test @DisplayName("Should create properties with defaults") void shouldCreatePropertiesWithDefaults() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); - + assertNotNull(properties.getSecurity()); assertNotNull(properties.getContext()); assertNotNull(properties.getConfig()); } - + @Test @DisplayName("Should have default security settings") void shouldHaveDefaultSecuritySettings() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); ApplicationLayerProperties.Security security = properties.getSecurity(); - + assertTrue(security.isEnabled()); - assertTrue(security.isUseSecurityCenter()); + assertTrue(security.isUsePolicyEngine()); assertNotNull(security.getDefaultRoles()); assertEquals(0, security.getDefaultRoles().length); assertFalse(security.isFailOnMissing()); } - + @Test @DisplayName("Should have default context settings") void shouldHaveDefaultContextSettings() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); ApplicationLayerProperties.Context context = properties.getContext(); - + assertTrue(context.isCacheEnabled()); assertEquals(300, context.getCacheTtl()); assertEquals(1000, context.getCacheMaxSize()); } - + @Test @DisplayName("Should have default config settings") void shouldHaveDefaultConfigSettings() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); ApplicationLayerProperties.Config config = properties.getConfig(); - + assertTrue(config.isCacheEnabled()); assertEquals(600, config.getCacheTtl()); assertFalse(config.isRefreshOnStartup()); } - + @Test @DisplayName("Should allow custom security settings") void shouldAllowCustomSecuritySettings() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); ApplicationLayerProperties.Security security = properties.getSecurity(); - + security.setEnabled(false); - security.setUseSecurityCenter(false); + security.setUsePolicyEngine(false); security.setDefaultRoles(new String[]{"USER", "GUEST"}); security.setFailOnMissing(true); - + assertFalse(security.isEnabled()); - assertFalse(security.isUseSecurityCenter()); + assertFalse(security.isUsePolicyEngine()); assertArrayEquals(new String[]{"USER", "GUEST"}, security.getDefaultRoles()); assertTrue(security.isFailOnMissing()); } - + @Test @DisplayName("Should allow custom context settings") void shouldAllowCustomContextSettings() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); ApplicationLayerProperties.Context context = properties.getContext(); - + context.setCacheEnabled(false); context.setCacheTtl(60); context.setCacheMaxSize(500); - + assertFalse(context.isCacheEnabled()); assertEquals(60, context.getCacheTtl()); assertEquals(500, context.getCacheMaxSize()); } - + @Test @DisplayName("Should allow custom config settings") void shouldAllowCustomConfigSettings() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); ApplicationLayerProperties.Config config = properties.getConfig(); - + config.setCacheEnabled(false); config.setCacheTtl(120); config.setRefreshOnStartup(true); - + assertFalse(config.isCacheEnabled()); assertEquals(120, config.getCacheTtl()); assertTrue(config.isRefreshOnStartup()); } - + @Test @DisplayName("Should allow replacing entire security object") void shouldAllowReplacingEntireSecurityObject() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); ApplicationLayerProperties.Security newSecurity = new ApplicationLayerProperties.Security(); - + newSecurity.setEnabled(false); properties.setSecurity(newSecurity); - + assertSame(newSecurity, properties.getSecurity()); assertFalse(properties.getSecurity().isEnabled()); } - + @Test @DisplayName("Should allow replacing entire context object") void shouldAllowReplacingEntireContextObject() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); ApplicationLayerProperties.Context newContext = new ApplicationLayerProperties.Context(); - + newContext.setCacheEnabled(false); properties.setContext(newContext); - + assertSame(newContext, properties.getContext()); assertFalse(properties.getContext().isCacheEnabled()); } - + @Test @DisplayName("Should allow replacing entire config object") void shouldAllowReplacingEntireConfigObject() { ApplicationLayerProperties properties = new ApplicationLayerProperties(); ApplicationLayerProperties.Config newConfig = new ApplicationLayerProperties.Config(); - + newConfig.setRefreshOnStartup(true); properties.setConfig(newConfig); - + assertSame(newConfig, properties.getConfig()); assertTrue(properties.getConfig().isRefreshOnStartup()); } diff --git a/src/test/java/org/fireflyframework/common/application/context/AppContextTest.java b/src/test/java/org/fireflyframework/common/application/context/AppContextTest.java index 9b30bf8..8021cf7 100644 --- a/src/test/java/org/fireflyframework/common/application/context/AppContextTest.java +++ b/src/test/java/org/fireflyframework/common/application/context/AppContextTest.java @@ -12,130 +12,120 @@ @DisplayName("AppContext Tests") class AppContextTest { - + @Test @DisplayName("Should create AppContext with builder") void shouldCreateAppContextWithBuilder() { - UUID partyId = UUID.randomUUID(); - UUID contractId = UUID.randomUUID(); - UUID productId = UUID.randomUUID(); + String subject = "user-123"; UUID tenantId = UUID.randomUUID(); Set roles = Set.of("ACCOUNT_OWNER", "ADMIN"); Set permissions = Set.of("READ", "WRITE", "DELETE"); - + AppContext context = AppContext.builder() - .partyId(partyId) - .contractId(contractId) - .productId(productId) + .subject(subject) .tenantId(tenantId) .roles(roles) .permissions(permissions) .build(); - - assertEquals(partyId, context.getPartyId()); - assertEquals(contractId, context.getContractId()); - assertEquals(productId, context.getProductId()); + + assertEquals(subject, context.getSubject()); assertEquals(tenantId, context.getTenantId()); assertEquals(roles, context.getRoles()); assertEquals(permissions, context.getPermissions()); } - + @Test @DisplayName("Should check if context has specific role") void shouldCheckHasRole() { AppContext context = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-123") .roles(Set.of("ACCOUNT_OWNER", "VIEWER")) .build(); - + assertTrue(context.hasRole("ACCOUNT_OWNER")); assertTrue(context.hasRole("VIEWER")); assertFalse(context.hasRole("ADMIN")); } - + @Test @DisplayName("Should check if context has any of specified roles") void shouldCheckHasAnyRole() { AppContext context = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-123") .roles(Set.of("ACCOUNT_OWNER")) .build(); - + assertTrue(context.hasAnyRole("ACCOUNT_OWNER", "ADMIN")); assertTrue(context.hasAnyRole("VIEWER", "ACCOUNT_OWNER")); assertFalse(context.hasAnyRole("ADMIN", "VIEWER")); } - + @Test @DisplayName("Should check if context has all specified roles") void shouldCheckHasAllRoles() { AppContext context = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-123") .roles(Set.of("ACCOUNT_OWNER", "ADMIN", "VIEWER")) .build(); - + assertTrue(context.hasAllRoles("ACCOUNT_OWNER", "ADMIN")); assertTrue(context.hasAllRoles("VIEWER")); assertFalse(context.hasAllRoles("ACCOUNT_OWNER", "EDITOR")); } - + @Test @DisplayName("Should handle null roles gracefully") void shouldHandleNullRoles() { AppContext context = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-123") .build(); - + assertFalse(context.hasRole("ADMIN")); assertFalse(context.hasAnyRole("ADMIN", "VIEWER")); assertFalse(context.hasAllRoles("ADMIN")); } - + @Test @DisplayName("Should check if context has specific permission") void shouldCheckHasPermission() { AppContext context = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-123") .permissions(Set.of("READ", "WRITE")) .build(); - + assertTrue(context.hasPermission("READ")); assertTrue(context.hasPermission("WRITE")); assertFalse(context.hasPermission("DELETE")); } - + @Test - @DisplayName("Should check if context has contract") - void shouldCheckHasContract() { - AppContext withContract = AppContext.builder() - .partyId(UUID.randomUUID()) - .contractId(UUID.randomUUID()) - .build(); - - AppContext withoutContract = AppContext.builder() - .partyId(UUID.randomUUID()) + @DisplayName("Should handle null permissions gracefully") + void shouldHandleNullPermissions() { + AppContext context = AppContext.builder() + .subject("user-123") .build(); - - assertTrue(withContract.hasContract()); - assertFalse(withoutContract.hasContract()); + + assertFalse(context.hasPermission("READ")); } - + @Test - @DisplayName("Should check if context has product") - void shouldCheckHasProduct() { - AppContext withProduct = AppContext.builder() - .partyId(UUID.randomUUID()) - .productId(UUID.randomUUID()) + @DisplayName("Should carry an optional tenant id") + void shouldCarryOptionalTenantId() { + UUID tenantId = UUID.randomUUID(); + + AppContext withTenant = AppContext.builder() + .subject("user-123") + .tenantId(tenantId) .build(); - - AppContext withoutProduct = AppContext.builder() - .partyId(UUID.randomUUID()) + + AppContext withoutTenant = AppContext.builder() + .subject("user-123") .build(); - - assertTrue(withProduct.hasProduct()); - assertFalse(withoutProduct.hasProduct()); + + assertEquals(tenantId, withTenant.getTenantId()); + assertNull(withoutTenant.getTenantId()); } - + @Test @DisplayName("Should store and retrieve attributes") void shouldStoreAndRetrieveAttributes() { @@ -143,61 +133,61 @@ void shouldStoreAndRetrieveAttributes() { attributes.put("key1", "value1"); attributes.put("key2", 123); attributes.put("key3", true); - + AppContext context = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-123") .attributes(attributes) .build(); - + assertEquals("value1", context.getAttribute("key1")); assertEquals(123, context.getAttribute("key2")); assertEquals(true, context.getAttribute("key3")); assertNull(context.getAttribute("nonexistent")); } - + @Test @DisplayName("Should handle null attributes gracefully") void shouldHandleNullAttributes() { AppContext context = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-123") .build(); - + assertNull(context.getAttribute("anyKey")); } - + @Test @DisplayName("Should support immutable updates with withers") void shouldSupportImmutableUpdates() { - UUID originalPartyId = UUID.randomUUID(); - UUID newPartyId = UUID.randomUUID(); - + String originalSubject = "user-original"; + String newSubject = "user-new"; + AppContext original = AppContext.builder() - .partyId(originalPartyId) + .subject(originalSubject) .build(); - - AppContext updated = original.withPartyId(newPartyId); - - assertEquals(originalPartyId, original.getPartyId()); - assertEquals(newPartyId, updated.getPartyId()); + + AppContext updated = original.withSubject(newSubject); + + assertEquals(originalSubject, original.getSubject()); + assertEquals(newSubject, updated.getSubject()); assertNotSame(original, updated); } - + @Test @DisplayName("Should support builder pattern with toBuilder") void shouldSupportToBuilder() { - UUID originalContractId = UUID.randomUUID(); - UUID newContractId = UUID.randomUUID(); - + Set originalRoles = Set.of("VIEWER"); + Set newRoles = Set.of("ADMIN"); + AppContext original = AppContext.builder() - .partyId(UUID.randomUUID()) - .contractId(originalContractId) + .subject("user-123") + .roles(originalRoles) .build(); - + AppContext updated = original.toBuilder() - .contractId(newContractId) + .roles(newRoles) .build(); - - assertEquals(originalContractId, original.getContractId()); - assertEquals(newContractId, updated.getContractId()); + + assertEquals(originalRoles, original.getRoles()); + assertEquals(newRoles, updated.getRoles()); } } diff --git a/src/test/java/org/fireflyframework/common/application/context/AppSecurityContextTest.java b/src/test/java/org/fireflyframework/common/application/context/AppSecurityContextTest.java index 4729fc0..477b22e 100644 --- a/src/test/java/org/fireflyframework/common/application/context/AppSecurityContextTest.java +++ b/src/test/java/org/fireflyframework/common/application/context/AppSecurityContextTest.java @@ -14,7 +14,7 @@ @DisplayName("AppSecurityContext Tests") class AppSecurityContextTest { - + @Test @DisplayName("Should create security context with builder") void shouldCreateSecurityContextWithBuilder() { @@ -26,7 +26,7 @@ void shouldCreateSecurityContextWithBuilder() { .authorized(true) .configSource(SecurityConfigSource.ANNOTATION) .build(); - + assertEquals("/api/accounts", context.getEndpoint()); assertEquals("POST", context.getHttpMethod()); assertTrue(context.getRequiredRoles().contains("ADMIN")); @@ -34,100 +34,100 @@ void shouldCreateSecurityContextWithBuilder() { assertTrue(context.isAuthorized()); assertEquals(SecurityConfigSource.ANNOTATION, context.getConfigSource()); } - + @Test @DisplayName("Should check if has required roles") void shouldCheckHasRequiredRoles() { AppSecurityContext withRoles = AppSecurityContext.builder() .requiredRoles(Set.of("USER")) .build(); - + AppSecurityContext withoutRoles = AppSecurityContext.builder() .build(); - + AppSecurityContext withEmptyRoles = AppSecurityContext.builder() .requiredRoles(Set.of()) .build(); - + assertTrue(withRoles.hasRequiredRoles()); assertFalse(withoutRoles.hasRequiredRoles()); assertFalse(withEmptyRoles.hasRequiredRoles()); } - + @Test @DisplayName("Should check if has required permissions") void shouldCheckHasRequiredPermissions() { AppSecurityContext withPermissions = AppSecurityContext.builder() .requiredPermissions(Set.of("READ")) .build(); - + AppSecurityContext withoutPermissions = AppSecurityContext.builder() .build(); - + assertTrue(withPermissions.hasRequiredPermissions()); assertFalse(withoutPermissions.hasRequiredPermissions()); } - + @Test @DisplayName("Should check if requires specific role") void shouldCheckRequiresRole() { AppSecurityContext context = AppSecurityContext.builder() .requiredRoles(Set.of("ADMIN", "EDITOR")) .build(); - + assertTrue(context.requiresRole("ADMIN")); assertTrue(context.requiresRole("EDITOR")); assertFalse(context.requiresRole("VIEWER")); } - + @Test @DisplayName("Should check if requires specific permission") void shouldCheckRequiresPermission() { AppSecurityContext context = AppSecurityContext.builder() .requiredPermissions(Set.of("READ", "WRITE")) .build(); - + assertTrue(context.requiresPermission("READ")); assertTrue(context.requiresPermission("WRITE")); assertFalse(context.requiresPermission("DELETE")); } - + @Test @DisplayName("Should store and retrieve security attributes") void shouldStoreAndRetrieveSecurityAttributes() { Map attributes = new HashMap<>(); attributes.put("key1", "value1"); attributes.put("key2", 123); - + AppSecurityContext context = AppSecurityContext.builder() .securityAttributes(attributes) .build(); - + assertEquals("value1", context.getSecurityAttribute("key1")); assertEquals(123, context.getSecurityAttribute("key2")); assertNull(context.getSecurityAttribute("nonexistent")); } - + @Test @DisplayName("Should default requiresAuthentication to true") void shouldDefaultRequiresAuthenticationToTrue() { AppSecurityContext context = AppSecurityContext.builder() .endpoint("/api/test") .build(); - + assertTrue(context.isRequiresAuthentication()); } - + @Test @DisplayName("Should default allowAnonymous to false") void shouldDefaultAllowAnonymousToFalse() { AppSecurityContext context = AppSecurityContext.builder() .endpoint("/api/test") .build(); - + assertFalse(context.isAllowAnonymous()); } - + @Test @DisplayName("Should allow anonymous access when configured") void shouldAllowAnonymousAccessWhenConfigured() { @@ -136,11 +136,11 @@ void shouldAllowAnonymousAccessWhenConfigured() { .allowAnonymous(true) .requiresAuthentication(false) .build(); - + assertTrue(context.isAllowAnonymous()); assertFalse(context.isRequiresAuthentication()); } - + @Test @DisplayName("Should store authorization failure reason") void shouldStoreAuthorizationFailureReason() { @@ -149,53 +149,53 @@ void shouldStoreAuthorizationFailureReason() { .authorized(false) .authorizationFailureReason("Insufficient permissions") .build(); - + assertFalse(context.isAuthorized()); assertEquals("Insufficient permissions", context.getAuthorizationFailureReason()); } - + @Test @DisplayName("Should support different security config sources") void shouldSupportDifferentSecurityConfigSources() { AppSecurityContext annotation = AppSecurityContext.builder() .configSource(SecurityConfigSource.ANNOTATION) .build(); - + AppSecurityContext explicitMap = AppSecurityContext.builder() .configSource(SecurityConfigSource.EXPLICIT_MAP) .build(); - - AppSecurityContext securityCenter = AppSecurityContext.builder() - .configSource(SecurityConfigSource.SECURITY_CENTER) + + AppSecurityContext policy = AppSecurityContext.builder() + .configSource(SecurityConfigSource.POLICY) .build(); - + AppSecurityContext defaultSource = AppSecurityContext.builder() .configSource(SecurityConfigSource.DEFAULT) .build(); - + assertEquals(SecurityConfigSource.ANNOTATION, annotation.getConfigSource()); assertEquals(SecurityConfigSource.EXPLICIT_MAP, explicitMap.getConfigSource()); - assertEquals(SecurityConfigSource.SECURITY_CENTER, securityCenter.getConfigSource()); + assertEquals(SecurityConfigSource.POLICY, policy.getConfigSource()); assertEquals(SecurityConfigSource.DEFAULT, defaultSource.getConfigSource()); } - + @Test @DisplayName("Should store security evaluation result") void shouldStoreSecurityEvaluationResult() { Instant now = Instant.now(); - + SecurityEvaluationResult evalResult = SecurityEvaluationResult.builder() .granted(true) .reason("Policy ALLOW_ADMIN matched") .evaluatedPolicy("ALLOW_ADMIN") .evaluatedAt(now) .build(); - + AppSecurityContext context = AppSecurityContext.builder() .endpoint("/api/test") .evaluationResult(evalResult) .build(); - + assertNotNull(context.getEvaluationResult()); assertTrue(context.getEvaluationResult().isGranted()); assertEquals("Policy ALLOW_ADMIN matched", context.getEvaluationResult().getReason()); @@ -205,45 +205,45 @@ void shouldStoreSecurityEvaluationResult() { @DisplayName("SecurityEvaluationResult Tests") class SecurityEvaluationResultTest { - + @Test @DisplayName("Should create evaluation result with builder") void shouldCreateEvaluationResultWithBuilder() { Instant now = Instant.now(); - + SecurityEvaluationResult result = SecurityEvaluationResult.builder() .granted(true) .reason("Access granted") .evaluatedPolicy("POLICY_001") .evaluatedAt(now) .build(); - + assertTrue(result.isGranted()); assertEquals("Access granted", result.getReason()); assertEquals("POLICY_001", result.getEvaluatedPolicy()); assertEquals(now, result.getEvaluatedAt()); } - + @Test @DisplayName("Should store evaluation details") void shouldStoreEvaluationDetails() { Map details = Map.of( - "evaluator", "SecurityCenter", + "evaluator", "PolicyEngine", "confidence", 0.95, "rulesEvaluated", 5 ); - + SecurityEvaluationResult result = SecurityEvaluationResult.builder() .granted(true) .evaluationDetails(details) .build(); - - assertEquals("SecurityCenter", result.getEvaluationDetail("evaluator")); + + assertEquals("PolicyEngine", result.getEvaluationDetail("evaluator")); assertEquals(0.95, result.getEvaluationDetail("confidence")); assertEquals(5, result.getEvaluationDetail("rulesEvaluated")); assertNull(result.getEvaluationDetail("nonexistent")); } - + @Test @DisplayName("Should handle denial with reason") void shouldHandleDenialWithReason() { @@ -252,12 +252,12 @@ void shouldHandleDenialWithReason() { .reason("User lacks required role: ADMIN") .evaluatedPolicy("REQUIRE_ADMIN_ROLE") .build(); - + assertFalse(result.isGranted()); assertEquals("User lacks required role: ADMIN", result.getReason()); assertEquals("REQUIRE_ADMIN_ROLE", result.getEvaluatedPolicy()); } - + @Test @DisplayName("Should support immutable updates with withers") void shouldSupportImmutableUpdates() { @@ -265,9 +265,9 @@ void shouldSupportImmutableUpdates() { .granted(false) .reason("Original reason") .build(); - + SecurityEvaluationResult updated = original.withGranted(true); - + assertFalse(original.isGranted()); assertTrue(updated.isGranted()); assertNotSame(original, updated); diff --git a/src/test/java/org/fireflyframework/common/application/context/ApplicationExecutionContextTest.java b/src/test/java/org/fireflyframework/common/application/context/ApplicationExecutionContextTest.java index 2763c24..3181615 100644 --- a/src/test/java/org/fireflyframework/common/application/context/ApplicationExecutionContextTest.java +++ b/src/test/java/org/fireflyframework/common/application/context/ApplicationExecutionContextTest.java @@ -11,120 +11,81 @@ @DisplayName("ApplicationExecutionContext Tests") class ApplicationExecutionContextTest { - + @Test @DisplayName("Should create execution context with builder") void shouldCreateExecutionContextWithBuilder() { - UUID partyId = UUID.randomUUID(); + String subject = "user-123"; UUID tenantId = UUID.randomUUID(); - UUID contractId = UUID.randomUUID(); - + AppContext context = AppContext.builder() - .partyId(partyId) + .subject(subject) .tenantId(tenantId) - .contractId(contractId) .roles(Set.of("USER")) .build(); - + AppConfig config = AppConfig.builder() .tenantId(tenantId) .tenantName("Test Tenant") .build(); - + AppSecurityContext securityContext = AppSecurityContext.builder() .endpoint("/api/test") .httpMethod("GET") .authorized(true) .build(); - + ApplicationExecutionContext execContext = ApplicationExecutionContext.builder() .context(context) .config(config) .securityContext(securityContext) .build(); - + assertNotNull(execContext.getContext()); assertNotNull(execContext.getConfig()); assertNotNull(execContext.getSecurityContext()); - assertEquals(partyId, execContext.getPartyId()); + assertEquals(subject, execContext.getSubject()); assertEquals(tenantId, execContext.getTenantId()); - assertEquals(contractId, execContext.getContractId()); } - + @Test @DisplayName("Should create minimal execution context") void shouldCreateMinimalExecutionContext() { - UUID partyId = UUID.randomUUID(); + String subject = "user-123"; UUID tenantId = UUID.randomUUID(); - - ApplicationExecutionContext context = ApplicationExecutionContext.createMinimal(partyId, tenantId); - + + ApplicationExecutionContext context = ApplicationExecutionContext.createMinimal(subject, tenantId); + assertNotNull(context); - assertEquals(partyId, context.getPartyId()); + assertEquals(subject, context.getSubject()); assertEquals(tenantId, context.getTenantId()); assertNotNull(context.getContext()); assertNotNull(context.getConfig()); assertNull(context.getSecurityContext()); } - + @Test @DisplayName("Should get tenantId from config") void shouldGetTenantIdFromConfig() { UUID tenantId = UUID.randomUUID(); - + ApplicationExecutionContext context = ApplicationExecutionContext.createMinimal( - UUID.randomUUID(), tenantId); - + "user-123", tenantId); + assertEquals(tenantId, context.getTenantId()); } - + @Test - @DisplayName("Should get partyId from context") - void shouldGetPartyIdFromContext() { - UUID partyId = UUID.randomUUID(); - + @DisplayName("Should get subject from context") + void shouldGetSubjectFromContext() { + String subject = "user-456"; + ApplicationExecutionContext context = ApplicationExecutionContext.createMinimal( - partyId, UUID.randomUUID()); - - assertEquals(partyId, context.getPartyId()); - } - - @Test - @DisplayName("Should get contractId from context") - void shouldGetContractIdFromContext() { - UUID contractId = UUID.randomUUID(); - - AppContext appContext = AppContext.builder() - .partyId(UUID.randomUUID()) - .contractId(contractId) - .build(); - - ApplicationExecutionContext context = ApplicationExecutionContext.builder() - .context(appContext) - .config(AppConfig.builder().tenantId(UUID.randomUUID()).build()) - .build(); - - assertEquals(contractId, context.getContractId()); - } - - @Test - @DisplayName("Should get productId from context") - void shouldGetProductIdFromContext() { - UUID productId = UUID.randomUUID(); - - AppContext appContext = AppContext.builder() - .partyId(UUID.randomUUID()) - .productId(productId) - .build(); - - ApplicationExecutionContext context = ApplicationExecutionContext.builder() - .context(appContext) - .config(AppConfig.builder().tenantId(UUID.randomUUID()).build()) - .build(); - - assertEquals(productId, context.getProductId()); + subject, UUID.randomUUID()); + + assertEquals(subject, context.getSubject()); } - + @Test @DisplayName("Should check if context is authorized") void shouldCheckIfAuthorized() { @@ -132,52 +93,52 @@ void shouldCheckIfAuthorized() { .endpoint("/api/test") .authorized(true) .build(); - + AppSecurityContext unauthorizedSecurity = AppSecurityContext.builder() .endpoint("/api/test") .authorized(false) .build(); - + ApplicationExecutionContext authorized = ApplicationExecutionContext.builder() - .context(AppContext.builder().partyId(UUID.randomUUID()).build()) + .context(AppContext.builder().subject("user-123").build()) .config(AppConfig.builder().tenantId(UUID.randomUUID()).build()) .securityContext(authorizedSecurity) .build(); - + ApplicationExecutionContext unauthorized = ApplicationExecutionContext.builder() - .context(AppContext.builder().partyId(UUID.randomUUID()).build()) + .context(AppContext.builder().subject("user-123").build()) .config(AppConfig.builder().tenantId(UUID.randomUUID()).build()) .securityContext(unauthorizedSecurity) .build(); - + ApplicationExecutionContext noSecurity = ApplicationExecutionContext.builder() - .context(AppContext.builder().partyId(UUID.randomUUID()).build()) + .context(AppContext.builder().subject("user-123").build()) .config(AppConfig.builder().tenantId(UUID.randomUUID()).build()) .build(); - + assertTrue(authorized.isAuthorized()); assertFalse(unauthorized.isAuthorized()); assertFalse(noSecurity.isAuthorized()); } - + @Test @DisplayName("Should check if context has role") void shouldCheckIfHasRole() { AppContext appContext = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-123") .roles(Set.of("ADMIN", "USER")) .build(); - + ApplicationExecutionContext context = ApplicationExecutionContext.builder() .context(appContext) .config(AppConfig.builder().tenantId(UUID.randomUUID()).build()) .build(); - + assertTrue(context.hasRole("ADMIN")); assertTrue(context.hasRole("USER")); assertFalse(context.hasRole("VIEWER")); } - + @Test @DisplayName("Should check if feature is enabled") void shouldCheckIfFeatureEnabled() { @@ -185,53 +146,53 @@ void shouldCheckIfFeatureEnabled() { .tenantId(UUID.randomUUID()) .featureFlags(Map.of("FEATURE_A", true, "FEATURE_B", false)) .build(); - + ApplicationExecutionContext context = ApplicationExecutionContext.builder() - .context(AppContext.builder().partyId(UUID.randomUUID()).build()) + .context(AppContext.builder().subject("user-123").build()) .config(appConfig) .build(); - + assertTrue(context.isFeatureEnabled("FEATURE_A")); assertFalse(context.isFeatureEnabled("FEATURE_B")); assertFalse(context.isFeatureEnabled("FEATURE_C")); } - + @Test @DisplayName("Should support immutable updates with withers") void shouldSupportImmutableUpdates() { - UUID originalPartyId = UUID.randomUUID(); - UUID newPartyId = UUID.randomUUID(); - + String originalSubject = "user-original"; + String newSubject = "user-new"; + AppContext originalContext = AppContext.builder() - .partyId(originalPartyId) + .subject(originalSubject) .build(); - + ApplicationExecutionContext original = ApplicationExecutionContext.builder() .context(originalContext) .config(AppConfig.builder().tenantId(UUID.randomUUID()).build()) .build(); - - AppContext newContext = originalContext.withPartyId(newPartyId); + + AppContext newContext = originalContext.withSubject(newSubject); ApplicationExecutionContext updated = original.withContext(newContext); - - assertEquals(originalPartyId, original.getPartyId()); - assertEquals(newPartyId, updated.getPartyId()); + + assertEquals(originalSubject, original.getSubject()); + assertEquals(newSubject, updated.getSubject()); assertNotSame(original, updated); } - + @Test @DisplayName("Should support toBuilder pattern") void shouldSupportToBuilder() { UUID originalTenantId = UUID.randomUUID(); UUID newTenantId = UUID.randomUUID(); - + ApplicationExecutionContext original = ApplicationExecutionContext.createMinimal( - UUID.randomUUID(), originalTenantId); - + "user-123", originalTenantId); + ApplicationExecutionContext updated = original.toBuilder() .config(AppConfig.builder().tenantId(newTenantId).build()) .build(); - + assertEquals(originalTenantId, original.getTenantId()); assertEquals(newTenantId, updated.getTenantId()); } diff --git a/src/test/java/org/fireflyframework/common/application/controller/AbstractApplicationControllerTest.java b/src/test/java/org/fireflyframework/common/application/controller/AbstractApplicationControllerTest.java index 6ae19d7..d63853b 100644 --- a/src/test/java/org/fireflyframework/common/application/controller/AbstractApplicationControllerTest.java +++ b/src/test/java/org/fireflyframework/common/application/controller/AbstractApplicationControllerTest.java @@ -37,146 +37,144 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * Unit tests for AbstractApplicationController. - * Tests application-layer context resolution (no contract or product). + * Unit tests for {@link AbstractApplicationController}. + * + *

      The controller resolves the product-agnostic {@link AppContext} (subject, tenant, roles, + * permissions) from the {@link ContextResolver} and combines it with the tenant {@link AppConfig}. + * There is no contract or product scoping at this layer.

      */ @ExtendWith(MockitoExtension.class) class AbstractApplicationControllerTest { - + @Mock private ContextResolver contextResolver; - + @Mock private ConfigResolver configResolver; - + @Mock private ServerWebExchange exchange; - + private TestApplicationController controller; - - private UUID testPartyId; + + private String testSubject; private UUID testTenantId; - + @BeforeEach void setUp() { controller = new TestApplicationController(); ReflectionTestUtils.setField(controller, "contextResolver", contextResolver); ReflectionTestUtils.setField(controller, "configResolver", configResolver); - - testPartyId = UUID.randomUUID(); + + testSubject = "user-" + UUID.randomUUID(); testTenantId = UUID.randomUUID(); } - + @Test void shouldResolveApplicationLayerContext() { // Given AppContext appContext = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) - .contractId(null) // No contract for application-layer - .productId(null) // No product for application-layer .roles(Set.of("customer:onboard")) .permissions(Set.of()) .build(); - + AppConfig appConfig = AppConfig.builder() .tenantId(testTenantId) .tenantName("Test Tenant") .build(); - - when(contextResolver.resolveContext(any(ServerWebExchange.class), isNull(), isNull())) + + when(contextResolver.resolveContext(any(ServerWebExchange.class))) .thenReturn(Mono.just(appContext)); when(configResolver.resolveConfig(testTenantId)) .thenReturn(Mono.just(appConfig)); - + // When Mono result = controller.resolveExecutionContext(exchange); - + // Then StepVerifier.create(result) .assertNext(ctx -> { assertThat(ctx).isNotNull(); assertThat(ctx.getContext()).isEqualTo(appContext); assertThat(ctx.getConfig()).isEqualTo(appConfig); - assertThat(ctx.getContext().getPartyId()).isEqualTo(testPartyId); + assertThat(ctx.getContext().getSubject()).isEqualTo(testSubject); assertThat(ctx.getContext().getTenantId()).isEqualTo(testTenantId); - assertThat(ctx.getContext().getContractId()).isNull(); - assertThat(ctx.getContext().getProductId()).isNull(); }) .verifyComplete(); - - verify(contextResolver).resolveContext(eq(exchange), isNull(), isNull()); + + verify(contextResolver).resolveContext(eq(exchange)); verify(configResolver).resolveConfig(testTenantId); } - + @Test void shouldHandleContextResolutionError() { // Given - when(contextResolver.resolveContext(any(ServerWebExchange.class), isNull(), isNull())) - .thenReturn(Mono.error(new IllegalStateException("X-Party-Id header not found"))); - + when(contextResolver.resolveContext(any(ServerWebExchange.class))) + .thenReturn(Mono.error(new IllegalStateException("No authenticated principal"))); + // When Mono result = controller.resolveExecutionContext(exchange); - + // Then StepVerifier.create(result) - .expectErrorMatches(error -> + .expectErrorMatches(error -> error instanceof IllegalStateException && - error.getMessage().contains("X-Party-Id header not found")) + error.getMessage().contains("No authenticated principal")) .verify(); } - + @Test void shouldHandleConfigResolutionError() { // Given AppContext appContext = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) .build(); - - when(contextResolver.resolveContext(any(ServerWebExchange.class), isNull(), isNull())) + + when(contextResolver.resolveContext(any(ServerWebExchange.class))) .thenReturn(Mono.just(appContext)); when(configResolver.resolveConfig(testTenantId)) .thenReturn(Mono.error(new RuntimeException("Config service unavailable"))); - + // When Mono result = controller.resolveExecutionContext(exchange); - + // Then StepVerifier.create(result) - .expectErrorMatches(error -> + .expectErrorMatches(error -> error instanceof RuntimeException && error.getMessage().contains("Config service unavailable")) .verify(); } - + @Test void shouldResolveContextWithRolesAndPermissions() { // Given AppContext appContext = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) .roles(Set.of("customer:onboard", "customer:viewer")) .permissions(Set.of("profile:read", "profile:update")) .build(); - + AppConfig appConfig = AppConfig.builder() .tenantId(testTenantId) .build(); - - when(contextResolver.resolveContext(any(ServerWebExchange.class), isNull(), isNull())) + + when(contextResolver.resolveContext(any(ServerWebExchange.class))) .thenReturn(Mono.just(appContext)); when(configResolver.resolveConfig(testTenantId)) .thenReturn(Mono.just(appConfig)); - + // When Mono result = controller.resolveExecutionContext(exchange); - + // Then StepVerifier.create(result) .assertNext(ctx -> { @@ -187,9 +185,9 @@ void shouldResolveContextWithRolesAndPermissions() { }) .verifyComplete(); } - + /** - * Concrete test implementation of AbstractApplicationController. + * Concrete test implementation of {@link AbstractApplicationController}. */ private static class TestApplicationController extends AbstractApplicationController { // Test implementation - inherits all functionality from AbstractApplicationController diff --git a/src/test/java/org/fireflyframework/common/application/controller/AbstractResourceControllerTest.java b/src/test/java/org/fireflyframework/common/application/controller/AbstractResourceControllerTest.java index 242deff..892f94c 100644 --- a/src/test/java/org/fireflyframework/common/application/controller/AbstractResourceControllerTest.java +++ b/src/test/java/org/fireflyframework/common/application/controller/AbstractResourceControllerTest.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024-2026 Firefly Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package org.fireflyframework.common.application.controller; import org.fireflyframework.common.application.context.AppConfig; @@ -25,164 +41,114 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +/** + * Unit tests for {@link AbstractResourceController}. + * + *

      The resource controller is a thin, product-agnostic base: it resolves the + * {@link AppContext} (subject, tenant, roles, permissions) from the {@link ContextResolver} + * and provides generic operation logging. It carries no contract/product scoping.

      + */ @DisplayName("AbstractResourceController Tests") @ExtendWith(MockitoExtension.class) class AbstractResourceControllerTest { - + @Mock private ContextResolver contextResolver; - + @Mock private ConfigResolver configResolver; - + @Mock private ServerWebExchange exchange; - + private TestResourceController controller; - - private UUID testPartyId; + + private String testSubject; private UUID testTenantId; - private UUID testContractId; - private UUID testProductId; - + @BeforeEach void setUp() { controller = new TestResourceController(); ReflectionTestUtils.setField(controller, "contextResolver", contextResolver); ReflectionTestUtils.setField(controller, "configResolver", configResolver); - - testPartyId = UUID.randomUUID(); + + testSubject = "user-" + UUID.randomUUID(); testTenantId = UUID.randomUUID(); - testContractId = UUID.randomUUID(); - testProductId = UUID.randomUUID(); } - - @Test - @DisplayName("Should validate valid context (both IDs)") - void shouldValidateValidContext() { - UUID contractId = UUID.randomUUID(); - UUID productId = UUID.randomUUID(); - - assertDoesNotThrow(() -> controller.testRequireContext(contractId, productId)); - } - - @Test - @DisplayName("Should throw exception for null contract ID") - void shouldThrowExceptionForNullContractId() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> controller.testRequireContext(null, testProductId) - ); - - assertTrue(exception.getMessage().contains("contractId is required")); - } - - @Test - @DisplayName("Should throw exception for null product ID") - void shouldThrowExceptionForNullProductId() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> controller.testRequireContext(testContractId, null) - ); - - assertTrue(exception.getMessage().contains("productId is required")); - } - + @Test - @DisplayName("Should log operation with resource context") - void shouldLogOperationWithResourceContext() { - UUID contractId = UUID.randomUUID(); - UUID productId = UUID.randomUUID(); - - assertDoesNotThrow(() -> controller.testLogOperation(contractId, productId, "testOperation")); + @DisplayName("Should log operation with operation name") + void shouldLogOperation() { + assertDoesNotThrow(() -> controller.testLogOperation("testOperation")); } - + @Test @DisplayName("Should handle null operation name in logging") void shouldHandleNullOperationNameInLogging() { - UUID contractId = UUID.randomUUID(); - UUID productId = UUID.randomUUID(); - - assertDoesNotThrow(() -> controller.testLogOperation(contractId, productId, null)); + assertDoesNotThrow(() -> controller.testLogOperation(null)); } - + @Test - @DisplayName("Should resolve resource context (contract + product)") - void shouldResolveResourceContext() { + @DisplayName("Should resolve execution context (subject + tenant + roles + permissions)") + void shouldResolveExecutionContext() { // Given AppContext appContext = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) - .contractId(testContractId) - .productId(testProductId) .roles(Set.of("transaction:viewer")) .permissions(Set.of("transaction:read")) .build(); - + AppConfig appConfig = AppConfig.builder() .tenantId(testTenantId) .tenantName("Test Tenant") .build(); - - when(contextResolver.resolveContext(any(ServerWebExchange.class), eq(testContractId), eq(testProductId))) + + when(contextResolver.resolveContext(any(ServerWebExchange.class))) .thenReturn(Mono.just(appContext)); when(configResolver.resolveConfig(testTenantId)) .thenReturn(Mono.just(appConfig)); - + // When - Mono result = controller.testResolveExecutionContext( - exchange, testContractId, testProductId); - + Mono result = controller.testResolveExecutionContext(exchange); + // Then StepVerifier.create(result) .assertNext(ctx -> { assertThat(ctx).isNotNull(); - assertThat(ctx.getContext().getPartyId()).isEqualTo(testPartyId); + assertThat(ctx.getContext().getSubject()).isEqualTo(testSubject); assertThat(ctx.getContext().getTenantId()).isEqualTo(testTenantId); - assertThat(ctx.getContext().getContractId()).isEqualTo(testContractId); - assertThat(ctx.getContext().getProductId()).isEqualTo(testProductId); + assertThat(ctx.getContext().getRoles()).containsExactly("transaction:viewer"); + assertThat(ctx.getContext().getPermissions()).containsExactly("transaction:read"); }) .verifyComplete(); - - verify(contextResolver).resolveContext(eq(exchange), eq(testContractId), eq(testProductId)); + + verify(contextResolver).resolveContext(eq(exchange)); verify(configResolver).resolveConfig(testTenantId); } - - @Test - @DisplayName("Should throw exception when contract ID is null in context resolution") - void shouldThrowExceptionWhenContractIdIsNullInContextResolution() { - // When & Then - assertThrows(IllegalArgumentException.class, () -> { - controller.testResolveExecutionContext(exchange, null, testProductId).block(); - }); - } - + @Test - @DisplayName("Should throw exception when product ID is null in context resolution") - void shouldThrowExceptionWhenProductIdIsNullInContextResolution() { - // When & Then - assertThrows(IllegalArgumentException.class, () -> { - controller.testResolveExecutionContext(exchange, testContractId, null).block(); - }); + @DisplayName("Should propagate context resolution error") + void shouldPropagateContextResolutionError() { + when(contextResolver.resolveContext(any(ServerWebExchange.class))) + .thenReturn(Mono.error(new IllegalStateException("No authenticated principal"))); + + assertThrows(IllegalStateException.class, + () -> controller.testResolveExecutionContext(exchange).block()); } - + /** - * Concrete test implementation of AbstractResourceController - * to expose protected methods for testing + * Concrete test implementation of {@link AbstractResourceController} + * to expose protected methods for testing. */ static class TestResourceController extends AbstractResourceController { - - public void testRequireContext(UUID contractId, UUID productId) { - requireContext(contractId, productId); - } - - public void testLogOperation(UUID contractId, UUID productId, String operation) { - logOperation(contractId, productId, operation); + + public void testLogOperation(String operation) { + logOperation(operation); } - - public Mono testResolveExecutionContext( - ServerWebExchange exchange, UUID contractId, UUID productId) { - return resolveExecutionContext(exchange, contractId, productId); + + public Mono testResolveExecutionContext(ServerWebExchange exchange) { + return resolveExecutionContext(exchange); } } } diff --git a/src/test/java/org/fireflyframework/common/application/integration/ControllerIntegrationTest.java b/src/test/java/org/fireflyframework/common/application/integration/ControllerIntegrationTest.java index f2b3c66..4216364 100644 --- a/src/test/java/org/fireflyframework/common/application/integration/ControllerIntegrationTest.java +++ b/src/test/java/org/fireflyframework/common/application/integration/ControllerIntegrationTest.java @@ -29,7 +29,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ServerWebExchange; @@ -42,204 +41,192 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * Integration test demonstrating both controller types: - * - AbstractApplicationController (application-layer, no contract/product) - * - AbstractResourceController (contract + product, both required) - * - * This test validates the complete architecture: - * 1. Istio injects X-Party-Id header - * 2. Config-mgmt resolves tenantId from partyId - * 3. Controllers extract contractId/productId from path variables - * 4. FireflySessionManager provides roles/permissions (mocked) - * 5. Context is fully resolved with complete resource hierarchy + * Integration test demonstrating both product-agnostic controller types: + *
        + *
      • {@link AbstractApplicationController} (application-layer endpoints)
      • + *
      • {@link AbstractResourceController} (resource endpoints)
      • + *
      + * + *

      This test validates the resolution flow:

      + *
        + *
      1. The validated security principal yields the authenticated subject + tenant
      2. + *
      3. The {@link ContextResolver} produces a product-agnostic {@link AppContext} + * (subject, tenant, roles, permissions)
      4. + *
      5. The {@link ConfigResolver} loads tenant {@link AppConfig}
      6. + *
      7. The controller assembles a complete {@link ApplicationExecutionContext}
      8. + *
      */ @ExtendWith(MockitoExtension.class) @DisplayName("Controller Integration Test - Two Controller Types") class ControllerIntegrationTest { - + @Mock private ContextResolver contextResolver; - + @Mock private ConfigResolver configResolver; - + @Mock private ServerWebExchange exchange; - + @Mock private ServerHttpRequest request; - - private UUID testPartyId; + + private String testSubject; private UUID testTenantId; - private UUID testContractId; - private UUID testProductId; - + private TestApplicationController applicationController; private TestResourceController resourceController; - + @BeforeEach void setUp() { - testPartyId = UUID.randomUUID(); + testSubject = "user-" + UUID.randomUUID(); testTenantId = UUID.randomUUID(); - testContractId = UUID.randomUUID(); - testProductId = UUID.randomUUID(); - + // Setup controllers applicationController = new TestApplicationController(); resourceController = new TestResourceController(); - + // Inject dependencies ReflectionTestUtils.setField(applicationController, "contextResolver", contextResolver); ReflectionTestUtils.setField(applicationController, "configResolver", configResolver); ReflectionTestUtils.setField(resourceController, "contextResolver", contextResolver); ReflectionTestUtils.setField(resourceController, "configResolver", configResolver); } - + @Test - @DisplayName("Scenario 1: Application-layer endpoint (Onboarding)") + @DisplayName("Scenario 1: Application-layer endpoint") void testApplicationLayerEndpoint() { - // Given: Onboarding endpoint with only party context + // Given: application-layer endpoint with subject + tenant context AppContext appContext = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) - .contractId(null) // No contract for onboarding - .productId(null) // No product for onboarding .roles(Set.of("customer:onboard")) .permissions(Set.of("profile:create")) .build(); - + AppConfig appConfig = AppConfig.builder() .tenantId(testTenantId) .tenantName("Test Bank") .build(); - - when(contextResolver.resolveContext(any(ServerWebExchange.class), isNull(), isNull())) + + when(contextResolver.resolveContext(any(ServerWebExchange.class))) .thenReturn(Mono.just(appContext)); when(configResolver.resolveConfig(testTenantId)) .thenReturn(Mono.just(appConfig)); - - // When: Call application-layer controller endpoint + + // When: call application-layer controller endpoint Mono result = applicationController.handleOnboarding(exchange); - - // Then: Context is resolved with party + tenant only + + // Then: context is resolved with subject + tenant + roles StepVerifier.create(result) .assertNext(ctx -> { - assertThat(ctx.getContext().getPartyId()).isEqualTo(testPartyId); + assertThat(ctx.getContext().getSubject()).isEqualTo(testSubject); assertThat(ctx.getContext().getTenantId()).isEqualTo(testTenantId); - assertThat(ctx.getContext().getContractId()).isNull(); - assertThat(ctx.getContext().getProductId()).isNull(); assertThat(ctx.getContext().getRoles()).contains("customer:onboard"); + assertThat(ctx.getContext().getPermissions()).contains("profile:create"); }) .verifyComplete(); - - verify(contextResolver).resolveContext(eq(exchange), isNull(), isNull()); + + verify(contextResolver).resolveContext(eq(exchange)); verify(configResolver).resolveConfig(testTenantId); } - + @Test - @DisplayName("Scenario 2: Resource endpoint (List Transactions with contract + product)") + @DisplayName("Scenario 2: Resource endpoint") void testResourceEndpoint() { - // Given: Transaction listing endpoint with full context + // Given: resource endpoint with subject + tenant + roles/permissions AppContext appContext = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) - .contractId(testContractId) - .productId(testProductId) .roles(Set.of("owner", "transaction:viewer")) .permissions(Set.of("transaction:read", "transaction:list")) .build(); - + AppConfig appConfig = AppConfig.builder() .tenantId(testTenantId) .tenantName("Test Bank") .build(); - - when(contextResolver.resolveContext(any(ServerWebExchange.class), eq(testContractId), eq(testProductId))) + + when(contextResolver.resolveContext(any(ServerWebExchange.class))) .thenReturn(Mono.just(appContext)); when(configResolver.resolveConfig(testTenantId)) .thenReturn(Mono.just(appConfig)); - - // When: Call resource controller endpoint with contractId + productId (both required) - Mono result = resourceController.listTransactions( - testContractId, testProductId, exchange); - - // Then: Context is resolved with complete resource hierarchy + + // When: call resource controller endpoint + Mono result = resourceController.listTransactions(exchange); + + // Then: context is resolved with subject + tenant + authorities StepVerifier.create(result) .assertNext(ctx -> { - assertThat(ctx.getContext().getPartyId()).isEqualTo(testPartyId); + assertThat(ctx.getContext().getSubject()).isEqualTo(testSubject); assertThat(ctx.getContext().getTenantId()).isEqualTo(testTenantId); - assertThat(ctx.getContext().getContractId()).isEqualTo(testContractId); - assertThat(ctx.getContext().getProductId()).isEqualTo(testProductId); assertThat(ctx.getContext().getRoles()).contains("owner", "transaction:viewer"); + assertThat(ctx.getContext().getPermissions()).contains("transaction:read"); }) .verifyComplete(); - - verify(contextResolver).resolveContext(eq(exchange), eq(testContractId), eq(testProductId)); + + verify(contextResolver).resolveContext(eq(exchange)); verify(configResolver).resolveConfig(testTenantId); } - + @Test - @DisplayName("Scenario 3: End-to-end flow - Onboarding → Access Resources") + @DisplayName("Scenario 3: End-to-end flow across both controller types") void testEndToEndFlow() { - // Step 1: Onboarding (application-layer, no contract/product) + // Step 1: application-layer endpoint AppContext onboardingContext = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) .roles(Set.of("customer:onboard")) .build(); - + AppConfig config = AppConfig.builder().tenantId(testTenantId).build(); - - when(contextResolver.resolveContext(any(), isNull(), isNull())) + + when(contextResolver.resolveContext(any(ServerWebExchange.class))) .thenReturn(Mono.just(onboardingContext)); when(configResolver.resolveConfig(testTenantId)) .thenReturn(Mono.just(config)); - + StepVerifier.create(applicationController.handleOnboarding(exchange)) .assertNext(ctx -> { - assertThat(ctx.getContext().getContractId()).isNull(); - assertThat(ctx.getContext().getProductId()).isNull(); + assertThat(ctx.getContext().getSubject()).isEqualTo(testSubject); + assertThat(ctx.getContext().getRoles()).contains("customer:onboard"); }) .verifyComplete(); - - // Step 2: After onboarding, access resources with contract + product (both required) + + // Step 2: resource endpoint resolved from the same authenticated principal AppContext resourceContext = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) - .contractId(testContractId) - .productId(testProductId) .roles(Set.of("owner", "transaction:viewer")) .build(); - - when(contextResolver.resolveContext(any(), eq(testContractId), eq(testProductId))) + + when(contextResolver.resolveContext(any(ServerWebExchange.class))) .thenReturn(Mono.just(resourceContext)); - - StepVerifier.create(resourceController.listTransactions(testContractId, testProductId, exchange)) + + StepVerifier.create(resourceController.listTransactions(exchange)) .assertNext(ctx -> { - assertThat(ctx.getContext().getContractId()).isEqualTo(testContractId); - assertThat(ctx.getContext().getProductId()).isEqualTo(testProductId); + assertThat(ctx.getContext().getSubject()).isEqualTo(testSubject); assertThat(ctx.getContext().getRoles()).contains("owner", "transaction:viewer"); }) .verifyComplete(); } - + // Test controller implementations - + static class TestApplicationController extends AbstractApplicationController { public Mono handleOnboarding(ServerWebExchange exchange) { return resolveExecutionContext(exchange); } } - + static class TestResourceController extends AbstractResourceController { - public Mono listTransactions( - UUID contractId, UUID productId, ServerWebExchange exchange) { - return resolveExecutionContext(exchange, contractId, productId); + public Mono listTransactions(ServerWebExchange exchange) { + return resolveExecutionContext(exchange); } } } diff --git a/src/test/java/org/fireflyframework/common/application/integration/SecurityAspectIntegrationTest.java b/src/test/java/org/fireflyframework/common/application/integration/SecurityAspectIntegrationTest.java index 350f677..179d012 100644 --- a/src/test/java/org/fireflyframework/common/application/integration/SecurityAspectIntegrationTest.java +++ b/src/test/java/org/fireflyframework/common/application/integration/SecurityAspectIntegrationTest.java @@ -41,59 +41,59 @@ /** * Integration tests for {@link SecurityAspect}. - * Tests AOP interception of @Secure annotations. + * Tests AOP interception of {@code @Secure} annotations against the product-agnostic + * {@link AppContext} (subject, tenant, roles, permissions). */ @DisplayName("SecurityAspect Integration Tests") class SecurityAspectIntegrationTest { - + private SecurityAuthorizationService authorizationService; private EndpointSecurityRegistry endpointSecurityRegistry; private SecurityAspect securityAspect; private TestService testService; private TestService proxiedService; - + @BeforeEach void setUp() { authorizationService = mock(SecurityAuthorizationService.class); endpointSecurityRegistry = new EndpointSecurityRegistry(); var applicationProperties = new org.fireflyframework.common.application.config.ApplicationLayerProperties(); - applicationProperties.getSecurity().setUseSecurityCenter(false); securityAspect = new SecurityAspect(authorizationService, endpointSecurityRegistry, applicationProperties); - + testService = new TestService(); - + // Create AOP proxy AspectJProxyFactory factory = new AspectJProxyFactory(testService); factory.addAspect(securityAspect); proxiedService = factory.getProxy(); } - + @Test @DisplayName("Should allow access when authorization is granted") void shouldAllowAccessWhenAuthorized() { // Given ApplicationExecutionContext context = createExecutionContext(); - + when(authorizationService.authorize(any(AppContext.class), any(AppSecurityContext.class))) .thenReturn(Mono.just(AppSecurityContext.builder() .endpoint("/test") .httpMethod("GET") .authorized(true) .build())); - + // When String result = proxiedService.secureMethod(context); - + // Then assertThat(result).isEqualTo("success"); } - + @Test @DisplayName("Should deny access when authorization is denied") void shouldDenyAccessWhenUnauthorized() { // Given ApplicationExecutionContext context = createExecutionContext(); - + when(authorizationService.authorize(any(AppContext.class), any(AppSecurityContext.class))) .thenReturn(Mono.just(AppSecurityContext.builder() .endpoint("/test") @@ -101,232 +101,232 @@ void shouldDenyAccessWhenUnauthorized() { .authorized(false) .authorizationFailureReason("Missing required role") .build())); - + // When/Then assertThatThrownBy(() -> proxiedService.secureMethod(context)) .isInstanceOf(AccessDeniedException.class) .hasMessageContaining("Missing required role"); } - + @Test @DisplayName("Should intercept method with @Secure annotation") void shouldInterceptSecureAnnotation() { // Given ApplicationExecutionContext context = createExecutionContext(); - + when(authorizationService.authorize(any(AppContext.class), any(AppSecurityContext.class))) .thenReturn(Mono.just(AppSecurityContext.builder() .endpoint("/test") .httpMethod("GET") .authorized(true) .build())); - + // When String result = proxiedService.methodWithRoles(context); - + // Then assertThat(result).isEqualTo("success-with-roles"); } - + @Test @DisplayName("Should check roles specified in @Secure annotation") void shouldCheckRolesFromAnnotation() { // Given ApplicationExecutionContext context = createExecutionContext(); - + when(authorizationService.authorize(any(AppContext.class), any(AppSecurityContext.class))) .thenAnswer(invocation -> { AppSecurityContext secContext = invocation.getArgument(1); - + // Verify that the aspect extracted roles from annotation assertThat(secContext.getRequiredRoles()).containsExactly("ADMIN"); assertThat(secContext.getConfigSource()) .isEqualTo(AppSecurityContext.SecurityConfigSource.ANNOTATION); - + // Simulate authorization success since user has ADMIN role return Mono.just(secContext.toBuilder() .authorized(true) .build()); }); - + // When String result = proxiedService.methodWithRoles(context); - + // Then assertThat(result).isEqualTo("success-with-roles"); } - + @Test @DisplayName("Should check permissions specified in @Secure annotation") void shouldCheckPermissionsFromAnnotation() { // Given ApplicationExecutionContext context = createExecutionContext(); - + when(authorizationService.authorize(any(AppContext.class), any(AppSecurityContext.class))) .thenAnswer(invocation -> { AppSecurityContext secContext = invocation.getArgument(1); - + // Verify that the aspect extracted permissions from annotation assertThat(secContext.getRequiredPermissions()).containsExactly("WRITE"); assertThat(secContext.getConfigSource()) .isEqualTo(AppSecurityContext.SecurityConfigSource.ANNOTATION); - + // Simulate authorization success since user has WRITE permission return Mono.just(secContext.toBuilder() .authorized(true) .build()); }); - + // When String result = proxiedService.methodWithPermissions(context); - + // Then assertThat(result).isEqualTo("success-with-permissions"); } - + @Test @DisplayName("Should skip security check when no execution context is provided") void shouldSkipSecurityCheckWithoutExecutionContext() { // When - Call without ExecutionContext String result = proxiedService.methodWithoutContext(); - + // Then - Should execute without security check assertThat(result).isEqualTo("no-context"); } - + @Test @DisplayName("Should check both roles and permissions from annotation") void shouldCheckRolesAndPermissionsFromAnnotation() { // Given ApplicationExecutionContext context = createExecutionContext(); - + when(authorizationService.authorize(any(AppContext.class), any(AppSecurityContext.class))) .thenAnswer(invocation -> { AppSecurityContext secContext = invocation.getArgument(1); - + // Verify that the aspect extracted both roles and permissions assertThat(secContext.getRequiredRoles()).containsExactly("ADMIN"); assertThat(secContext.getRequiredPermissions()).containsExactly("WRITE"); assertThat(secContext.getConfigSource()) .isEqualTo(AppSecurityContext.SecurityConfigSource.ANNOTATION); - + // Simulate authorization success return Mono.just(secContext.toBuilder() .authorized(true) .build()); }); - + // When String result = proxiedService.methodWithRolesAndPermissions(context); - + // Then assertThat(result).isEqualTo("success-with-both"); } - + @Test @DisplayName("Should deny access when user lacks required roles") void shouldDenyAccessWhenUserLacksRoles() { // Given - Context with user that has no ADMIN role ApplicationExecutionContext context = ApplicationExecutionContext.builder() .context(AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-" + UUID.randomUUID()) .tenantId(UUID.randomUUID()) .roles(Set.of("USER")) // Only USER role, not ADMIN .permissions(Set.of("READ")) .build()) .build(); - + when(authorizationService.authorize(any(AppContext.class), any(AppSecurityContext.class))) .thenAnswer(invocation -> { AppSecurityContext secContext = invocation.getArgument(1); - + // Simulate authorization service checking and denying return Mono.just(secContext.toBuilder() .authorized(false) .authorizationFailureReason("User does not have required ADMIN role") .build()); }); - + // When/Then assertThatThrownBy(() -> proxiedService.methodWithRoles(context)) .isInstanceOf(AccessDeniedException.class) .hasMessageContaining("User does not have required ADMIN role"); } - + @Test @DisplayName("Should pass AppContext to authorization service") void shouldPassAppContextToAuthorizationService() { // Given - UUID partyId = UUID.randomUUID(); + String subject = "user-" + UUID.randomUUID(); UUID tenantId = UUID.randomUUID(); ApplicationExecutionContext context = ApplicationExecutionContext.builder() .context(AppContext.builder() - .partyId(partyId) + .subject(subject) .tenantId(tenantId) .roles(Set.of("ADMIN")) .permissions(Set.of("WRITE")) .build()) .build(); - + when(authorizationService.authorize(any(AppContext.class), any(AppSecurityContext.class))) .thenAnswer(invocation -> { AppContext appContext = invocation.getArgument(0); - + // Verify that the aspect passed the correct AppContext - assertThat(appContext.getPartyId()).isEqualTo(partyId); + assertThat(appContext.getSubject()).isEqualTo(subject); assertThat(appContext.getTenantId()).isEqualTo(tenantId); assertThat(appContext.getRoles()).contains("ADMIN"); assertThat(appContext.getPermissions()).contains("WRITE"); - + AppSecurityContext secContext = invocation.getArgument(1); return Mono.just(secContext.toBuilder() .authorized(true) .build()); }); - + // When String result = proxiedService.secureMethod(context); - + // Then assertThat(result).isEqualTo("success"); } - + private ApplicationExecutionContext createExecutionContext() { return ApplicationExecutionContext.builder() .context(AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-" + UUID.randomUUID()) .tenantId(UUID.randomUUID()) .roles(Set.of("ADMIN", "USER")) .permissions(Set.of("READ", "WRITE")) .build()) .build(); } - + /** - * Test service with @Secure annotations. + * Test service with {@code @Secure} annotations. */ static class TestService { - + @Secure public String secureMethod(ApplicationExecutionContext context) { return "success"; } - + @Secure(roles = {"ADMIN"}) public String methodWithRoles(ApplicationExecutionContext context) { return "success-with-roles"; } - + @Secure(permissions = {"WRITE"}) public String methodWithPermissions(ApplicationExecutionContext context) { return "success-with-permissions"; } - + @Secure(roles = {"ADMIN"}, permissions = {"WRITE"}) public String methodWithRolesAndPermissions(ApplicationExecutionContext context) { return "success-with-both"; } - + @Secure public String methodWithoutContext() { return "no-context"; diff --git a/src/test/java/org/fireflyframework/common/application/integration/SecurityAuthorizationIntegrationTest.java b/src/test/java/org/fireflyframework/common/application/integration/SecurityAuthorizationIntegrationTest.java index 9af18fa..0b149c5 100644 --- a/src/test/java/org/fireflyframework/common/application/integration/SecurityAuthorizationIntegrationTest.java +++ b/src/test/java/org/fireflyframework/common/application/integration/SecurityAuthorizationIntegrationTest.java @@ -19,182 +19,194 @@ import org.fireflyframework.common.application.context.AppContext; import org.fireflyframework.common.application.context.AppSecurityContext; import org.fireflyframework.common.application.security.DefaultSecurityAuthorizationService; -import org.fireflyframework.common.application.spi.SessionManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import java.util.Set; import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - /** - * Integration test for SessionManager with DefaultSecurityAuthorizationService. - * - *

      Tests the authorization flow including:

      + * Integration tests for {@link DefaultSecurityAuthorizationService}. + * + *

      Authorization is decided solely from the roles and permissions already + * resolved onto the product-agnostic {@link AppContext}. There is no Security Center / session + * dependency and no contract/product scoping.

      + * *
        - *
      1. Product access validation via SessionManager
      2. - *
      3. Role-based authorization
      4. - *
      5. Permission-based authorization
      6. - *
      7. Graceful degradation when Security Center unavailable
      8. + *
      9. Role-based checks against {@link AppContext#getRoles()}
      10. + *
      11. Permission-based checks against {@link AppContext#getPermissions()}
      12. + *
      13. Endpoint authorization via {@link AppSecurityContext} required roles/permissions
      14. *
      */ -@ExtendWith(MockitoExtension.class) @DisplayName("Security Authorization Integration Tests") class SecurityAuthorizationIntegrationTest { - - @Mock - private SessionManager sessionManager; - + private DefaultSecurityAuthorizationService authorizationService; - - private UUID testPartyId; + + private String testSubject; private UUID testTenantId; - private UUID testContractId; - private UUID testProductId; - + @BeforeEach void setUp() { - authorizationService = new DefaultSecurityAuthorizationService(sessionManager); - - testPartyId = UUID.randomUUID(); + authorizationService = new DefaultSecurityAuthorizationService(); + + testSubject = "user-" + UUID.randomUUID(); testTenantId = UUID.randomUUID(); - testContractId = UUID.randomUUID(); - testProductId = UUID.randomUUID(); - } - - @Test - @DisplayName("Should validate product access when party has access") - void shouldValidateProductAccessWhenPartyHasAccess() { - // Given: Party has product access - when(sessionManager.hasAccessToProduct(testPartyId, testProductId)) - .thenReturn(Mono.just(true)); - - // When: Check product access - Mono result = sessionManager.hasAccessToProduct(testPartyId, testProductId); - - // Then: Returns true - StepVerifier.create(result) - .expectNext(true) - .verifyComplete(); - - verify(sessionManager, times(1)).hasAccessToProduct(testPartyId, testProductId); - } - - @Test - @DisplayName("Should validate product access when party lacks access") - void shouldValidateProductAccessWhenPartyLacksAccess() { - // Given: Party does NOT have product access - when(sessionManager.hasAccessToProduct(testPartyId, testProductId)) - .thenReturn(Mono.just(false)); - - // When: Check product access - Mono result = sessionManager.hasAccessToProduct(testPartyId, testProductId); - - // Then: Returns false - StepVerifier.create(result) - .expectNext(false) - .verifyComplete(); - - verify(sessionManager, times(1)).hasAccessToProduct(testPartyId, testProductId); - } - - @Test - @DisplayName("Should check specific permission via SessionManager") - void shouldCheckSpecificPermissionViaSessionManager() { - // Given: Session manager with permission check - when(sessionManager.hasPermission(testPartyId, testProductId, "READ", "BALANCE")) - .thenReturn(Mono.just(true)); - - // When: Check permission - Mono result = sessionManager.hasPermission(testPartyId, testProductId, "READ", "BALANCE"); - - // Then: Returns true - StepVerifier.create(result) - .expectNext(true) - .verifyComplete(); - - verify(sessionManager, times(1)).hasPermission(testPartyId, testProductId, "READ", "BALANCE"); - } - - @Test - @DisplayName("Should check permission with action and resource type") - void shouldCheckPermissionWithActionAndResourceType() { - // Given: Permission check configured - when(sessionManager.hasPermission(testPartyId, testProductId, "WRITE", "TRANSACTION")) - .thenReturn(Mono.just(false)); - - // When: Check permission - Mono result = sessionManager.hasPermission(testPartyId, testProductId, "WRITE", "TRANSACTION"); - - // Then: Returns false - StepVerifier.create(result) - .expectNext(false) - .verifyComplete(); - - verify(sessionManager, times(1)).hasPermission(testPartyId, testProductId, "WRITE", "TRANSACTION"); } - - - - - - + @Test @DisplayName("Should use hasRole from AppContext") void shouldUseHasRoleFromAppContext() { // Given AppContext context = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) .roles(Set.of("owner", "account_viewer")) .permissions(Set.of()) .build(); - + // When Mono hasOwner = authorizationService.hasRole(context, "owner"); Mono hasAdmin = authorizationService.hasRole(context, "admin"); - + // Then StepVerifier.create(hasOwner) .expectNext(true) .verifyComplete(); - + StepVerifier.create(hasAdmin) .expectNext(false) .verifyComplete(); } - + @Test @DisplayName("Should use hasPermission from AppContext") void shouldUseHasPermissionFromAppContext() { // Given AppContext context = AppContext.builder() - .partyId(testPartyId) + .subject(testSubject) .tenantId(testTenantId) .roles(Set.of("owner")) .permissions(Set.of("owner:READ:BALANCE", "owner:WRITE:TRANSACTION")) .build(); - + // When Mono canReadBalance = authorizationService.hasPermission(context, "owner:READ:BALANCE"); Mono canDeleteAccount = authorizationService.hasPermission(context, "owner:DELETE:ACCOUNT"); - + // Then StepVerifier.create(canReadBalance) .expectNext(true) .verifyComplete(); - + StepVerifier.create(canDeleteAccount) .expectNext(false) .verifyComplete(); } + + @Test + @DisplayName("Should authorize endpoint when required role is present in AppContext") + void shouldAuthorizeWhenRequiredRolePresent() { + // Given + AppContext context = AppContext.builder() + .subject(testSubject) + .tenantId(testTenantId) + .roles(Set.of("owner", "account_viewer")) + .permissions(Set.of()) + .build(); + + AppSecurityContext securityContext = AppSecurityContext.builder() + .endpoint("/api/v1/accounts") + .httpMethod("GET") + .requiredRoles(Set.of("owner")) + .configSource(AppSecurityContext.SecurityConfigSource.ANNOTATION) + .build(); + + // When/Then + StepVerifier.create(authorizationService.authorize(context, securityContext)) + .assertNext(result -> { + org.assertj.core.api.Assertions.assertThat(result.isAuthorized()).isTrue(); + }) + .verifyComplete(); + } + + @Test + @DisplayName("Should deny endpoint when required role is missing from AppContext") + void shouldDenyWhenRequiredRoleMissing() { + // Given + AppContext context = AppContext.builder() + .subject(testSubject) + .tenantId(testTenantId) + .roles(Set.of("account_viewer")) + .permissions(Set.of()) + .build(); + + AppSecurityContext securityContext = AppSecurityContext.builder() + .endpoint("/api/v1/accounts") + .httpMethod("DELETE") + .requiredRoles(Set.of("admin")) + .configSource(AppSecurityContext.SecurityConfigSource.ANNOTATION) + .build(); + + // When/Then + StepVerifier.create(authorizationService.authorize(context, securityContext)) + .assertNext(result -> { + org.assertj.core.api.Assertions.assertThat(result.isAuthorized()).isFalse(); + }) + .verifyComplete(); + } + + @Test + @DisplayName("Should authorize endpoint when required permission is present in AppContext") + void shouldAuthorizeWhenRequiredPermissionPresent() { + // Given + AppContext context = AppContext.builder() + .subject(testSubject) + .tenantId(testTenantId) + .roles(Set.of("owner")) + .permissions(Set.of("owner:READ:BALANCE")) + .build(); + + AppSecurityContext securityContext = AppSecurityContext.builder() + .endpoint("/api/v1/balances") + .httpMethod("GET") + .requiredPermissions(Set.of("owner:READ:BALANCE")) + .configSource(AppSecurityContext.SecurityConfigSource.ANNOTATION) + .build(); + + // When/Then + StepVerifier.create(authorizationService.authorize(context, securityContext)) + .assertNext(result -> { + org.assertj.core.api.Assertions.assertThat(result.isAuthorized()).isTrue(); + }) + .verifyComplete(); + } + + @Test + @DisplayName("Should authorize endpoint when no role or permission requirements are declared") + void shouldAuthorizeWhenNoRequirements() { + // Given + AppContext context = AppContext.builder() + .subject(testSubject) + .tenantId(testTenantId) + .roles(Set.of()) + .permissions(Set.of()) + .build(); + + AppSecurityContext securityContext = AppSecurityContext.builder() + .endpoint("/api/v1/public") + .httpMethod("GET") + .configSource(AppSecurityContext.SecurityConfigSource.ANNOTATION) + .build(); + + // When/Then + StepVerifier.create(authorizationService.authorize(context, securityContext)) + .assertNext(result -> { + org.assertj.core.api.Assertions.assertThat(result.isAuthorized()).isTrue(); + }) + .verifyComplete(); + } } diff --git a/src/test/java/org/fireflyframework/common/application/resolver/AbstractContextResolverTest.java b/src/test/java/org/fireflyframework/common/application/resolver/AbstractContextResolverTest.java index 3fd2ebf..d6ef3e5 100644 --- a/src/test/java/org/fireflyframework/common/application/resolver/AbstractContextResolverTest.java +++ b/src/test/java/org/fireflyframework/common/application/resolver/AbstractContextResolverTest.java @@ -20,8 +20,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -30,246 +28,154 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; /** * Unit tests for {@link AbstractContextResolver}. */ @DisplayName("AbstractContextResolver Tests") class AbstractContextResolverTest { - + private TestContextResolver contextResolver; private ServerWebExchange exchange; - private ServerHttpRequest request; - private HttpHeaders headers; - + @BeforeEach void setUp() { contextResolver = new TestContextResolver(); exchange = mock(ServerWebExchange.class); - request = mock(ServerHttpRequest.class); - headers = new HttpHeaders(); - - when(exchange.getRequest()).thenReturn(request); - when(request.getHeaders()).thenReturn(headers); } - + @Test - @DisplayName("Should resolve context with all IDs") - void shouldResolveContextWithAllIds() { + @DisplayName("Should resolve context with subject and tenant") + void shouldResolveContextWithSubjectAndTenant() { // Given - UUID partyId = UUID.randomUUID(); + String subject = "user-123"; UUID tenantId = UUID.randomUUID(); - UUID contractId = UUID.randomUUID(); - UUID productId = UUID.randomUUID(); - - contextResolver.setPartyId(partyId); + + contextResolver.setSubject(subject); contextResolver.setTenantId(tenantId); - contextResolver.setContractId(contractId); - contextResolver.setProductId(productId); - + // When/Then StepVerifier.create(contextResolver.resolveContext(exchange)) .assertNext(context -> { - assertThat(context.getPartyId()).isEqualTo(partyId); + assertThat(context.getSubject()).isEqualTo(subject); assertThat(context.getTenantId()).isEqualTo(tenantId); - assertThat(context.getContractId()).isEqualTo(contractId); - assertThat(context.getProductId()).isEqualTo(productId); }) .verifyComplete(); } - - @Test - @DisplayName("Should extract UUID from attribute") - void shouldExtractUuidFromAttribute() { - // Given - UUID expectedId = UUID.randomUUID(); - when(exchange.getAttribute("testAttribute")).thenReturn(expectedId); - - // When/Then - StepVerifier.create(contextResolver.extractUUID(exchange, "testAttribute", "testHeader")) - .expectNext(expectedId) - .verifyComplete(); - } - - @Test - @DisplayName("Should extract UUID from header when attribute is missing") - void shouldExtractUuidFromHeader() { - // Given - UUID expectedId = UUID.randomUUID(); - when(exchange.getAttribute("testAttribute")).thenReturn(null); - headers.set("testHeader", expectedId.toString()); - - // When/Then - StepVerifier.create(contextResolver.extractUUID(exchange, "testAttribute", "testHeader")) - .expectNext(expectedId) - .verifyComplete(); - } - - @Test - @DisplayName("Should return empty when UUID not found") - void shouldReturnEmptyWhenUuidNotFound() { - // Given - when(exchange.getAttribute("testAttribute")).thenReturn(null); - - // When/Then - StepVerifier.create(contextResolver.extractUUID(exchange, "testAttribute", "testHeader")) - .verifyComplete(); - } - + @Test - @DisplayName("Should return empty when header has invalid UUID format") - void shouldReturnEmptyForInvalidUuidFormat() { + @DisplayName("Should resolve context with empty tenant when single-tenant") + void shouldResolveContextWithEmptyTenant() { // Given - when(exchange.getAttribute("testAttribute")).thenReturn(null); - headers.set("testHeader", "invalid-uuid"); - + String subject = "user-123"; + contextResolver.setSubject(subject); + contextResolver.setTenantId(null); + // When/Then - StepVerifier.create(contextResolver.extractUUID(exchange, "testAttribute", "testHeader")) + StepVerifier.create(contextResolver.resolveContext(exchange)) + .assertNext(context -> { + assertThat(context.getSubject()).isEqualTo(subject); + assertThat(context.getTenantId()).isNull(); + }) .verifyComplete(); } - + @Test - @DisplayName("Should resolve roles for context") - void shouldResolveRoles() { + @DisplayName("Should default roles to empty when not overridden") + void shouldDefaultRolesToEmpty() { // Given - UUID partyId = UUID.randomUUID(); - UUID tenantId = UUID.randomUUID(); - - AppContext context = AppContext.builder() - .partyId(partyId) - .tenantId(tenantId) - .contractId(UUID.randomUUID()) - .productId(UUID.randomUUID()) - .build(); - + contextResolver.setSubject("user-123"); + // When/Then - StepVerifier.create(contextResolver.resolveRoles(context, exchange)) - .assertNext(roles -> assertThat(roles).isEmpty()) + StepVerifier.create(contextResolver.resolveContext(exchange)) + .assertNext(context -> assertThat(context.getRoles()).isEmpty()) .verifyComplete(); } - + @Test - @DisplayName("Should resolve permissions for context") - void shouldResolvePermissions() { + @DisplayName("Should default permissions to empty when not overridden") + void shouldDefaultPermissionsToEmpty() { // Given - UUID partyId = UUID.randomUUID(); - UUID tenantId = UUID.randomUUID(); - - AppContext context = AppContext.builder() - .partyId(partyId) - .tenantId(tenantId) - .contractId(UUID.randomUUID()) - .productId(UUID.randomUUID()) - .build(); - + contextResolver.setSubject("user-123"); + // When/Then - StepVerifier.create(contextResolver.resolvePermissions(context, exchange)) - .assertNext(permissions -> assertThat(permissions).isEmpty()) + StepVerifier.create(contextResolver.resolveContext(exchange)) + .assertNext(context -> assertThat(context.getPermissions()).isEmpty()) .verifyComplete(); } - + @Test - @DisplayName("Should enrich context with roles and permissions") - void shouldEnrichContext() { + @DisplayName("Should enrich context with resolved roles and permissions") + void shouldEnrichContextWithRolesAndPermissions() { // Given - UUID partyId = UUID.randomUUID(); - UUID tenantId = UUID.randomUUID(); - - AppContext basicContext = AppContext.builder() - .partyId(partyId) - .tenantId(tenantId) - .contractId(UUID.randomUUID()) - .productId(UUID.randomUUID()) - .build(); - Set roles = Set.of("ROLE_ADMIN", "ROLE_USER"); Set permissions = Set.of("READ", "WRITE"); - + TestContextResolver enrichedResolver = new TestContextResolver(); + enrichedResolver.setSubject("user-123"); enrichedResolver.setRoles(roles); enrichedResolver.setPermissions(permissions); - + // When/Then - StepVerifier.create(enrichedResolver.enrichContext(basicContext, exchange)) + StepVerifier.create(enrichedResolver.resolveContext(exchange)) .assertNext(context -> { assertThat(context.getRoles()).containsExactlyInAnyOrderElementsOf(roles); assertThat(context.getPermissions()).containsExactlyInAnyOrderElementsOf(permissions); }) .verifyComplete(); } - + @Test - @DisplayName("Should return empty when extracting UUID from path fails") - void shouldReturnEmptyWhenExtractingFromPathFails() { - // When/Then - StepVerifier.create(contextResolver.extractUUIDFromPath(exchange, "variableName")) - .verifyComplete(); + @DisplayName("Should support by default and have zero priority") + void shouldSupportByDefault() { + assertThat(contextResolver.supports(exchange)).isTrue(); + assertThat(contextResolver.getPriority()).isZero(); } - + /** * Test implementation of AbstractContextResolver. */ private static class TestContextResolver extends AbstractContextResolver { - - private UUID partyId = UUID.randomUUID(); + + private String subject = "subject-" + UUID.randomUUID(); private UUID tenantId = UUID.randomUUID(); - private UUID contractId; - private UUID productId; private Set roles = Set.of(); private Set permissions = Set.of(); - - public void setPartyId(UUID partyId) { - this.partyId = partyId; + + public void setSubject(String subject) { + this.subject = subject; } - + public void setTenantId(UUID tenantId) { this.tenantId = tenantId; } - - public void setContractId(UUID contractId) { - this.contractId = contractId; - } - - public void setProductId(UUID productId) { - this.productId = productId; - } - + public void setRoles(Set roles) { this.roles = roles; } - + public void setPermissions(Set permissions) { this.permissions = permissions; } - + @Override - public Mono resolvePartyId(ServerWebExchange exchange) { - return Mono.just(partyId); + public Mono resolveSubject(ServerWebExchange exchange) { + return Mono.just(subject); } - + @Override public Mono resolveTenantId(ServerWebExchange exchange) { - return Mono.just(tenantId); - } - - @Override - public Mono resolveContractId(ServerWebExchange exchange) { - return contractId != null ? Mono.just(contractId) : Mono.empty(); + return tenantId != null ? Mono.just(tenantId) : Mono.empty(); } - - @Override - public Mono resolveProductId(ServerWebExchange exchange) { - return productId != null ? Mono.just(productId) : Mono.empty(); - } - + @Override - protected Mono> resolveRoles(AppContext context, ServerWebExchange exchange) { + protected Mono> resolveRoles(String subject, ServerWebExchange exchange) { return Mono.just(roles); } - + @Override - protected Mono> resolvePermissions(AppContext context, ServerWebExchange exchange) { + protected Mono> resolvePermissions(String subject, ServerWebExchange exchange) { return Mono.just(permissions); } } diff --git a/src/test/java/org/fireflyframework/common/application/security/AbstractSecurityAuthorizationServiceTest.java b/src/test/java/org/fireflyframework/common/application/security/AbstractSecurityAuthorizationServiceTest.java index c31b58f..1ee20e0 100644 --- a/src/test/java/org/fireflyframework/common/application/security/AbstractSecurityAuthorizationServiceTest.java +++ b/src/test/java/org/fireflyframework/common/application/security/AbstractSecurityAuthorizationServiceTest.java @@ -30,26 +30,27 @@ /** * Unit tests for {@link AbstractSecurityAuthorizationService}. + * + *

      Authorization is evaluated solely from the {@link AppContext} roles and permissions that were + * already resolved for the request.

      */ @DisplayName("AbstractSecurityAuthorizationService Tests") class AbstractSecurityAuthorizationServiceTest { - + private TestSecurityAuthorizationService authorizationService; private AppContext context; - + @BeforeEach void setUp() { authorizationService = new TestSecurityAuthorizationService(); context = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-123") .tenantId(UUID.randomUUID()) - .contractId(UUID.randomUUID()) - .productId(UUID.randomUUID()) .roles(Set.of("ROLE_USER", "ROLE_ADMIN")) .permissions(Set.of("READ", "WRITE")) .build(); } - + @Test @DisplayName("Should authorize anonymous access when allowed") void shouldAuthorizeAnonymousAccess() { @@ -59,7 +60,7 @@ void shouldAuthorizeAnonymousAccess() { .httpMethod("GET") .allowAnonymous(true) .build(); - + // When/Then StepVerifier.create(authorizationService.authorize(context, securityContext)) .assertNext(result -> { @@ -68,7 +69,7 @@ void shouldAuthorizeAnonymousAccess() { }) .verifyComplete(); } - + @Test @DisplayName("Should authorize when required role is present") void shouldAuthorizeWhenRolePresent() { @@ -78,7 +79,7 @@ void shouldAuthorizeWhenRolePresent() { .httpMethod("GET") .requiredRoles(Set.of("ROLE_ADMIN")) .build(); - + // When/Then StepVerifier.create(authorizationService.authorize(context, securityContext)) .assertNext(result -> { @@ -87,7 +88,7 @@ void shouldAuthorizeWhenRolePresent() { }) .verifyComplete(); } - + @Test @DisplayName("Should deny when required role is missing") void shouldDenyWhenRoleMissing() { @@ -97,7 +98,7 @@ void shouldDenyWhenRoleMissing() { .httpMethod("GET") .requiredRoles(Set.of("ROLE_SUPERADMIN")) .build(); - + // When/Then StepVerifier.create(authorizationService.authorize(context, securityContext)) .assertNext(result -> { @@ -106,7 +107,7 @@ void shouldDenyWhenRoleMissing() { }) .verifyComplete(); } - + @Test @DisplayName("Should authorize when required permission is granted") void shouldAuthorizeWhenPermissionGranted() { @@ -116,7 +117,7 @@ void shouldAuthorizeWhenPermissionGranted() { .httpMethod("GET") .requiredPermissions(Set.of("READ")) .build(); - + // When/Then StepVerifier.create(authorizationService.authorize(context, securityContext)) .assertNext(result -> { @@ -125,7 +126,7 @@ void shouldAuthorizeWhenPermissionGranted() { }) .verifyComplete(); } - + @Test @DisplayName("Should deny when required permission is missing") void shouldDenyWhenPermissionMissing() { @@ -135,7 +136,7 @@ void shouldDenyWhenPermissionMissing() { .httpMethod("DELETE") .requiredPermissions(Set.of("DELETE")) .build(); - + // When/Then StepVerifier.create(authorizationService.authorize(context, securityContext)) .assertNext(result -> { @@ -144,7 +145,7 @@ void shouldDenyWhenPermissionMissing() { }) .verifyComplete(); } - + @Test @DisplayName("Should check both roles and permissions when required") void shouldCheckBothRolesAndPermissions() { @@ -155,13 +156,13 @@ void shouldCheckBothRolesAndPermissions() { .requiredRoles(Set.of("ROLE_ADMIN")) .requiredPermissions(Set.of("WRITE")) .build(); - + // When/Then StepVerifier.create(authorizationService.authorize(context, securityContext)) .assertNext(result -> assertThat(result.isAuthorized()).isTrue()) .verifyComplete(); } - + @Test @DisplayName("Should deny when role is present but permission is missing") void shouldDenyWhenRolePresentButPermissionMissing() { @@ -172,7 +173,7 @@ void shouldDenyWhenRolePresentButPermissionMissing() { .requiredRoles(Set.of("ROLE_ADMIN")) .requiredPermissions(Set.of("DELETE")) .build(); - + // When/Then StepVerifier.create(authorizationService.authorize(context, securityContext)) .assertNext(result -> { @@ -181,33 +182,33 @@ void shouldDenyWhenRolePresentButPermissionMissing() { }) .verifyComplete(); } - + @Test - @DisplayName("Should check if party has specific role") + @DisplayName("Should check if subject has specific role") void shouldCheckHasRole() { // When/Then StepVerifier.create(authorizationService.hasRole(context, "ROLE_ADMIN")) .expectNext(true) .verifyComplete(); - + StepVerifier.create(authorizationService.hasRole(context, "ROLE_SUPERADMIN")) .expectNext(false) .verifyComplete(); } - + @Test - @DisplayName("Should check if party has specific permission") + @DisplayName("Should check if subject has specific permission") void shouldCheckHasPermission() { // When/Then StepVerifier.create(authorizationService.hasPermission(context, "READ")) .expectNext(true) .verifyComplete(); - + StepVerifier.create(authorizationService.hasPermission(context, "DELETE")) .expectNext(false) .verifyComplete(); } - + @Test @DisplayName("Should allow access when no specific requirements") void shouldAllowAccessWithNoRequirements() { @@ -216,13 +217,13 @@ void shouldAllowAccessWithNoRequirements() { .endpoint("/open") .httpMethod("GET") .build(); - + // When/Then StepVerifier.create(authorizationService.authorize(context, securityContext)) .assertNext(result -> assertThat(result.isAuthorized()).isTrue()) .verifyComplete(); } - + @Test @DisplayName("Should evaluate expression and return false by default") void shouldEvaluateExpression() { @@ -231,27 +232,7 @@ void shouldEvaluateExpression() { .expectNext(false) .verifyComplete(); } - - @Test - @DisplayName("Should deny with SecurityCenter when config source is SECURITY_CENTER") - void shouldDenyWithSecurityCenter() { - // Given - AppSecurityContext securityContext = AppSecurityContext.builder() - .endpoint("/protected") - .httpMethod("GET") - .configSource(AppSecurityContext.SecurityConfigSource.SECURITY_CENTER) - .build(); - - // When/Then - StepVerifier.create(authorizationService.authorize(context, securityContext)) - .assertNext(result -> { - assertThat(result.isAuthorized()).isFalse(); - assertThat(result.getAuthorizationFailureReason()) - .isEqualTo("SecurityCenter integration not implemented"); - }) - .verifyComplete(); - } - + /** * Test implementation of AbstractSecurityAuthorizationService. */ diff --git a/src/test/java/org/fireflyframework/common/application/service/AbstractApplicationServiceTest.java b/src/test/java/org/fireflyframework/common/application/service/AbstractApplicationServiceTest.java index d20ee75..146ca25 100644 --- a/src/test/java/org/fireflyframework/common/application/service/AbstractApplicationServiceTest.java +++ b/src/test/java/org/fireflyframework/common/application/service/AbstractApplicationServiceTest.java @@ -37,16 +37,19 @@ /** * Unit tests for {@link AbstractApplicationService}. + * + *

      Tests the product-agnostic application-service helpers: context resolution, role/permission + * authorization (delegated to {@link SecurityAuthorizationService}) and tenant config access.

      */ @DisplayName("AbstractApplicationService Tests") class AbstractApplicationServiceTest { - + private ContextResolver contextResolver; private ConfigResolver configResolver; private SecurityAuthorizationService authorizationService; private TestApplicationService applicationService; private ServerWebExchange exchange; - + @BeforeEach void setUp() { contextResolver = mock(ContextResolver.class); @@ -55,19 +58,19 @@ void setUp() { applicationService = new TestApplicationService(contextResolver, configResolver, authorizationService); exchange = mock(ServerWebExchange.class); } - + @Test @DisplayName("Should resolve execution context successfully") void shouldResolveExecutionContext() { // Given UUID tenantId = UUID.randomUUID(); AppContext appContext = AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-" + UUID.randomUUID()) .tenantId(tenantId) - .contractId(UUID.randomUUID()) - .productId(UUID.randomUUID()) + .roles(Set.of("ROLE_USER")) + .permissions(Set.of("READ")) .build(); - + AppConfig appConfig = AppConfig.builder() .tenantId(tenantId) .active(true) @@ -75,85 +78,21 @@ void shouldResolveExecutionContext() { .featureFlags(new HashMap<>()) .settings(new HashMap<>()) .build(); - + when(contextResolver.resolveContext(exchange)).thenReturn(Mono.just(appContext)); when(configResolver.resolveConfig(tenantId)).thenReturn(Mono.just(appConfig)); - + // When/Then StepVerifier.create(applicationService.resolveExecutionContext(exchange)) .assertNext(executionContext -> { assertThat(executionContext.getContext()).isEqualTo(appContext); assertThat(executionContext.getConfig()).isEqualTo(appConfig); + assertThat(executionContext.getContext().getSubject()).isEqualTo(appContext.getSubject()); + assertThat(executionContext.getContext().getTenantId()).isEqualTo(tenantId); }) .verifyComplete(); } - - @Test - @DisplayName("Should validate context successfully when requirements are met") - void shouldValidateContextSuccessfully() { - // Given - ApplicationExecutionContext context = createExecutionContext(); - - // When/Then - StepVerifier.create(applicationService.validateContext(context, true, true)) - .expectNext(context) - .verifyComplete(); - } - - @Test - @DisplayName("Should fail validation when contract is required but missing") - void shouldFailValidationWhenContractMissing() { - // Given - ApplicationExecutionContext context = ApplicationExecutionContext.builder() - .context(AppContext.builder() - .partyId(UUID.randomUUID()) - .tenantId(UUID.randomUUID()) - .productId(UUID.randomUUID()) - .build()) - .config(AppConfig.builder() - .tenantId(UUID.randomUUID()) - .active(true) - .providers(new HashMap<>()) - .featureFlags(new HashMap<>()) - .settings(new HashMap<>()) - .build()) - .build(); - - // When/Then - StepVerifier.create(applicationService.validateContext(context, true, false)) - .expectErrorMatches(error -> - error instanceof IllegalStateException && - error.getMessage().contains("Contract ID is required")) - .verify(); - } - - @Test - @DisplayName("Should fail validation when product is required but missing") - void shouldFailValidationWhenProductMissing() { - // Given - ApplicationExecutionContext context = ApplicationExecutionContext.builder() - .context(AppContext.builder() - .partyId(UUID.randomUUID()) - .tenantId(UUID.randomUUID()) - .contractId(UUID.randomUUID()) - .build()) - .config(AppConfig.builder() - .tenantId(UUID.randomUUID()) - .active(true) - .providers(new HashMap<>()) - .featureFlags(new HashMap<>()) - .settings(new HashMap<>()) - .build()) - .build(); - - // When/Then - StepVerifier.create(applicationService.validateContext(context, false, true)) - .expectErrorMatches(error -> - error instanceof IllegalStateException && - error.getMessage().contains("Product ID is required")) - .verify(); - } - + @Test @DisplayName("Should require role successfully when present") void shouldRequireRoleSuccessfully() { @@ -161,12 +100,12 @@ void shouldRequireRoleSuccessfully() { ApplicationExecutionContext context = createExecutionContext(); when(authorizationService.hasRole(context.getContext(), "ROLE_ADMIN")) .thenReturn(Mono.just(true)); - + // When/Then StepVerifier.create(applicationService.requireRole(context, "ROLE_ADMIN")) .verifyComplete(); } - + @Test @DisplayName("Should fail when required role is missing") void shouldFailWhenRequiredRoleMissing() { @@ -174,13 +113,13 @@ void shouldFailWhenRequiredRoleMissing() { ApplicationExecutionContext context = createExecutionContext(); when(authorizationService.hasRole(context.getContext(), "ROLE_SUPERADMIN")) .thenReturn(Mono.just(false)); - + // When/Then StepVerifier.create(applicationService.requireRole(context, "ROLE_SUPERADMIN")) .expectError(AccessDeniedException.class) .verify(); } - + @Test @DisplayName("Should require permission successfully when granted") void shouldRequirePermissionSuccessfully() { @@ -188,12 +127,12 @@ void shouldRequirePermissionSuccessfully() { ApplicationExecutionContext context = createExecutionContext(); when(authorizationService.hasPermission(context.getContext(), "WRITE")) .thenReturn(Mono.just(true)); - + // When/Then StepVerifier.create(applicationService.requirePermission(context, "WRITE")) .verifyComplete(); } - + @Test @DisplayName("Should fail when required permission is missing") void shouldFailWhenRequiredPermissionMissing() { @@ -201,19 +140,19 @@ void shouldFailWhenRequiredPermissionMissing() { ApplicationExecutionContext context = createExecutionContext(); when(authorizationService.hasPermission(context.getContext(), "DELETE")) .thenReturn(Mono.just(false)); - + // When/Then StepVerifier.create(applicationService.requirePermission(context, "DELETE")) .expectError(AccessDeniedException.class) .verify(); } - + @Test @DisplayName("Should get provider config successfully") void shouldGetProviderConfig() { // Given ApplicationExecutionContext context = createExecutionContextWithProvider(); - + // When/Then StepVerifier.create(applicationService.getProviderConfig(context, "payment")) .assertNext(providerConfig -> { @@ -222,44 +161,44 @@ void shouldGetProviderConfig() { }) .verifyComplete(); } - + @Test @DisplayName("Should fail when provider is not configured") void shouldFailWhenProviderNotConfigured() { // Given ApplicationExecutionContext context = createExecutionContext(); - + // When/Then StepVerifier.create(applicationService.getProviderConfig(context, "nonexistent")) - .expectErrorMatches(error -> + .expectErrorMatches(error -> error instanceof IllegalStateException && error.getMessage().contains("Provider not configured")) .verify(); } - + @Test @DisplayName("Should check if feature is enabled") void shouldCheckIfFeatureIsEnabled() { // Given ApplicationExecutionContext context = createExecutionContextWithFeature(); - + // When/Then StepVerifier.create(applicationService.isFeatureEnabled(context, "new-ui")) .expectNext(true) .verifyComplete(); - + StepVerifier.create(applicationService.isFeatureEnabled(context, "old-feature")) .expectNext(false) .verifyComplete(); } - + private ApplicationExecutionContext createExecutionContext() { return ApplicationExecutionContext.builder() .context(AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-" + UUID.randomUUID()) .tenantId(UUID.randomUUID()) - .contractId(UUID.randomUUID()) - .productId(UUID.randomUUID()) + .roles(Set.of("ROLE_ADMIN")) + .permissions(Set.of("WRITE")) .build()) .config(AppConfig.builder() .tenantId(UUID.randomUUID()) @@ -270,7 +209,7 @@ private ApplicationExecutionContext createExecutionContext() { .build()) .build(); } - + private ApplicationExecutionContext createExecutionContextWithProvider() { Map providers = new HashMap<>(); providers.put("payment", AppConfig.ProviderConfig.builder() @@ -279,13 +218,11 @@ private ApplicationExecutionContext createExecutionContextWithProvider() { .enabled(true) .properties(new HashMap<>()) .build()); - + return ApplicationExecutionContext.builder() .context(AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-" + UUID.randomUUID()) .tenantId(UUID.randomUUID()) - .contractId(UUID.randomUUID()) - .productId(UUID.randomUUID()) .build()) .config(AppConfig.builder() .tenantId(UUID.randomUUID()) @@ -296,17 +233,15 @@ private ApplicationExecutionContext createExecutionContextWithProvider() { .build()) .build(); } - + private ApplicationExecutionContext createExecutionContextWithFeature() { Map featureFlags = new HashMap<>(); featureFlags.put("new-ui", true); - + return ApplicationExecutionContext.builder() .context(AppContext.builder() - .partyId(UUID.randomUUID()) + .subject("user-" + UUID.randomUUID()) .tenantId(UUID.randomUUID()) - .contractId(UUID.randomUUID()) - .productId(UUID.randomUUID()) .build()) .config(AppConfig.builder() .tenantId(UUID.randomUUID()) @@ -317,12 +252,12 @@ private ApplicationExecutionContext createExecutionContextWithFeature() { .build()) .build(); } - + /** - * Test implementation of AbstractApplicationService. + * Test implementation of {@link AbstractApplicationService}. */ private static class TestApplicationService extends AbstractApplicationService { - + protected TestApplicationService(ContextResolver contextResolver, ConfigResolver configResolver, SecurityAuthorizationService authorizationService) {