From 34421143107e69e4468c0f9c1dcd93cb4fbafedf Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Thu, 18 Jun 2026 23:13:15 +0200 Subject: [PATCH] =?UTF-8?q?refactor!:=20de-domain=20backoffice=20=E2=80=94?= =?UTF-8?q?=20remove=20party/contract/product=20+=20Security=20Center?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BackofficeContext is now product-agnostic (backofficeUserId + generic impersonatedSubject + tenantId + roles/permissions/attributes). Identity comes from the validated SecurityContextPort (fireflyframework-security); impersonation is a generic String subject (was party UUID). Deletes BackofficeSessionContextMapper and all SessionManager/SessionContext usage; resolvers and the resource controller are genericized. 13 tests green. BREAKING (clean break, no shims). --- .../backoffice/context/BackofficeContext.java | 212 +++----- .../context/BackofficeSecurityContext.java | 267 ++++------ .../AbstractBackofficeResourceController.java | 228 ++------ .../AbstractBackofficeContextResolver.java | 325 ++++-------- .../resolver/BackofficeContextResolver.java | 132 ++--- .../DefaultBackofficeContextResolver.java | 485 ++++-------------- .../util/BackofficeSessionContextMapper.java | 273 ---------- .../context/BackofficeContextTest.java | 229 ++------- ...tractBackofficeResourceControllerTest.java | 328 ++---------- .../BackofficeSessionContextMapperTest.java | 149 ------ 10 files changed, 520 insertions(+), 2108 deletions(-) delete mode 100644 src/main/java/org/fireflyframework/common/backoffice/util/BackofficeSessionContextMapper.java delete mode 100644 src/test/java/org/fireflyframework/common/backoffice/util/BackofficeSessionContextMapperTest.java diff --git a/src/main/java/org/fireflyframework/common/backoffice/context/BackofficeContext.java b/src/main/java/org/fireflyframework/common/backoffice/context/BackofficeContext.java index 762eec3..5fcae62 100644 --- a/src/main/java/org/fireflyframework/common/backoffice/context/BackofficeContext.java +++ b/src/main/java/org/fireflyframework/common/backoffice/context/BackofficeContext.java @@ -20,29 +20,31 @@ import lombok.Value; import lombok.With; -import jakarta.validation.constraints.NotNull; -import java.time.Instant; +import java.util.Map; import java.util.Set; import java.util.UUID; /** - * Immutable backoffice context container for internal portal/backoffice requests. - * Contains information about the backoffice user performing the action and the customer being impersonated. - * - *

This class extends the standard application context with customer impersonation capabilities:

+ * Immutable, product-agnostic backoffice context container for internal portal/backoffice requests. + * + *

This context carries information about the backoffice operator performing the action and, + * optionally, about a subject being impersonated. It deliberately carries no + * product-domain concepts (no party, contract, or product). The impersonated principal is a generic + * {@code String} subject so the backoffice platform stays neutral across products.

+ * * - *

- * - *

The impersonation is tracked for audit purposes and security validation.

- * - *

Usage: Backoffice systems must send the X-Impersonate-Party-Id header to indicate - * which customer they are accessing. The X-User-Id header identifies the backoffice user.

- * + * + *

Validated identity is sourced from the {@code fireflyframework-security} platform + * ({@code SecurityContextPort} / {@code SecurityPrincipal}). Impersonation, when present, is read from + * an optional {@code X-Impersonate-Subject} request header.

+ * * @author Firefly Development Team * @since 1.0.0 */ @@ -50,107 +52,85 @@ @Builder(toBuilder = true) @With public class BackofficeContext { - + /** - * Unique identifier of the backoffice user (admin/support) performing the action. - * This is the authenticated user in the backoffice system. - * Comes from X-User-Id header (injected by Istio for backoffice routes). + * Unique identifier of the backoffice operator (admin/support) performing the action. + * Derived from the validated security principal's subject when it is a UUID; otherwise {@code null} + * (the raw subject is then kept under the {@code "backofficeUserSubject"} attribute). */ - @NotNull UUID backofficeUserId; - - /** - * Unique identifier of the customer (party) being impersonated. - * This is the customer whose data is being accessed or modified. - * Comes from X-Impersonate-Party-Id header (required for all backoffice operations on customer data). - */ - @NotNull - UUID impersonatedPartyId; - + /** - * 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. + * Generic subject being impersonated by the backoffice operator. + * {@code null} when no impersonation is in effect. Read from the optional + * {@code X-Impersonate-Subject} header. */ - UUID contractId; - + String impersonatedSubject; + /** - * 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. + * The generic tenant/organization this context belongs to. + * Parsed from the security principal's tenant identifier when it is a UUID; otherwise {@code null}. */ - UUID productId; - + UUID tenantId; + /** - * Roles that the backoffice user has. - * Used for authorization decisions (e.g., "admin", "support", "analyst"). + * Roles that the backoffice operator holds. + * Used for authorization decisions. Sourced from the principal's authorities. */ Set backofficeRoles; - + /** - * Permissions that the backoffice user has. - * Derived from roles and used for fine-grained authorization. + * Permissions that the backoffice operator holds. + * Used for fine-grained authorization. Sourced from the principal's scopes. */ Set backofficePermissions; - - /** - * Roles that the impersonated party has in the context of this contract/product. - * These are informational - the backoffice user's permissions take precedence. - */ - Set impersonatedPartyRoles; - - /** - * Permissions that the impersonated party has in this context. - * These are informational - the backoffice user's permissions take precedence. - */ - Set impersonatedPartyPermissions; - + /** - * The tenant/organization this context belongs to. - * Links to the tenant of the impersonated party. + * Roles that the impersonated subject holds. + * Informational - the backoffice operator's permissions take precedence. */ - UUID tenantId; - + Set impersonatedSubjectRoles; + /** - * Timestamp when the impersonation started (for audit trail). + * Permissions that the impersonated subject holds. + * Informational - the backoffice operator's permissions take precedence. */ - @Builder.Default - Instant impersonationStartedAt = Instant.now(); - + Set impersonatedSubjectPermissions; + /** * Reason for impersonation (optional, for audit purposes). - * Example: "Customer support ticket #12345", "Administrative review" + * Example: "Support ticket #12345", "Administrative review". */ String impersonationReason; - + /** - * IP address of the backoffice user (for audit trail). + * IP address of the backoffice operator (for audit trail). */ String backofficeUserIpAddress; - + /** * Additional context-specific attributes. - * Can be used to store domain-specific context information. + * Can be used to store generic context information (e.g. the raw backoffice user subject). */ - java.util.Map attributes; - + Map attributes; + /** - * Checks if the backoffice user has a specific role - * + * Checks if the backoffice operator has a specific role. + * * @param role the role to check * @return true if the role is present */ public boolean hasBackofficeRole(String role) { return backofficeRoles != null && backofficeRoles.contains(role); } - + /** - * Checks if the backoffice user has any of the specified roles - * + * Checks if the backoffice operator has any of the specified roles. + * * @param roles the roles to check * @return true if any of the roles are present */ - public boolean hasAnyBackofficeRole(String... roles) { + public boolean hasBackofficeAnyRole(String... roles) { if (this.backofficeRoles == null || roles == null) { return false; } @@ -161,66 +141,39 @@ public boolean hasAnyBackofficeRole(String... roles) { } return false; } - - /** - * Checks if the backoffice user has all of the specified roles - * - * @param roles the roles to check - * @return true if all roles are present - */ - public boolean hasAllBackofficeRoles(String... roles) { - if (this.backofficeRoles == null || roles == null) { - return false; - } - for (String role : roles) { - if (!this.backofficeRoles.contains(role)) { - return false; - } - } - return true; - } - + /** - * Checks if the backoffice user has a specific permission - * + * Checks if the backoffice operator has a specific permission. + * * @param permission the permission to check * @return true if the permission is present */ public boolean hasBackofficePermission(String permission) { return backofficePermissions != null && backofficePermissions.contains(permission); } - + /** - * Checks if the impersonated party has a specific role (informational) - * + * Checks if the impersonated subject has a specific role (informational). + * * @param role the role to check - * @return true if the role is present for the impersonated party - */ - public boolean impersonatedPartyHasRole(String role) { - return impersonatedPartyRoles != null && impersonatedPartyRoles.contains(role); - } - - /** - * Checks if this context has a contract association - * - * @return true if contractId is present + * @return true if the role is present for the impersonated subject */ - public boolean hasContract() { - return contractId != null; + public boolean impersonatedSubjectHasRole(String role) { + return impersonatedSubjectRoles != null && impersonatedSubjectRoles.contains(role); } - + /** - * Checks if this context has a product association - * - * @return true if productId is present + * Indicates whether this context represents an active impersonation. + * + * @return true if an impersonated subject is set */ - public boolean hasProduct() { - return productId != null; + public boolean isImpersonating() { + return impersonatedSubject != null; } - + /** - * Gets an attribute from the context - * + * Gets an attribute from the context. + * * @param key the attribute key * @param the expected type * @return the attribute value or null if not present @@ -229,13 +182,4 @@ public boolean hasProduct() { public T getAttribute(String key) { return attributes != null ? (T) attributes.get(key) : null; } - - /** - * Checks if this is a valid impersonation context - * - * @return true if both backoffice user and impersonated party are set - */ - public boolean isValidImpersonation() { - return backofficeUserId != null && impersonatedPartyId != null; - } } diff --git a/src/main/java/org/fireflyframework/common/backoffice/context/BackofficeSecurityContext.java b/src/main/java/org/fireflyframework/common/backoffice/context/BackofficeSecurityContext.java index 100a356..c6a5ce8 100644 --- a/src/main/java/org/fireflyframework/common/backoffice/context/BackofficeSecurityContext.java +++ b/src/main/java/org/fireflyframework/common/backoffice/context/BackofficeSecurityContext.java @@ -26,24 +26,18 @@ import java.util.UUID; /** - * Immutable security context for backoffice requests with customer impersonation. - * Contains security-related information including endpoint-role mappings, authorization results, - * and impersonation audit trail. - * - *

This class extends the standard security context with backoffice-specific features:

- *
    - *
  • Tracks which backoffice user is performing the action
  • - *
  • Records which customer (party) is being impersonated
  • - *
  • Maintains audit trail for compliance and security
  • - *
  • Validates backoffice user has permission to impersonate
  • - *
- * + * Immutable, product-agnostic security context for backoffice requests. + * + *

Captures security-related information for a backoffice endpoint: the required roles/permissions, + * the authorization outcome, and an optional impersonation audit trail. It carries no + * product-domain concepts; the impersonated principal is a generic {@code String} subject.

+ * *

Security context can be configured in two ways:

*
    - *
  • Declarative: Using @BackofficeSecure annotation on endpoints/controllers
  • + *
  • Declarative: Using {@code @BackofficeSecure} annotation on endpoints/controllers
  • *
  • Programmatic: Explicit endpoint-role mapping registration
  • *
- * + * * @author Firefly Development Team * @since 1.0.0 */ @@ -51,150 +45,145 @@ @Builder(toBuilder = true) @With public class BackofficeSecurityContext { - + /** - * The endpoint being accessed (e.g., "/backoffice/api/v1/customers/{partyId}/accounts") + * The endpoint being accessed (e.g., "/backoffice/api/v1/subjects/{subject}/accounts") */ String endpoint; - + /** * The HTTP method being used (GET, POST, PUT, DELETE, etc.) */ String httpMethod; - + /** - * Backoffice roles required to access this endpoint - * Examples: "admin", "customer_support", "analyst", "auditor" + * Backoffice roles required to access this endpoint. + * Examples: "admin", "support", "analyst", "auditor" */ Set requiredBackofficeRoles; - + /** - * Backoffice permissions required to access this endpoint - * Examples: "customers:read", "accounts:write", "transactions:delete" + * Backoffice permissions required to access this endpoint. + * Examples: "subjects:read", "accounts:write", "transactions:delete" */ Set requiredBackofficePermissions; - + /** - * Whether impersonation is allowed for this endpoint + * Whether impersonation is allowed for this endpoint. */ @Builder.Default boolean impersonationAllowed = true; - + /** - * Whether impersonation is required for this endpoint - * If true, the X-Impersonate-Party-Id header must be present + * Whether impersonation is required for this endpoint. + * If true, the {@code X-Impersonate-Subject} header must be present. */ @Builder.Default - boolean impersonationRequired = true; - + boolean impersonationRequired = false; + /** - * Whether authorization was successful + * Whether authorization was successful. */ boolean authorized; - + /** - * Reason for authorization failure (if applicable) + * Reason for authorization failure (if applicable). */ String authorizationFailureReason; - + /** - * The backoffice user ID that was authenticated + * The backoffice operator ID that was authenticated. */ UUID backofficeUserId; - + /** - * The customer (party) being impersonated + * The generic subject being impersonated (may be {@code null}). */ - UUID impersonatedPartyId; - + String impersonatedSubject; + /** - * Whether the backoffice user has permission to impersonate this customer + * Whether the backoffice operator is authorized to impersonate this subject. */ boolean impersonationAuthorized; - + /** - * Reason if impersonation was denied + * Reason if impersonation was denied. */ String impersonationDenialReason; - + /** - * Timestamp when impersonation was authorized + * Timestamp when impersonation was authorized. */ Instant impersonationAuthorizedAt; - + /** - * Source of the security configuration (ANNOTATION, EXPLICIT_MAP, SECURITY_CENTER) + * Source of the security configuration. */ SecurityConfigSource configSource; - + /** - * Additional security attributes + * Additional security attributes. */ Map securityAttributes; - + /** - * Whether this endpoint requires authentication + * Whether this endpoint requires authentication. */ @Builder.Default boolean requiresAuthentication = true; - + /** - * Whether this endpoint allows anonymous access (typically false for backoffice) + * Whether this endpoint allows anonymous access (typically false for backoffice). */ @Builder.Default boolean allowAnonymous = false; - - /** - * Custom security evaluation result from SecurityCenter - */ - SecurityEvaluationResult evaluationResult; - + /** - * Audit trail information for impersonation + * Audit trail information for impersonation. */ ImpersonationAuditTrail auditTrail; - + /** - * Checks if the security context requires any backoffice roles - * + * Checks if the security context requires any backoffice roles. + * * @return true if roles are required */ public boolean hasRequiredBackofficeRoles() { return requiredBackofficeRoles != null && !requiredBackofficeRoles.isEmpty(); } - + /** - * Checks if the security context requires any backoffice permissions - * + * Checks if the security context requires any backoffice permissions. + * * @return true if permissions are required */ public boolean hasRequiredBackofficePermissions() { return requiredBackofficePermissions != null && !requiredBackofficePermissions.isEmpty(); } - + /** - * Checks if a specific backoffice role is required - * + * Checks if a specific backoffice role is required. + * * @param role the role to check * @return true if the role is required */ public boolean requiresBackofficeRole(String role) { return requiredBackofficeRoles != null && requiredBackofficeRoles.contains(role); } - + /** - * Checks if a specific backoffice permission is required - * + * Checks if a specific backoffice permission is required. + * * @param permission the permission to check * @return true if the permission is required */ public boolean requiresBackofficePermission(String permission) { return requiredBackofficePermissions != null && requiredBackofficePermissions.contains(permission); } - + /** - * Gets a security attribute - * + * Gets a security attribute. + * * @param key the attribute key * @param the expected type * @return the attribute value or null if not found @@ -203,153 +192,97 @@ public boolean requiresBackofficePermission(String permission) { public T getSecurityAttribute(String key) { return securityAttributes != null ? (T) securityAttributes.get(key) : null; } - + /** - * Checks if impersonation is both allowed and successfully authorized - * + * Checks if impersonation is both allowed and successfully authorized. + * * @return true if impersonation is valid */ public boolean isImpersonationValid() { - return impersonationAllowed && impersonationAuthorized && impersonatedPartyId != null; + return impersonationAllowed && impersonationAuthorized && impersonatedSubject != null; } - + /** - * Source of security configuration + * Source of security configuration. */ public enum SecurityConfigSource { /** - * Security configuration from @BackofficeSecure annotation + * Security configuration from {@code @BackofficeSecure} annotation. */ ANNOTATION, - + /** - * Security configuration from explicit endpoint-role mapping + * Security configuration from explicit endpoint-role mapping. */ EXPLICIT_MAP, - - /** - * Security configuration from Firefly SecurityCenter - */ - SECURITY_CENTER, - + /** - * Security configuration from default/fallback rules + * Security configuration from default/fallback rules. */ DEFAULT } - - /** - * Result of security evaluation from SecurityCenter - */ - @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 - */ - Instant evaluatedAt; - - /** - * Gets an evaluation detail - * - * @param key the detail key - * @param the expected type - * @return the detail value or null if not found - */ - @SuppressWarnings("unchecked") - public T getEvaluationDetail(String key) { - return evaluationDetails != null ? (T) evaluationDetails.get(key) : null; - } - } - + /** - * Audit trail for customer impersonation + * Audit trail for subject impersonation. */ @Value @Builder(toBuilder = true) @With public static class ImpersonationAuditTrail { - + /** - * Backoffice user who initiated impersonation + * Backoffice operator who initiated impersonation. */ UUID backofficeUserId; - + /** - * Customer (party) being impersonated + * Generic subject being impersonated. */ - UUID impersonatedPartyId; - + String impersonatedSubject; + /** - * Timestamp when impersonation started + * Timestamp when impersonation started. */ Instant startedAt; - + /** - * IP address of backoffice user + * IP address of backoffice operator. */ String ipAddress; - + /** - * User agent of backoffice user + * User agent of backoffice operator. */ String userAgent; - + /** - * Reason for impersonation (e.g., support ticket number) + * Reason for impersonation (e.g., support ticket number). */ String reason; - + /** - * Endpoint being accessed + * Endpoint being accessed. */ String endpoint; - + /** - * HTTP method + * HTTP method. */ String httpMethod; - - /** - * Session ID for correlation - */ - String sessionId; - + /** - * Request ID for tracing + * Request ID for tracing. */ String requestId; - + /** - * Additional audit metadata + * Additional audit metadata. */ Map metadata; - + /** - * Gets audit metadata - * + * Gets audit metadata. + * * @param key the metadata key * @param the expected type * @return the metadata value or null if not found diff --git a/src/main/java/org/fireflyframework/common/backoffice/controller/AbstractBackofficeResourceController.java b/src/main/java/org/fireflyframework/common/backoffice/controller/AbstractBackofficeResourceController.java index 8784e3e..687ff75 100644 --- a/src/main/java/org/fireflyframework/common/backoffice/controller/AbstractBackofficeResourceController.java +++ b/src/main/java/org/fireflyframework/common/backoffice/controller/AbstractBackofficeResourceController.java @@ -16,214 +16,48 @@ package org.fireflyframework.common.backoffice.controller; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.fireflyframework.common.backoffice.context.BackofficeContext; import org.fireflyframework.common.backoffice.resolver.BackofficeContextResolver; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; -import java.util.UUID; - /** - *

Abstract Base Controller for Backoffice Customer Resource Endpoints

- * - *

This base class is for controllers that operate on customer resources with impersonation. - * It automatically resolves the full backoffice context including backoffice user, impersonated customer, - * contract, and product. Perfect for backoffice staff accessing customer data.

- * - *

When to Use

- *

Extend this class when building REST endpoints for backoffice staff to access customer resources:

- *
    - *
  • Customer Accounts: {@code /backoffice/customers/{partyId}/contracts/{contractId}/accounts}
  • - *
  • Transactions: {@code /backoffice/customers/{partyId}/contracts/{contractId}/transactions}
  • - *
  • Customer Profile: {@code /backoffice/customers/{partyId}/profile}
  • - *
  • Support Operations: Managing customer issues, disputes
  • - *
- * - *

Architecture

- *

This controller automatically resolves:

- *
    - *
  • Backoffice User ID: From Istio-injected X-User-Id header
  • - *
  • Impersonated Party ID: From X-Impersonate-Party-Id header
  • - *
  • Contract ID: From {@code @PathVariable UUID contractId} (REQUIRED)
  • - *
  • Product ID: From {@code @PathVariable UUID productId} (REQUIRED)
  • - *
  • Backoffice Roles: Admin, support, analyst roles
  • - *
  • Customer Validation: Ensures customer has access to contract/product
  • - *
- * - *

Quick Example

- *
- * {@code
- * @RestController
- * @RequestMapping("/backoffice/api/v1/customers/{partyId}/contracts/{contractId}")
- * public class BackofficeAccountController extends AbstractBackofficeResourceController {
- *     
- *     @Autowired
- *     private AccountService accountService;
- *     
- *     @GetMapping("/accounts")
- *     @BackofficeSecure(roles = "customer_support", impersonationRequired = true)
- *     public Mono> getCustomerAccounts(
- *             @PathVariable UUID partyId,
- *             @PathVariable UUID contractId,
- *             ServerWebExchange exchange) {
- *         
- *         // Automatically resolved context with backoffice user + impersonated customer
- *         return resolveBackofficeContext(exchange, partyId, contractId, null)
- *             .flatMap(context -> {
- *                 logImpersonationOperation(context, "getCustomerAccounts");
- *                 return accountService.getAccountsForCustomer(context);
- *             });
- *     }
- * }
- * }
- * 
- * - *

What You Get

- *
    - *
  • Full Context Resolution: {@link #resolveBackofficeContext}
  • - *
  • Customer Impersonation: Backoffice user + impersonated customer
  • - *
  • Security Validation: Customer access rights verification
  • - *
  • Audit Logging: {@link #logImpersonationOperation}
  • - *
  • Path Variable Validation: {@link #validatePartyId}
  • - *
- * - * @author Firefly Development Team - * @since 1.0.0 - * @see AbstractBackofficeController For administrative endpoints (no customer impersonation) + * Thin, product-agnostic base for backoffice resource controllers. It resolves the + * {@link BackofficeContext} from the validated security context (via {@link BackofficeContextResolver}) + * and offers generic operator-role/permission guards. It carries no contract/product + * scoping and reads no trusted {@code X-Party-Id}-style identity header. */ @Slf4j +@RequiredArgsConstructor public abstract class AbstractBackofficeResourceController { - - @Autowired - private BackofficeContextResolver contextResolver; - - /** - * Resolves the full backoffice context with customer impersonation. - * - *

This method:

- *
    - *
  1. Extracts backoffice user ID from X-User-Id header (Istio-injected)
  2. - *
  3. Extracts impersonated party ID from X-Impersonate-Party-Id header
  4. - *
  5. Validates the impersonated party matches the path variable
  6. - *
  7. Uses the provided contractId and productId from {@code @PathVariable}
  8. - *
  9. Validates customer has access to the contract/product via Security Center
  10. - *
  11. Enriches with roles and permissions for both users
  12. - *
  13. Creates audit trail
  14. - *
- * - * @param exchange the server web exchange - * @param partyId the impersonated party ID from path variable (must match header) - * @param contractId the contract ID from path variable (nullable) - * @param productId the product ID from path variable (nullable) - * @return Mono of BackofficeContext with complete impersonation context - */ - protected Mono resolveBackofficeContext( - ServerWebExchange exchange, - UUID partyId, - UUID contractId, - UUID productId) { - - log.debug("Resolving backoffice context for customer: {}, contract: {}, product: {}", - partyId, contractId, productId); - - return contextResolver.resolveContext(exchange, contractId, productId) - .flatMap(context -> validatePartyId(context, partyId)) - .doOnSuccess(context -> log.debug( - "Successfully resolved backoffice context: backoffice user={}, impersonated party={}, contract={}, product={}", - context.getBackofficeUserId(), context.getImpersonatedPartyId(), - context.getContractId(), context.getProductId())) - .doOnError(error -> log.error("Failed to resolve backoffice context", error)); - } - - /** - * Validates that the impersonated party ID matches the path variable. - * - *

This ensures consistency between the impersonation header and the URL path.

- * - * @param context the resolved backoffice context - * @param expectedPartyId the party ID from the path variable - * @return Mono of validated context - */ - protected Mono validatePartyId(BackofficeContext context, UUID expectedPartyId) { - if (!expectedPartyId.equals(context.getImpersonatedPartyId())) { - log.error("Party ID mismatch: path variable={}, impersonated party={}", - expectedPartyId, context.getImpersonatedPartyId()); - return Mono.error(new IllegalArgumentException( - String.format("Party ID in path (%s) does not match impersonated party (%s)", - expectedPartyId, context.getImpersonatedPartyId()))); - } - return Mono.just(context); - } - - /** - * Logs a customer impersonation operation for audit trail. - * - *

This creates a detailed audit log of who accessed whose data and why.

- * - * @param context the backoffice context - * @param operation description of the operation - */ - protected void logImpersonationOperation(BackofficeContext context, String operation) { - log.info("[Backoffice Impersonation] Backoffice User: {}, Impersonated Customer: {}, Contract: {}, Product: {}, Operation: {}, Reason: {}", - context.getBackofficeUserId(), - context.getImpersonatedPartyId(), - context.getContractId(), - context.getProductId(), - operation, - context.getImpersonationReason() != null ? context.getImpersonationReason() : "Not specified"); + + protected final BackofficeContextResolver contextResolver; + + /** Resolve the backoffice context (operator, optional impersonated subject, roles, tenant). */ + protected Mono resolveContext(ServerWebExchange exchange) { + return contextResolver.resolveContext(exchange) + .doOnNext(ctx -> log.debug("Resolved backoffice context: operator={}, impersonating={}, tenant={}", + ctx.getBackofficeUserId(), ctx.getImpersonatedSubject(), ctx.getTenantId())); } - - /** - * Validates that required context components are present. - * - * @param context the backoffice context - * @param requireContract whether contract ID is required - * @param requireProduct whether product ID is required - * @return Mono of validated context - */ - protected Mono requireContext(BackofficeContext context, - boolean requireContract, - boolean requireProduct) { - if (requireContract && !context.hasContract()) { - return Mono.error(new IllegalStateException("Contract ID is required but not present")); - } - - if (requireProduct && !context.hasProduct()) { - return Mono.error(new IllegalStateException("Product ID is required but not present")); - } - - return Mono.just(context); + + /** Log a backoffice operation for the audit trail. */ + protected void logOperation(String operation) { + log.info("Backoffice operation: {}", operation); } - - /** - * Checks if the backoffice user has the required permission. - * - * @param context the backoffice context - * @param permission the required permission (e.g., "customers:read") - * @return Mono that completes if permission is granted, errors otherwise - */ - protected Mono requireBackofficePermission(BackofficeContext context, String permission) { - if (!context.hasBackofficePermission(permission)) { - return Mono.error(new org.springframework.security.access.AccessDeniedException( - "Required backoffice permission not granted: " + permission)); - } - return Mono.empty(); + + /** Fail-closed guard: require the operator to hold a backoffice role. */ + protected Mono requireBackofficeRole(ServerWebExchange exchange, String role) { + return resolveContext(exchange).flatMap(ctx -> ctx.hasBackofficeRole(role) + ? Mono.empty() + : Mono.error(new SecurityException("Backoffice role required: " + role))); } - - /** - * Checks if the backoffice user has the required role. - * - * @param context the backoffice context - * @param role the required role (e.g., "admin", "customer_support") - * @return Mono that completes if role is present, errors otherwise - */ - protected Mono requireBackofficeRole(BackofficeContext context, String role) { - if (!context.hasBackofficeRole(role)) { - return Mono.error(new org.springframework.security.access.AccessDeniedException( - "Required backoffice role not present: " + role)); - } - return Mono.empty(); + + /** Fail-closed guard: require the operator to hold a backoffice permission. */ + protected Mono requireBackofficePermission(ServerWebExchange exchange, String permission) { + return resolveContext(exchange).flatMap(ctx -> ctx.hasBackofficePermission(permission) + ? Mono.empty() + : Mono.error(new SecurityException("Backoffice permission required: " + permission))); } } diff --git a/src/main/java/org/fireflyframework/common/backoffice/resolver/AbstractBackofficeContextResolver.java b/src/main/java/org/fireflyframework/common/backoffice/resolver/AbstractBackofficeContextResolver.java index 1c71cae..cbbfb32 100644 --- a/src/main/java/org/fireflyframework/common/backoffice/resolver/AbstractBackofficeContextResolver.java +++ b/src/main/java/org/fireflyframework/common/backoffice/resolver/AbstractBackofficeContextResolver.java @@ -21,301 +21,192 @@ import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; -import java.time.Instant; import java.util.Set; import java.util.UUID; /** - * Abstract base implementation of BackofficeContextResolver. - * Provides common functionality and template methods for backoffice context resolution with impersonation. - * - *

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

- * - *

This class handles:

- *
    - *
  • Extraction of backoffice user ID from X-User-Id header
  • - *
  • Extraction of impersonated party ID from X-Impersonate-Party-Id header
  • - *
  • Validation of impersonation permissions
  • - *
  • Enrichment with roles and permissions for both users
  • - *
  • Audit trail creation
  • - *
- * + * Abstract base implementation of {@link BackofficeContextResolver}. + * + *

Provides the assembly template for a product-agnostic {@link BackofficeContext}: the backoffice + * operator identity, roles and permissions default from the validated security principal (resolved by + * subclasses), an optional generic impersonated subject is read from the request, and the tenant is + * derived from the principal.

+ * + *

Subclasses implement the principal-backed resolution methods. This base wires them together, + * captures the operator IP for audit, and exposes enrichment hooks for impersonated-subject + * roles/permissions (informational only).

+ * * @author Firefly Development Team * @since 1.0.0 */ @Slf4j public abstract class AbstractBackofficeContextResolver implements BackofficeContextResolver { - + @Override public Mono resolveContext(ServerWebExchange exchange) { - log.debug("Resolving backoffice context for request (deprecated - use version with explicit IDs)"); - - return Mono.zip( - resolveBackofficeUserId(exchange), - resolveImpersonatedPartyId(exchange), - resolveTenantId(exchange), - resolveContractId(exchange).defaultIfEmpty(new UUID(0, 0)), // sentinel value — use overload with explicit IDs - resolveProductId(exchange).defaultIfEmpty(new UUID(0, 0)) // sentinel value — use overload with explicit IDs - ) - .flatMap(tuple -> { - UUID backofficeUserId = tuple.getT1(); - UUID impersonatedPartyId = tuple.getT2(); - UUID tenantId = tuple.getT3(); - UUID contractId = tuple.getT4(); - UUID productId = tuple.getT5(); - - // Validate impersonation permission - return validateImpersonationPermission(backofficeUserId, impersonatedPartyId, exchange) - .flatMap(isAuthorized -> { - if (!isAuthorized) { - return Mono.error(new SecurityException( - String.format("Backoffice user %s is not authorized to impersonate party %s", - backofficeUserId, impersonatedPartyId))); - } - - return enrichContext( - BackofficeContext.builder() - .backofficeUserId(backofficeUserId) - .impersonatedPartyId(impersonatedPartyId) - .tenantId(tenantId) - .contractId(contractId) - .productId(productId) - .impersonationStartedAt(Instant.now()) - .build(), - exchange - ); - }); - }) - .doOnSuccess(context -> log.debug("Successfully resolved backoffice context: backoffice user={}, impersonated party={}", - context.getBackofficeUserId(), context.getImpersonatedPartyId())) - .doOnError(error -> log.error("Failed to resolve backoffice context", error)); - } - - @Override - public Mono resolveContext(ServerWebExchange exchange, UUID contractId, UUID productId) { - log.debug("Resolving backoffice context with explicit contract: {} and product: {}", contractId, productId); - + log.debug("Resolving backoffice context from validated security principal"); + return Mono.zip( - resolveBackofficeUserId(exchange), - resolveImpersonatedPartyId(exchange), - resolveTenantId(exchange), - resolveImpersonationReason(exchange).defaultIfEmpty("Not specified") - ) - .flatMap(tuple -> { - UUID backofficeUserId = tuple.getT1(); - UUID impersonatedPartyId = tuple.getT2(); - UUID tenantId = tuple.getT3(); - String impersonationReason = tuple.getT4(); - - // Validate impersonation permission - return validateImpersonationPermission(backofficeUserId, impersonatedPartyId, exchange) - .flatMap(isAuthorized -> { - if (!isAuthorized) { - return Mono.error(new SecurityException( - String.format("Backoffice user %s is not authorized to impersonate party %s", - backofficeUserId, impersonatedPartyId))); - } - - // Extract IP address for audit - String ipAddress = extractIpAddress(exchange); - - return enrichContext( - BackofficeContext.builder() - .backofficeUserId(backofficeUserId) - .impersonatedPartyId(impersonatedPartyId) - .tenantId(tenantId) - .contractId(contractId) // Explicit from controller - .productId(productId) // Explicit from controller - .impersonationStartedAt(Instant.now()) - .impersonationReason(impersonationReason) - .backofficeUserIpAddress(ipAddress) - .build(), - exchange - ); - }); - }) - .doOnSuccess(context -> log.debug("Successfully resolved backoffice context: backoffice user={}, impersonated party={}, contract={}, product={}", - context.getBackofficeUserId(), context.getImpersonatedPartyId(), - context.getContractId(), context.getProductId())) - .doOnError(error -> log.error("Failed to resolve backoffice context", error)); + resolveBackofficeUserId(exchange).map(UUID::toString).defaultIfEmpty(""), + resolveImpersonatedSubject(exchange).defaultIfEmpty(""), + resolveTenantId(exchange).map(UUID::toString).defaultIfEmpty(""), + resolveImpersonationReason(exchange).defaultIfEmpty("") + ) + .flatMap(tuple -> { + String backofficeUserIdRaw = tuple.getT1(); + String impersonatedSubjectRaw = tuple.getT2(); + String tenantIdRaw = tuple.getT3(); + String impersonationReasonRaw = tuple.getT4(); + + UUID backofficeUserId = backofficeUserIdRaw.isEmpty() ? null : UUID.fromString(backofficeUserIdRaw); + String impersonatedSubject = impersonatedSubjectRaw.isEmpty() ? null : impersonatedSubjectRaw; + UUID tenantId = tenantIdRaw.isEmpty() ? null : UUID.fromString(tenantIdRaw); + String impersonationReason = impersonationReasonRaw.isEmpty() ? null : impersonationReasonRaw; + + BackofficeContext basicContext = BackofficeContext.builder() + .backofficeUserId(backofficeUserId) + .impersonatedSubject(impersonatedSubject) + .tenantId(tenantId) + .impersonationReason(impersonationReason) + .backofficeUserIpAddress(extractIpAddress(exchange)) + .build(); + + return enrichContext(basicContext, exchange); + }) + .doOnSuccess(context -> log.debug( + "Resolved backoffice context: operator={}, impersonatedSubject={}, tenant={}", + context.getBackofficeUserId(), context.getImpersonatedSubject(), context.getTenantId())) + .doOnError(error -> log.error("Failed to resolve backoffice context", error)); } - + /** - * Enriches the basic context with roles, permissions, and additional data. - * This method should fetch data from platform services for both the backoffice user and impersonated party. - * - * @param basicContext the basic context with IDs - * @param exchange the server web exchange + * Enriches the basic context with backoffice roles/permissions (from the principal) and the + * impersonated subject's informational roles/permissions. + * + * @param basicContext the basic context with identities resolved + * @param exchange the server web exchange * @return Mono of enriched BackofficeContext */ - protected Mono enrichContext(BackofficeContext basicContext, + protected Mono enrichContext(BackofficeContext basicContext, ServerWebExchange exchange) { return Mono.zip( - resolveBackofficeRoles(basicContext, exchange), - resolveBackofficePermissions(basicContext, exchange), - resolveImpersonatedPartyRoles(basicContext, exchange), - resolveImpersonatedPartyPermissions(basicContext, exchange) - ) - .map(tuple -> basicContext.toBuilder() - .backofficeRoles(tuple.getT1()) - .backofficePermissions(tuple.getT2()) - .impersonatedPartyRoles(tuple.getT3()) - .impersonatedPartyPermissions(tuple.getT4()) - .build()) - .defaultIfEmpty(basicContext); + resolveBackofficeRoles(basicContext, exchange), + resolveBackofficePermissions(basicContext, exchange), + resolveImpersonatedSubjectRoles(basicContext, exchange), + resolveImpersonatedSubjectPermissions(basicContext, exchange) + ) + .map(tuple -> basicContext.toBuilder() + .backofficeRoles(tuple.getT1()) + .backofficePermissions(tuple.getT2()) + .impersonatedSubjectRoles(tuple.getT3()) + .impersonatedSubjectPermissions(tuple.getT4()) + .build()) + .defaultIfEmpty(basicContext); } - + /** - * Resolves roles for the backoffice user. - * These are backoffice-specific roles like "admin", "support", "analyst", etc. - * - * @param context the backoffice context + * Resolves roles for the backoffice operator. Defaults to the principal's authorities in the + * concrete resolver; the base returns an empty set. + * + * @param context the backoffice context * @param exchange the server web exchange * @return Mono of role set */ protected Mono> resolveBackofficeRoles(BackofficeContext context, ServerWebExchange exchange) { - log.debug("Resolving backoffice roles for user: {}", context.getBackofficeUserId()); + log.debug("Resolving backoffice roles for operator: {}", context.getBackofficeUserId()); return Mono.just(Set.of()); } - + /** - * Resolves permissions for the backoffice user. - * These are derived from backoffice roles. - * - * @param context the backoffice context + * Resolves permissions for the backoffice operator. Defaults to the principal's scopes in the + * concrete resolver; the base returns an empty set. + * + * @param context the backoffice context * @param exchange the server web exchange * @return Mono of permission set */ protected Mono> resolveBackofficePermissions(BackofficeContext context, ServerWebExchange exchange) { - log.debug("Resolving backoffice permissions for user: {}", context.getBackofficeUserId()); + log.debug("Resolving backoffice permissions for operator: {}", context.getBackofficeUserId()); return Mono.just(Set.of()); } - + /** - * Resolves roles for the impersonated party in the context of the contract/product. - * These are informational - the backoffice user's permissions take precedence. - * - * @param context the backoffice context + * Resolves informational roles for the impersonated subject (if any). + * + * @param context the backoffice context * @param exchange the server web exchange * @return Mono of role set */ - protected Mono> resolveImpersonatedPartyRoles(BackofficeContext context, ServerWebExchange exchange) { - log.debug("Resolving impersonated party roles for party: {} in contract: {}", - context.getImpersonatedPartyId(), context.getContractId()); + protected Mono> resolveImpersonatedSubjectRoles(BackofficeContext context, ServerWebExchange exchange) { + log.debug("Resolving impersonated subject roles for: {}", context.getImpersonatedSubject()); return Mono.just(Set.of()); } - + /** - * Resolves permissions for the impersonated party in the context of the contract/product. - * These are informational - the backoffice user's permissions take precedence. - * - * @param context the backoffice context + * Resolves informational permissions for the impersonated subject (if any). + * + * @param context the backoffice context * @param exchange the server web exchange * @return Mono of permission set */ - protected Mono> resolveImpersonatedPartyPermissions(BackofficeContext context, ServerWebExchange exchange) { - log.debug("Resolving impersonated party permissions for party: {} in contract: {}, product: {}", - context.getImpersonatedPartyId(), context.getContractId(), context.getProductId()); + protected Mono> resolveImpersonatedSubjectPermissions(BackofficeContext context, ServerWebExchange exchange) { + log.debug("Resolving impersonated subject permissions for: {}", context.getImpersonatedSubject()); 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 string value from request attribute or header. - * - * @param exchange the server web exchange + * Extracts a string value from a request attribute or header. + * + * @param exchange the server web exchange * @param attributeName the attribute name - * @param headerName the header name - * @return Mono of String + * @param headerName the header name + * @return Mono of String (may be empty) */ protected Mono extractString(ServerWebExchange exchange, String attributeName, String headerName) { - // Try to get from attribute first String fromAttribute = exchange.getAttribute(attributeName); if (fromAttribute != null && !fromAttribute.isEmpty()) { return Mono.just(fromAttribute); } - - // Try to get from header + String headerValue = exchange.getRequest().getHeaders().getFirst(headerName); if (headerValue != null && !headerValue.isEmpty()) { return Mono.just(headerValue); } - + return Mono.empty(); } - + /** - * Extracts IP address from the request. - * + * Extracts the IP address of the backoffice operator from the request. + * * @param exchange the server web exchange * @return IP address or "unknown" */ protected String extractIpAddress(ServerWebExchange exchange) { - // Try X-Forwarded-For first (for proxied requests) String xForwardedFor = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For"); if (xForwardedFor != null && !xForwardedFor.isEmpty()) { - // Take the first IP in the chain return xForwardedFor.split(",")[0].trim(); } - - // Try X-Real-IP + String xRealIp = exchange.getRequest().getHeaders().getFirst("X-Real-IP"); if (xRealIp != null && !xRealIp.isEmpty()) { return xRealIp; } - - // Fall back to remote address - if (exchange.getRequest().getRemoteAddress() != null) { + + if (exchange.getRequest().getRemoteAddress() != null + && exchange.getRequest().getRemoteAddress().getAddress() != null) { return exchange.getRequest().getRemoteAddress().getAddress().getHostAddress(); } - + return "unknown"; } - - @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(); + public Mono resolveImpersonatedSubject(ServerWebExchange exchange) { + log.debug("Resolving impersonated subject from X-Impersonate-Subject header"); + return extractString(exchange, "impersonatedSubject", "X-Impersonate-Subject"); } - + @Override public Mono resolveImpersonationReason(ServerWebExchange exchange) { log.debug("Resolving impersonation reason from request"); diff --git a/src/main/java/org/fireflyframework/common/backoffice/resolver/BackofficeContextResolver.java b/src/main/java/org/fireflyframework/common/backoffice/resolver/BackofficeContextResolver.java index 87d2357..ba28074 100644 --- a/src/main/java/org/fireflyframework/common/backoffice/resolver/BackofficeContextResolver.java +++ b/src/main/java/org/fireflyframework/common/backoffice/resolver/BackofficeContextResolver.java @@ -23,140 +23,88 @@ import java.util.UUID; /** - * Interface for resolving backoffice context with customer impersonation from incoming requests. - * Implementations are responsible for extracting and enriching context information including: - *
    - *
  • backofficeUserId - The actual backoffice/admin user making the request
  • - *
  • impersonatedPartyId - The customer (party) being accessed/impersonated
  • - *
  • contractId, productId - Business context identifiers
  • - *
  • roles and permissions - For both backoffice user and impersonated party
  • - *
- * - *

This is the main entry point for backoffice context resolution with impersonation support.

- * - *

Expected Headers (Injected by Istio for Backoffice Routes)

+ * Interface for resolving the {@link BackofficeContext} from incoming requests. + * + *

Implementations extract and enrich product-agnostic context information from the validated + * security principal (via the {@code fireflyframework-security} platform):

*
    - *
  • X-User-Id - Backoffice user UUID (required) - Authenticated backoffice user
  • - *
  • X-Impersonate-Party-Id - Customer party UUID (required) - Customer being accessed
  • - *
  • X-Tenant-Id - Tenant UUID (optional) - Can be resolved from party
  • - *
  • X-Impersonation-Reason - Reason for impersonation (optional, for audit)
  • + *
  • backofficeUserId - the authenticated backoffice operator (from the principal subject)
  • + *
  • backofficeRoles / backofficePermissions - from the principal's authorities/scopes
  • + *
  • tenantId - from the principal's tenant identifier (when a UUID)
  • + *
  • impersonatedSubject - optional generic subject from the {@code X-Impersonate-Subject} header
  • *
- * + * + *

This is the main entry point for backoffice context resolution. It carries no product domain + * (no party, contract, or product) and no Security Center / SessionManager dependency.

+ * * @author Firefly Development Team * @since 1.0.0 */ public interface BackofficeContextResolver { - + /** * Resolves the complete backoffice context from the request. - * This method extracts all IDs automatically (backoffice user, impersonated party, tenant, contract, product). - * + * * @param exchange the server web exchange * @return Mono of resolved BackofficeContext */ Mono resolveContext(ServerWebExchange exchange); - - /** - * Resolves the backoffice context with explicit contractId and productId. - * This is the method controllers should use to pass IDs extracted from {@code @PathVariable}. - * - *

Backoffice user and impersonated party IDs are extracted from Istio headers - * (X-User-Id, X-Impersonate-Party-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 BackofficeContext - */ - Mono resolveContext(ServerWebExchange exchange, UUID contractId, UUID productId); - + /** - * Resolves the backoffice user ID from the request. - * This should extract the authenticated backoffice/admin user identifier. - * - *

Expected header: X-User-Id (injected by Istio for backoffice routes)

- * + * Resolves the backoffice operator ID from the request. + * + *

The operator identity is derived from the validated security principal's subject. When the + * subject is a UUID it is returned; otherwise the result is empty and the raw subject should be + * retained as the {@code "backofficeUserSubject"} attribute on the context.

+ * * @param exchange the server web exchange - * @return Mono of backoffice user UUID + * @return Mono of backoffice operator UUID (may be empty) */ Mono resolveBackofficeUserId(ServerWebExchange exchange); - - /** - * Resolves the impersonated party ID from the request. - * This is the customer (party) whose data is being accessed or modified. - * - *

Expected header: X-Impersonate-Party-Id (required for backoffice operations on customer data)

- * - * @param exchange the server web exchange - * @return Mono of impersonated party UUID - */ - Mono resolveImpersonatedPartyId(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. - * + * Resolves the generic impersonated subject from the request (optional). + * + *

Read from the {@code X-Impersonate-Subject} header when present; empty otherwise.

+ * * @param exchange the server web exchange - * @return Mono of product UUID (may be empty) + * @return Mono of the impersonated subject (may be empty) */ - Mono resolveProductId(ServerWebExchange exchange); - + Mono resolveImpersonatedSubject(ServerWebExchange exchange); + /** * Resolves the tenant ID from the request. - * This typically comes from the impersonated party's tenant association. - * + * + *

Typically derived from the validated security principal's tenant identifier.

+ * * @param exchange the server web exchange - * @return Mono of tenant UUID + * @return Mono of tenant UUID (may be empty) */ Mono resolveTenantId(ServerWebExchange exchange); - + /** * Resolves the impersonation reason from the request (for audit trail). - * This may come from headers or request attributes. - * + * * @param exchange the server web exchange * @return Mono of impersonation reason (may be empty) */ Mono resolveImpersonationReason(ServerWebExchange exchange); - - /** - * Validates that the backoffice user has permission to impersonate the given party. - * This should check with the Security Center or permission service. - * - * @param backofficeUserId the backoffice user requesting impersonation - * @param impersonatedPartyId the party being impersonated - * @param exchange the server web exchange - * @return Mono of boolean indicating if impersonation is authorized - */ - Mono validateImpersonationPermission(UUID backofficeUserId, - UUID impersonatedPartyId, - 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 */ 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 */ default int getPriority() { diff --git a/src/main/java/org/fireflyframework/common/backoffice/resolver/DefaultBackofficeContextResolver.java b/src/main/java/org/fireflyframework/common/backoffice/resolver/DefaultBackofficeContextResolver.java index eb67301..b2186e1 100644 --- a/src/main/java/org/fireflyframework/common/backoffice/resolver/DefaultBackofficeContextResolver.java +++ b/src/main/java/org/fireflyframework/common/backoffice/resolver/DefaultBackofficeContextResolver.java @@ -16,105 +16,38 @@ package org.fireflyframework.common.backoffice.resolver; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import org.fireflyframework.common.backoffice.context.BackofficeContext; -import org.fireflyframework.common.backoffice.util.BackofficeSessionContextMapper; -import org.fireflyframework.common.application.spi.SessionContext; -import org.fireflyframework.common.application.spi.SessionManager; +import org.fireflyframework.security.api.domain.SecurityPrincipal; +import org.fireflyframework.security.spi.SecurityContextPort; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; -import java.util.Base64; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; +import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.UUID; /** - * Default implementation of BackofficeContextResolver for customer impersonation. - * - *

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

- * - *

This resolver automatically:

- *
    - *
  • Extracts backofficeUserId from Istio-injected HTTP header ({@code X-User-Id})
  • - *
  • Extracts impersonatedPartyId from HTTP header ({@code X-Impersonate-Party-Id})
  • - *
  • Resolves tenantId by calling {@code common-platform-config-mgmt} with the impersonated partyId
  • - *
  • Validates that backoffice user has permission to impersonate the customer
  • - *
  • Enriches context with roles and permissions for both backoffice user and impersonated party
  • - *
  • Creates audit trail for compliance
  • - *
- * - *

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 backoffice JWT, injects X-User-Id header (from JWT subject)
  • - *
  • Backoffice Frontend: Sends X-Impersonate-Party-Id header with customer being accessed
  • - *
  • This Resolver: Validates impersonation permission and enriches context
  • - *
  • Controllers: Extract contractId/productId from {@code @PathVariable} in REST path
  • - *
  • SDK Enrichment: Fetch roles/permissions from Security Center for both users
  • - *
- * - *

Expected HTTP Headers (Injected by Istio for Backoffice Routes)

- *
    - *
  • X-User-Id - Backoffice user UUID (required) - Extracted from authenticated backoffice JWT
  • - *
  • X-Impersonate-Party-Id - Customer party UUID (required) - Sent by backoffice frontend
  • - *
  • X-Impersonation-Reason - Reason for access (optional) - For audit trail
  • - *
- * - *

Tenant Resolution

- *

The tenant ID is resolved from the impersonated party, not the backoffice user:

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

Impersonation Validation

- *

Before allowing access, this resolver validates that the backoffice user has permission to impersonate:

- *
- * {@code
- * // Call Security Center to validate impersonation
- * boolean canImpersonate = securityCenter.validateImpersonation(
- *     backofficeUserId, 
- *     impersonatedPartyId, 
- *     tenantId
- * );
- * }
- * 
- * - *

Role & Permission Resolution

- *

Roles and permissions are fetched for both the backoffice user and the impersonated party:

+ * Default, product-agnostic implementation of {@link BackofficeContextResolver}. + * + *

Provided by the library - applications don't need to implement anything.

+ * + *

This resolver assembles the {@link BackofficeContext} from the validated security principal + * exposed by the {@code fireflyframework-security} platform ({@link SecurityContextPort}):

*
    - *
  • Backoffice User: Gets backoffice-specific roles (admin, support, analyst)
  • - *
  • Impersonated Party: Gets customer roles in the contract/product (informational)
  • + *
  • backofficeUserId - from {@link SecurityPrincipal#subject()} when it is a UUID; + * otherwise {@code null}, with the raw subject kept under the {@code "backofficeUserSubject"} attribute
  • + *
  • backofficeRoles - from {@link SecurityPrincipal#authorities()}
  • + *
  • backofficePermissions - from {@link SecurityPrincipal#scopes()}
  • + *
  • tenantId - parsed from {@link SecurityPrincipal#tenantId()} when it is a UUID
  • + *
  • impersonatedSubject - read from the optional {@code X-Impersonate-Subject} header
  • *
- * - *

Controller Responsibility

- *

Controllers must extract contractId and productId from path variables:

- *
- * {@code
- * @GetMapping("/backoffice/customers/{partyId}/contracts/{contractId}/accounts")
- * public Mono> getAccounts(
- *         @PathVariable UUID partyId,
- *         @PathVariable UUID contractId, 
- *         ServerWebExchange exchange) {
- *     // Controller extracts contractId from path, passes to service
- * }
- * }
- * 
- * + * + *

There is no Security Center, no SessionManager, and no product domain (party/contract/product).

+ * * @author Firefly Development Team * @since 1.0.0 */ @@ -122,344 +55,114 @@ @Slf4j @RequiredArgsConstructor public class DefaultBackofficeContextResolver extends AbstractBackofficeContextResolver { - - @Autowired(required = false) - private final SessionManager sessionManager; - - // TODO: Inject platform SDK clients when available - // private final ConfigManagementClient configMgmtClient; // For tenant resolution - // private final BackofficeSecurityClient backofficeSecurityClient; // For impersonation validation - - @Override - public Mono resolveBackofficeUserId(ServerWebExchange exchange) { - log.debug("Resolving backoffice user ID from Istio-injected header"); - - // Backoffice user ID is injected by Istio as X-User-Id header - return extractUUID(exchange, "backofficeUserId", "X-User-Id") - .doOnNext(id -> log.debug("Resolved backoffice user ID from Istio header: {}", id)) - .switchIfEmpty(Mono.error(new IllegalStateException( - "X-User-Id header not found. Ensure request passes through Istio gateway with backoffice authentication."))); - } - - @Override - public Mono resolveImpersonatedPartyId(ServerWebExchange exchange) { - log.debug("Resolving impersonated party ID from request header"); - - // Impersonated party ID is sent by backoffice frontend as X-Impersonate-Party-Id header - return extractUUID(exchange, "impersonatedPartyId", "X-Impersonate-Party-Id") - .doOnNext(id -> log.debug("Resolved impersonated party ID from header: {}", id)) - .switchIfEmpty(Mono.error(new IllegalStateException( - "X-Impersonate-Party-Id header not found. Backoffice requests must specify which customer to access."))); - } - + + /** + * Optional attribute key under which the raw (non-UUID) principal subject is preserved. + */ + public static final String ATTR_BACKOFFICE_USER_SUBJECT = "backofficeUserSubject"; + + private final SecurityContextPort securityContextPort; + @Override - public Mono resolveTenantId(ServerWebExchange exchange) { - log.debug("Resolving tenant ID from config-mgmt using impersonated party ID"); - - // Tenant ID is resolved from the IMPERSONATED PARTY, not the backoffice user - // The tenant is determined by which customer is being accessed - return resolveImpersonatedPartyId(exchange) - .flatMap(impersonatedPartyId -> { - log.debug("Fetching tenant ID for impersonated party: {} from config-mgmt", impersonatedPartyId); - - // TODO: Implement using common-platform-config-mgmt-sdk - // When SDK is available, call: - /* - return configMgmtClient.getPartyTenant(impersonatedPartyId) - .map(response -> response.getTenantId()) - .doOnNext(tenantId -> log.debug("Resolved tenant ID: {} for impersonated party: {}", - tenantId, impersonatedPartyId)); - */ - - // 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/" + impersonatedPartyId + "/tenant"))); + public Mono resolveContext(ServerWebExchange exchange) { + log.debug("Resolving backoffice context from validated security principal"); + + return securityContextPort.currentPrincipal() + .map(principal -> buildContext(principal, exchange)) + .doOnSuccess(context -> { + if (context != null) { + log.debug("Resolved backoffice context: operator={}, impersonatedSubject={}, tenant={}", + context.getBackofficeUserId(), context.getImpersonatedSubject(), context.getTenantId()); + } }) - .doOnError(error -> log.error("Failed to resolve tenant ID for impersonated party", error)); + .doOnError(error -> log.error("Failed to resolve backoffice context", error)); } - - @Override - public Mono validateImpersonationPermission(UUID backofficeUserId, - UUID impersonatedPartyId, - ServerWebExchange exchange) { - log.debug("Validating impersonation: backoffice user {} accessing customer {}", - backofficeUserId, impersonatedPartyId); - - // Backoffice user authentication is handled by Istio (JWT validation + X-User-Id injection) - // Impersonation header (X-Impersonate-Party-Id) is trusted since it comes from authenticated backoffice channels - // - // Here we only need to validate that the impersonated customer (party) exists and is accessible - // The actual contract/product rights validation will be done in enrichContext() when contractId/productId are known - - log.info("Impersonation request: backoffice user {} accessing customer {}", - backofficeUserId, impersonatedPartyId); - - // Always allow since authentication is handled by Istio - // Contract/product access validation happens later via Security Center - return Mono.just(true); - } - - @Override - protected Mono> resolveBackofficeRoles(BackofficeContext context, ServerWebExchange exchange) { - log.debug("Resolving backoffice roles for user: {}", context.getBackofficeUserId()); - // Strategy 1: Use SessionManager if available - if (sessionManager != null) { - return sessionManager.createOrGetSession(exchange) - .map(session -> { - Set roles = BackofficeSessionContextMapper.extractBackofficeRoles(session); - log.debug("Resolved {} backoffice roles from SessionManager for user {}: {}", - roles.size(), context.getBackofficeUserId(), roles); - return roles; - }) - .doOnError(error -> log.error("Failed to resolve backoffice roles from SessionManager: {}", - error.getMessage(), error)) - .onErrorReturn(Set.of()); - } + private BackofficeContext buildContext(SecurityPrincipal principal, ServerWebExchange exchange) { + String subject = principal.subject(); + UUID backofficeUserId = parseUuid(subject); - // Strategy 2: Extract roles from JWT Authorization header (fallback) - Set jwtRoles = extractRolesFromJwt(exchange); - if (!jwtRoles.isEmpty()) { - log.debug("Resolved {} backoffice roles from JWT for user {}: {}", - jwtRoles.size(), context.getBackofficeUserId(), jwtRoles); - return Mono.just(jwtRoles); + Map attributes = new HashMap<>(); + if (backofficeUserId == null && subject != null) { + // Subject is not a UUID — keep it so callers can still identify the operator. + attributes.put(ATTR_BACKOFFICE_USER_SUBJECT, subject); } - log.warn("No SessionManager or JWT available - returning empty backoffice roles"); - return Mono.just(Set.of()); + String impersonatedSubject = firstHeader(exchange, "X-Impersonate-Subject"); + String impersonationReason = firstHeader(exchange, "X-Impersonation-Reason"); + + Set backofficeRoles = principal.authorities() != null ? principal.authorities() : Set.of(); + Set backofficePermissions = principal.scopes() != null ? principal.scopes() : Set.of(); + UUID tenantId = parseUuid(principal.tenantId()); + + return BackofficeContext.builder() + .backofficeUserId(backofficeUserId) + .impersonatedSubject(impersonatedSubject) + .tenantId(tenantId) + .backofficeRoles(backofficeRoles) + .backofficePermissions(backofficePermissions) + .impersonatedSubjectRoles(Set.of()) + .impersonatedSubjectPermissions(Set.of()) + .impersonationReason(impersonationReason) + .backofficeUserIpAddress(extractIpAddress(exchange)) + .attributes(attributes) + .build(); } - - @Override - protected Mono> resolveBackofficePermissions(BackofficeContext context, ServerWebExchange exchange) { - log.debug("Resolving backoffice permissions for user: {}", context.getBackofficeUserId()); - - // Strategy 1: Use SessionManager if available - if (sessionManager != null) { - return sessionManager.createOrGetSession(exchange) - .map(session -> { - Set permissions = BackofficeSessionContextMapper.extractBackofficePermissions(session); - log.debug("Resolved {} backoffice permissions from SessionManager for user {}: {}", - permissions.size(), context.getBackofficeUserId(), permissions); - return permissions; - }) - .doOnError(error -> log.error("Failed to resolve backoffice permissions from SessionManager: {}", - error.getMessage(), error)) - .onErrorReturn(Set.of()); - } - // Strategy 2: Extract scopes/permissions from JWT Authorization header (fallback) - Set jwtPermissions = extractPermissionsFromJwt(exchange); - if (!jwtPermissions.isEmpty()) { - log.debug("Resolved {} backoffice permissions from JWT for user {}: {}", - jwtPermissions.size(), context.getBackofficeUserId(), jwtPermissions); - return Mono.just(jwtPermissions); - } + @Override + public Mono resolveBackofficeUserId(ServerWebExchange exchange) { + log.debug("Resolving backoffice operator ID from validated security principal"); + return securityContextPort.currentPrincipal() + .mapNotNull(principal -> parseUuid(principal.subject())); + } - log.warn("No SessionManager or JWT available - returning empty backoffice permissions"); - return Mono.just(Set.of()); + @Override + public Mono resolveTenantId(ServerWebExchange exchange) { + log.debug("Resolving tenant ID from validated security principal"); + return securityContextPort.currentPrincipal() + .mapNotNull(principal -> parseUuid(principal.tenantId())); } - + @Override - protected Mono> resolveImpersonatedPartyRoles(BackofficeContext context, ServerWebExchange exchange) { - log.debug("Resolving impersonated party roles for party: {} in contract: {}, product: {}", - context.getImpersonatedPartyId(), context.getContractId(), context.getProductId()); - - // Check if SessionManager is available - if (sessionManager == null) { - log.warn("SessionManager not available - returning empty impersonated party roles. " + - "Ensure common-platform-security-center is deployed and accessible."); - return Mono.just(Set.of()); - } - - // For impersonated party, we validate they have rights over the contract/product via Security Center - // This ensures the customer actually has access to the requested resources - // TODO: Implement party session lookup and validation - /* - return sessionManager.getPartySession(context.getImpersonatedPartyId(), context.getTenantId()) - .flatMap(partySession -> { - // Validate customer has rights to the contract/product via Security Center - if (context.getContractId() != null) { - boolean hasContractAccess = partySession.getActiveContracts().stream() - .anyMatch(contract -> context.getContractId().equals(contract.getContractId()) - && Boolean.TRUE.equals(contract.getIsActive())); - - if (!hasContractAccess) { - return Mono.error(new SecurityException( - String.format("Customer %s does not have access to contract %s", - context.getImpersonatedPartyId(), context.getContractId()))); - } - - if (context.getProductId() != null) { - boolean hasProductAccess = partySession.getActiveContracts().stream() - .filter(contract -> context.getContractId().equals(contract.getContractId())) - .anyMatch(contract -> contract.getProduct() != null - && context.getProductId().equals(contract.getProduct().getProductId())); - - if (!hasProductAccess) { - return Mono.error(new SecurityException( - String.format("Customer %s does not have access to product %s in contract %s", - context.getImpersonatedPartyId(), context.getProductId(), context.getContractId()))); - } - } - } - - // Extract roles using standard SessionContextMapper based on context scope - Set roles = SessionContextMapper.extractRoles( - partySession, - context.getContractId(), - context.getProductId() - ); - - log.info("Validated customer {} access to contract {} / product {} - {} roles found", - context.getImpersonatedPartyId(), context.getContractId(), - context.getProductId(), roles.size()); - return Mono.just(roles); - }) - .doOnError(error -> log.error("Failed to resolve/validate impersonated party roles: {}", - error.getMessage(), error)) - .onErrorReturn(Set.of()); - */ - - // Temporary: Return empty set - log.debug("Impersonated party role resolution not yet implemented"); - return Mono.just(Set.of()); + protected Mono> resolveBackofficeRoles(BackofficeContext context, ServerWebExchange exchange) { + return securityContextPort.currentPrincipal() + .map(principal -> principal.authorities() != null ? principal.authorities() : Set.of()) + .defaultIfEmpty(Set.of()); } - + @Override - protected Mono> resolveImpersonatedPartyPermissions(BackofficeContext context, ServerWebExchange exchange) { - log.debug("Resolving impersonated party permissions for party: {} in contract: {}, product: {}", - context.getImpersonatedPartyId(), context.getContractId(), context.getProductId()); - - // Check if SessionManager is available - if (sessionManager == null) { - log.warn("SessionManager not available - returning empty impersonated party permissions. " + - "Ensure common-platform-security-center is deployed and accessible."); - return Mono.just(Set.of()); - } - - // For impersonated party, validate they have rights via Security Center and extract permissions - // TODO: Implement party session lookup - /* - return sessionManager.getPartySession(context.getImpersonatedPartyId(), context.getTenantId()) - .map(partySession -> { - // Extract permissions from role scopes using standard SessionContextMapper - Set permissions = SessionContextMapper.extractPermissions( - partySession, - context.getContractId(), - context.getProductId() - ); - - log.debug("Resolved {} permissions for impersonated party {}: {}", - permissions.size(), context.getImpersonatedPartyId(), permissions); - return permissions; - }) - .doOnError(error -> log.error("Failed to resolve impersonated party permissions: {}", - error.getMessage(), error)) - .onErrorReturn(Set.of()); - */ - - // Temporary: Return empty set - log.debug("Impersonated party permission resolution not yet implemented"); - return Mono.just(Set.of()); + protected Mono> resolveBackofficePermissions(BackofficeContext context, ServerWebExchange exchange) { + return securityContextPort.currentPrincipal() + .map(principal -> principal.scopes() != null ? principal.scopes() : Set.of()) + .defaultIfEmpty(Set.of()); } - + @Override public boolean supports(ServerWebExchange exchange) { - // This default resolver supports all backoffice requests return true; } - + @Override public int getPriority() { - // Default priority return 0; } /** - * Extract roles from JWT Authorization header. - * Supports standard "roles" claim, Keycloak "realm_access.roles", and Cognito "cognito:groups". + * Parses the given value to a {@link UUID}, returning {@code null} when it is absent or not a UUID. */ - private Set extractRolesFromJwt(ServerWebExchange exchange) { - Map claims = parseJwtClaims(exchange); - if (claims == null) { - return Collections.emptySet(); - } - - Set roles = new HashSet<>(); - - // Standard "roles" claim - addStringList(roles, claims.get("roles")); - - // Keycloak: realm_access.roles - if (claims.get("realm_access") instanceof Map realmAccess) { - addStringList(roles, realmAccess.get("roles")); - } - - // Cognito: cognito:groups - addStringList(roles, claims.get("cognito:groups")); - - return roles; - } - - /** - * Extract permissions/scopes from JWT Authorization header. - */ - private Set extractPermissionsFromJwt(ServerWebExchange exchange) { - Map claims = parseJwtClaims(exchange); - if (claims == null) { - return Collections.emptySet(); - } - - Set permissions = new HashSet<>(); - - // Standard "scope" claim (space-separated) - if (claims.get("scope") instanceof String scopeStr) { - for (String s : scopeStr.split("\\s+")) { - if (!s.isBlank()) { - permissions.add(s); - } - } - } - - // "permissions" claim (array) - addStringList(permissions, claims.get("permissions")); - - return permissions; - } - - @SuppressWarnings("unchecked") - private Map parseJwtClaims(ServerWebExchange exchange) { - String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization"); - if (authHeader == null || !authHeader.startsWith("Bearer ")) { + private static UUID parseUuid(String value) { + if (value == null || value.isBlank()) { return null; } - try { - String token = authHeader.substring(7); - String[] parts = token.split("\\."); - if (parts.length < 2) { - return null; - } - String payload = new String(Base64.getUrlDecoder().decode(parts[1])); - ObjectMapper mapper = new ObjectMapper(); - return mapper.readValue(payload, new TypeReference>() {}); - } catch (Exception e) { - log.debug("Failed to parse JWT claims: {}", e.getMessage()); + return UUID.fromString(value); + } catch (IllegalArgumentException ignored) { return null; } } - private void addStringList(Set target, Object value) { - if (value instanceof List list) { - for (Object item : list) { - if (item instanceof String s && !s.isBlank()) { - target.add(s); - } - } - } + private static String firstHeader(ServerWebExchange exchange, String headerName) { + String value = exchange.getRequest().getHeaders().getFirst(headerName); + return (value != null && !value.isEmpty()) ? value : null; } } diff --git a/src/main/java/org/fireflyframework/common/backoffice/util/BackofficeSessionContextMapper.java b/src/main/java/org/fireflyframework/common/backoffice/util/BackofficeSessionContextMapper.java deleted file mode 100644 index 698a8d0..0000000 --- a/src/main/java/org/fireflyframework/common/backoffice/util/BackofficeSessionContextMapper.java +++ /dev/null @@ -1,273 +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.backoffice.util; - -import org.fireflyframework.common.application.spi.SessionContext; -import lombok.extern.slf4j.Slf4j; - -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -/** - * Utility class for mapping SessionContext to backoffice roles and permissions. - * - *

This mapper extracts backoffice-specific roles and permissions from the session. - * Unlike the standard SessionContextMapper which focuses on contract/product roles, - * this mapper focuses on backoffice/administrative roles.

- * - *

Backoffice Role Examples:

- *
    - *
  • admin - Full system administrator
  • - *
  • customer_support - Can view and assist customers
  • - *
  • financial_analyst - Can view financial data
  • - *
  • auditor - Read-only access for compliance
  • - *
  • operations - Can manage operational tasks
  • - *
- * - *

Backoffice Permission Format: {resource}:{action}

- *

Examples:

- *
    - *
  • customers:read
  • - *
  • customers:write
  • - *
  • accounts:read
  • - *
  • transactions:read
  • - *
  • transactions:write
  • - *
  • system:admin
  • - *
- * - * @author Firefly Development Team - * @since 1.0.0 - */ -@Slf4j -public final class BackofficeSessionContextMapper { - - private BackofficeSessionContextMapper() { - // Utility class - prevent instantiation - } - - /** - * Extracts backoffice roles from the session context. - * - *

Backoffice roles are typically stored at the party level (not contract-specific) - * and represent the user's administrative privileges.

- * - * @param sessionContext The session context from SessionManager - * @return Set of backoffice role codes (e.g., "admin", "customer_support", "analyst") - */ - public static Set extractBackofficeRoles(SessionContext sessionContext) { - if (sessionContext == null) { - log.debug("Session context is null, returning empty backoffice roles"); - return Collections.emptySet(); - } - - Set roles = new HashSet<>(); - - // Extract roles from the session's roles list. - // Backoffice sessions populate SessionContext.roles with the user's administrative roles. - if (sessionContext.getRoles() != null) { - for (String role : sessionContext.getRoles()) { - if (role != null && !role.isBlank()) { - roles.add(role); - log.debug("Extracted backoffice role: {}", role); - } - } - } - - // Also check attributes for backoffice-specific roles (if session enrichment provides them separately) - if (sessionContext.getAttributes() != null) { - Object boRoles = sessionContext.getAttributes().get("backofficeRoles"); - if (boRoles instanceof Iterable iterable) { - for (Object r : iterable) { - if (r instanceof String roleStr && !roleStr.isBlank()) { - roles.add(roleStr); - log.debug("Extracted backoffice role from attributes: {}", roleStr); - } - } - } - } - - log.debug("Extracted {} backoffice roles: {}", roles.size(), roles); - return roles; - } - - /** - * Extracts backoffice permissions from the session context. - * - *

Permissions are derived from backoffice roles and represent specific - * actions the user can perform in the backoffice system.

- * - * @param sessionContext The session context from SessionManager - * @return Set of permission strings (e.g., "customers:read", "accounts:write") - */ - public static Set extractBackofficePermissions(SessionContext sessionContext) { - if (sessionContext == null) { - log.debug("Session context is null, returning empty backoffice permissions"); - return Collections.emptySet(); - } - - Set permissions = new HashSet<>(); - - // Extract permissions from the session's scopes list. - // Backoffice sessions populate SessionContext.scopes with fine-grained permissions (resource:action). - if (sessionContext.getScopes() != null) { - for (String scope : sessionContext.getScopes()) { - if (scope != null && !scope.isBlank()) { - permissions.add(scope); - log.debug("Extracted backoffice permission from scope: {}", scope); - } - } - } - - // Also check attributes for backoffice-specific permissions - if (sessionContext.getAttributes() != null) { - Object boPermissions = sessionContext.getAttributes().get("backofficePermissions"); - if (boPermissions instanceof Iterable iterable) { - for (Object p : iterable) { - if (p instanceof String permStr && !permStr.isBlank()) { - permissions.add(permStr); - log.debug("Extracted backoffice permission from attributes: {}", permStr); - } - } - } - } - - log.debug("Extracted {} backoffice permissions: {}", permissions.size(), permissions); - return permissions; - } - - /** - * Checks if the backoffice user has a specific role. - * - * @param sessionContext The session context from SessionManager - * @param role The role to check (e.g., "admin", "customer_support") - * @return true if the user has the role, false otherwise - */ - public static boolean hasBackofficeRole(SessionContext sessionContext, String role) { - if (sessionContext == null || role == null) { - return false; - } - - Set roles = extractBackofficeRoles(sessionContext); - boolean hasRole = roles.contains(role); - - log.debug("Backoffice role check for '{}': {}", role, hasRole); - return hasRole; - } - - /** - * Checks if the backoffice user has a specific permission. - * - * @param sessionContext The session context from SessionManager - * @param resource The resource type (e.g., "customers", "accounts") - * @param action The action type (e.g., "read", "write", "delete") - * @return true if the user has the permission, false otherwise - */ - public static boolean hasBackofficePermission(SessionContext sessionContext, - String resource, - String action) { - if (sessionContext == null || resource == null || action == null) { - return false; - } - - Set permissions = extractBackofficePermissions(sessionContext); - String permissionStr = String.format("%s:%s", resource, action); - boolean hasPermission = permissions.contains(permissionStr); - - log.debug("Backoffice permission check for '{}': {}", permissionStr, hasPermission); - return hasPermission; - } - - /** - * Checks if the backoffice user has any of the specified roles. - * - * @param sessionContext The session context from SessionManager - * @param roles The roles to check - * @return true if the user has any of the roles, false otherwise - */ - public static boolean hasAnyBackofficeRole(SessionContext sessionContext, String... roles) { - if (sessionContext == null || roles == null || roles.length == 0) { - return false; - } - - Set userRoles = extractBackofficeRoles(sessionContext); - for (String role : roles) { - if (userRoles.contains(role)) { - log.debug("Backoffice user has role: {}", role); - return true; - } - } - - log.debug("Backoffice user does not have any of the required roles: {}", (Object[]) roles); - return false; - } - - /** - * Checks if the backoffice user has all of the specified roles. - * - * @param sessionContext The session context from SessionManager - * @param roles The roles to check - * @return true if the user has all roles, false otherwise - */ - public static boolean hasAllBackofficeRoles(SessionContext sessionContext, String... roles) { - if (sessionContext == null || roles == null || roles.length == 0) { - return false; - } - - Set userRoles = extractBackofficeRoles(sessionContext); - for (String role : roles) { - if (!userRoles.contains(role)) { - log.debug("Backoffice user missing required role: {}", role); - return false; - } - } - - log.debug("Backoffice user has all required roles: {}", (Object[]) roles); - return true; - } - - /** - * Checks if the backoffice user is an administrator. - * This is a convenience method that checks for the "admin" role. - * - * @param sessionContext The session context from SessionManager - * @return true if the user is an admin, false otherwise - */ - public static boolean isAdmin(SessionContext sessionContext) { - return hasBackofficeRole(sessionContext, "admin"); - } - - /** - * Checks if the backoffice user has read access to customer data. - * - * @param sessionContext The session context from SessionManager - * @return true if the user can read customer data, false otherwise - */ - public static boolean canReadCustomers(SessionContext sessionContext) { - return hasBackofficePermission(sessionContext, "customers", "read"); - } - - /** - * Checks if the backoffice user has write access to customer data. - * - * @param sessionContext The session context from SessionManager - * @return true if the user can write customer data, false otherwise - */ - public static boolean canWriteCustomers(SessionContext sessionContext) { - return hasBackofficePermission(sessionContext, "customers", "write"); - } -} diff --git a/src/test/java/org/fireflyframework/common/backoffice/context/BackofficeContextTest.java b/src/test/java/org/fireflyframework/common/backoffice/context/BackofficeContextTest.java index ce13995..4639213 100644 --- a/src/test/java/org/fireflyframework/common/backoffice/context/BackofficeContextTest.java +++ b/src/test/java/org/fireflyframework/common/backoffice/context/BackofficeContextTest.java @@ -18,227 +18,58 @@ import org.junit.jupiter.api.Test; -import java.time.Instant; -import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; class BackofficeContextTest { @Test - void shouldCreateBackofficeContextWithBuilder() { - UUID backofficeUserId = UUID.randomUUID(); - UUID impersonatedPartyId = UUID.randomUUID(); - UUID contractId = UUID.randomUUID(); - UUID productId = UUID.randomUUID(); - UUID tenantId = UUID.randomUUID(); - Instant now = Instant.now(); - - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(backofficeUserId) - .impersonatedPartyId(impersonatedPartyId) - .contractId(contractId) - .productId(productId) - .tenantId(tenantId) - .backofficeRoles(Set.of("admin", "support")) - .backofficePermissions(Set.of("customers:read", "customers:write")) - .impersonatedPartyRoles(Set.of("owner")) - .impersonatedPartyPermissions(Set.of("account:read")) - .impersonationStartedAt(now) - .impersonationReason("Support ticket #12345") - .backofficeUserIpAddress("192.168.1.1") - .build(); - - assertNotNull(context); - assertEquals(backofficeUserId, context.getBackofficeUserId()); - assertEquals(impersonatedPartyId, context.getImpersonatedPartyId()); - assertEquals(contractId, context.getContractId()); - assertEquals(productId, context.getProductId()); - assertEquals(tenantId, context.getTenantId()); - assertEquals(2, context.getBackofficeRoles().size()); - assertEquals(2, context.getBackofficePermissions().size()); - assertEquals(now, context.getImpersonationStartedAt()); - assertEquals("Support ticket #12345", context.getImpersonationReason()); - assertEquals("192.168.1.1", context.getBackofficeUserIpAddress()); - } - - @Test - void shouldCheckBackofficeRoleCorrectly() { - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .backofficeRoles(Set.of("admin", "support")) - .build(); - - assertTrue(context.hasBackofficeRole("admin")); - assertTrue(context.hasBackofficeRole("support")); - assertFalse(context.hasBackofficeRole("analyst")); - } - - @Test - void shouldCheckAnyBackofficeRoleCorrectly() { - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .backofficeRoles(Set.of("admin", "support")) - .build(); - - assertTrue(context.hasAnyBackofficeRole("admin", "analyst")); - assertTrue(context.hasAnyBackofficeRole("support")); - assertFalse(context.hasAnyBackofficeRole("analyst", "auditor")); - } - - @Test - void shouldCheckAllBackofficeRolesCorrectly() { - BackofficeContext context = BackofficeContext.builder() + void operatorRoleAndPermissionHelpers() { + BackofficeContext ctx = BackofficeContext.builder() .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) .backofficeRoles(Set.of("admin", "support")) + .backofficePermissions(Set.of("refund:approve")) .build(); - assertTrue(context.hasAllBackofficeRoles("admin", "support")); - assertFalse(context.hasAllBackofficeRoles("admin", "analyst")); - } - - @Test - void shouldCheckBackofficePermissionCorrectly() { - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .backofficePermissions(Set.of("customers:read", "customers:write")) - .build(); - - assertTrue(context.hasBackofficePermission("customers:read")); - assertTrue(context.hasBackofficePermission("customers:write")); - assertFalse(context.hasBackofficePermission("customers:delete")); - } - - @Test - void shouldCheckImpersonatedPartyRoleCorrectly() { - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .impersonatedPartyRoles(Set.of("owner", "viewer")) - .build(); - - assertTrue(context.impersonatedPartyHasRole("owner")); - assertTrue(context.impersonatedPartyHasRole("viewer")); - assertFalse(context.impersonatedPartyHasRole("admin")); + assertThat(ctx.hasBackofficeRole("admin")).isTrue(); + assertThat(ctx.hasBackofficeRole("missing")).isFalse(); + assertThat(ctx.hasBackofficeAnyRole("missing", "support")).isTrue(); + assertThat(ctx.hasBackofficePermission("refund:approve")).isTrue(); + assertThat(ctx.hasBackofficePermission("nope")).isFalse(); } @Test - void shouldCheckHasContractCorrectly() { - BackofficeContext withContract = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .contractId(UUID.randomUUID()) - .build(); + void impersonationIsGenericSubject() { + BackofficeContext notImpersonating = BackofficeContext.builder() + .backofficeUserId(UUID.randomUUID()).build(); + assertThat(notImpersonating.isImpersonating()).isFalse(); - BackofficeContext withoutContract = BackofficeContext.builder() + BackofficeContext impersonating = BackofficeContext.builder() .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .build(); - - assertTrue(withContract.hasContract()); - assertFalse(withoutContract.hasContract()); + .impersonatedSubject("user-123") + .impersonatedSubjectRoles(Set.of("customer")) + .impersonationReason("Support ticket #42") + .build(); + assertThat(impersonating.isImpersonating()).isTrue(); + assertThat(impersonating.getImpersonatedSubject()).isEqualTo("user-123"); + assertThat(impersonating.impersonatedSubjectHasRole("customer")).isTrue(); + assertThat(impersonating.getImpersonationReason()).isEqualTo("Support ticket #42"); } @Test - void shouldCheckHasProductCorrectly() { - BackofficeContext withProduct = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .productId(UUID.randomUUID()) - .build(); - - BackofficeContext withoutProduct = BackofficeContext.builder() + void carriesGenericTenantAndAttributes() { + UUID tenant = UUID.randomUUID(); + BackofficeContext ctx = BackofficeContext.builder() .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .build(); - - assertTrue(withProduct.hasProduct()); - assertFalse(withoutProduct.hasProduct()); - } - - @Test - void shouldGetAttributeCorrectly() { - Map attributes = new HashMap<>(); - attributes.put("customKey", "customValue"); - attributes.put("numericKey", 42); - - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .attributes(attributes) - .build(); - - assertEquals("customValue", context.getAttribute("customKey")); - assertEquals(Integer.valueOf(42), context.getAttribute("numericKey")); - assertNull(context.getAttribute("nonExistent")); - } - - @Test - void shouldValidateImpersonationCorrectly() { - BackofficeContext validContext = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .build(); - - assertTrue(validContext.isValidImpersonation()); - } - - @Test - void shouldHandleNullRolesGracefully() { - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .build(); - - assertFalse(context.hasBackofficeRole("admin")); - assertFalse(context.hasAnyBackofficeRole("admin", "support")); - assertFalse(context.hasAllBackofficeRoles("admin")); - } - - @Test - void shouldHandleNullPermissionsGracefully() { - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .build(); - - assertFalse(context.hasBackofficePermission("customers:read")); - } - - @Test - void shouldHandleNullAttributesGracefully() { - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .build(); - - assertNull(context.getAttribute("anyKey")); - } - - @Test - void shouldUseToBuilderCorrectly() { - UUID originalUserId = UUID.randomUUID(); - UUID newUserId = UUID.randomUUID(); - - BackofficeContext original = BackofficeContext.builder() - .backofficeUserId(originalUserId) - .impersonatedPartyId(UUID.randomUUID()) - .backofficeRoles(Set.of("admin")) - .build(); - - BackofficeContext modified = original.toBuilder() - .backofficeUserId(newUserId) + .tenantId(tenant) + .attributes(Map.of("backofficeUserSubject", "ops@firefly")) .build(); - assertEquals(originalUserId, original.getBackofficeUserId()); - assertEquals(newUserId, modified.getBackofficeUserId()); - assertEquals(original.getImpersonatedPartyId(), modified.getImpersonatedPartyId()); + assertThat(ctx.getTenantId()).isEqualTo(tenant); + assertThat(ctx.getAttribute("backofficeUserSubject")).isEqualTo("ops@firefly"); + assertThat(ctx.getAttribute("missing")).isNull(); } } diff --git a/src/test/java/org/fireflyframework/common/backoffice/controller/AbstractBackofficeResourceControllerTest.java b/src/test/java/org/fireflyframework/common/backoffice/controller/AbstractBackofficeResourceControllerTest.java index a1ee8ee..08e0f2a 100644 --- a/src/test/java/org/fireflyframework/common/backoffice/controller/AbstractBackofficeResourceControllerTest.java +++ b/src/test/java/org/fireflyframework/common/backoffice/controller/AbstractBackofficeResourceControllerTest.java @@ -20,12 +20,11 @@ import org.fireflyframework.common.backoffice.resolver.BackofficeContextResolver; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.http.HttpHeaders; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; -import org.springframework.security.access.AccessDeniedException; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -33,329 +32,80 @@ import java.util.Set; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) class AbstractBackofficeResourceControllerTest { @Mock - private BackofficeContextResolver contextResolver; + BackofficeContextResolver contextResolver; - private TestBackofficeResourceController controller; + private TestController controller; @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); - controller = new TestBackofficeResourceController(); - controller.setContextResolver(contextResolver); + controller = new TestController(contextResolver); } - @Test - void shouldResolveBackofficeContextSuccessfully() { - // Given - UUID partyId = UUID.randomUUID(); - UUID contractId = UUID.randomUUID(); - UUID productId = UUID.randomUUID(); - ServerWebExchange exchange = createExchange(); - - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(partyId) - .contractId(contractId) - .productId(productId) - .build(); - - when(contextResolver.resolveContext(any(ServerWebExchange.class), eq(contractId), eq(productId))) - .thenReturn(Mono.just(context)); - - // When - Mono result = controller.resolveBackofficeContext(exchange, partyId, contractId, productId); - - // Then - StepVerifier.create(result) - .expectNext(context) - .verifyComplete(); - - verify(contextResolver).resolveContext(exchange, contractId, productId); + private ServerWebExchange exchange() { + return MockServerWebExchange.from(MockServerHttpRequest.get("/backoffice/resource")); } - @Test - void shouldFailWhenPartyIdDoesNotMatch() { - // Given - UUID partyIdInPath = UUID.randomUUID(); - UUID differentPartyId = UUID.randomUUID(); - UUID contractId = UUID.randomUUID(); - ServerWebExchange exchange = createExchange(); - - BackofficeContext context = BackofficeContext.builder() + private BackofficeContext context(Set roles, String impersonatedSubject) { + return BackofficeContext.builder() .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(differentPartyId) - .contractId(contractId) + .backofficeRoles(roles) + .impersonatedSubject(impersonatedSubject) .build(); - - when(contextResolver.resolveContext(any(ServerWebExchange.class), eq(contractId), eq(null))) - .thenReturn(Mono.just(context)); - - // When - Mono result = controller.resolveBackofficeContext(exchange, partyIdInPath, contractId, null); - - // Then - StepVerifier.create(result) - .expectErrorMatches(error -> - error instanceof IllegalArgumentException && - error.getMessage().contains("does not match impersonated party")) - .verify(); } @Test - void shouldValidatePartyIdSuccessfully() { - // Given - UUID partyId = UUID.randomUUID(); - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(partyId) - .build(); - - // When - Mono result = controller.validatePartyId(context, partyId); - - // Then - StepVerifier.create(result) - .expectNext(context) + void resolvesBackofficeContext() { + when(contextResolver.resolveContext(any())).thenReturn(Mono.just(context(Set.of("admin"), null))); + StepVerifier.create(controller.resolveContext(exchange())) + .expectNextMatches(ctx -> ctx.hasBackofficeRole("admin") && !ctx.isImpersonating()) .verifyComplete(); } @Test - void shouldFailValidationWhenPartyIdMismatch() { - // Given - UUID partyId = UUID.randomUUID(); - UUID differentPartyId = UUID.randomUUID(); - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(partyId) - .build(); - - // When - Mono result = controller.validatePartyId(context, differentPartyId); - - // Then - StepVerifier.create(result) - .expectError(IllegalArgumentException.class) - .verify(); - } - - @Test - void shouldLogImpersonationOperation() { - // Given - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .contractId(UUID.randomUUID()) - .impersonationReason("Support ticket #123") - .build(); - - // When/Then - should not throw - controller.logImpersonationOperation(context, "testOperation"); - } - - @Test - void shouldRequireContractWhenPresent() { - // Given - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .contractId(UUID.randomUUID()) - .build(); - - // When - Mono result = controller.requireContext(context, true, false); - - // Then - StepVerifier.create(result) - .expectNext(context) + void detectsImpersonation() { + when(contextResolver.resolveContext(any())).thenReturn(Mono.just(context(Set.of("support"), "user-123"))); + StepVerifier.create(controller.resolveContext(exchange())) + .expectNextMatches(BackofficeContext::isImpersonating) .verifyComplete(); } @Test - void shouldFailWhenContractRequiredButNotPresent() { - // Given - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .build(); - - // When - Mono result = controller.requireContext(context, true, false); - - // Then - StepVerifier.create(result) - .expectErrorMatches(error -> - error instanceof IllegalStateException && - error.getMessage().contains("Contract ID is required")) - .verify(); + void propagatesResolverError() { + when(contextResolver.resolveContext(any())).thenReturn(Mono.error(new IllegalStateException("no principal"))); + StepVerifier.create(controller.resolveContext(exchange())) + .expectError(IllegalStateException.class).verify(); } @Test - void shouldRequireProductWhenPresent() { - // Given - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .productId(UUID.randomUUID()) - .build(); - - // When - Mono result = controller.requireContext(context, false, true); - - // Then - StepVerifier.create(result) - .expectNext(context) - .verifyComplete(); + void requireBackofficeRolePassesWhenHeld() { + when(contextResolver.resolveContext(any())).thenReturn(Mono.just(context(Set.of("admin"), null))); + StepVerifier.create(controller.requireBackofficeRole(exchange(), "admin")).verifyComplete(); } @Test - void shouldFailWhenProductRequiredButNotPresent() { - // Given - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .build(); - - // When - Mono result = controller.requireContext(context, false, true); - - // Then - StepVerifier.create(result) - .expectErrorMatches(error -> - error instanceof IllegalStateException && - error.getMessage().contains("Product ID is required")) - .verify(); + void requireBackofficeRoleFailsWhenMissing() { + when(contextResolver.resolveContext(any())).thenReturn(Mono.just(context(Set.of("support"), null))); + StepVerifier.create(controller.requireBackofficeRole(exchange(), "admin")) + .expectError(SecurityException.class).verify(); } @Test - void shouldPassBackofficePermissionCheck() { - // Given - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .backofficePermissions(Set.of("customers:read", "customers:write")) - .build(); - - // When - Mono result = controller.requireBackofficePermission(context, "customers:read"); - - // Then - StepVerifier.create(result) - .verifyComplete(); + void logOperationDoesNotThrow() { + assertThatCode(() -> controller.logOperation("approve-refund")).doesNotThrowAnyException(); } - @Test - void shouldFailBackofficePermissionCheck() { - // Given - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .backofficePermissions(Set.of("customers:read")) - .build(); - - // When - Mono result = controller.requireBackofficePermission(context, "customers:delete"); - - // Then - StepVerifier.create(result) - .expectError(AccessDeniedException.class) - .verify(); - } - - @Test - void shouldPassBackofficeRoleCheck() { - // Given - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .backofficeRoles(Set.of("admin", "support")) - .build(); - - // When - Mono result = controller.requireBackofficeRole(context, "admin"); - - // Then - StepVerifier.create(result) - .verifyComplete(); - } - - @Test - void shouldFailBackofficeRoleCheck() { - // Given - BackofficeContext context = BackofficeContext.builder() - .backofficeUserId(UUID.randomUUID()) - .impersonatedPartyId(UUID.randomUUID()) - .backofficeRoles(Set.of("support")) - .build(); - - // When - Mono result = controller.requireBackofficeRole(context, "admin"); - - // Then - StepVerifier.create(result) - .expectError(AccessDeniedException.class) - .verify(); - } - - private ServerWebExchange createExchange() { - MockServerHttpRequest request = MockServerHttpRequest - .get("/test") - .header(HttpHeaders.AUTHORIZATION, "Bearer test-token") - .build(); - return MockServerWebExchange.from(request); - } - - // Test implementation of AbstractBackofficeResourceController - static class TestBackofficeResourceController extends AbstractBackofficeResourceController { - - // Expose protected methods for testing - @Override - public Mono resolveBackofficeContext( - ServerWebExchange exchange, UUID partyId, UUID contractId, UUID productId) { - return super.resolveBackofficeContext(exchange, partyId, contractId, productId); - } - - @Override - public Mono validatePartyId(BackofficeContext context, UUID expectedPartyId) { - return super.validatePartyId(context, expectedPartyId); - } - - @Override - public void logImpersonationOperation(BackofficeContext context, String operation) { - super.logImpersonationOperation(context, operation); - } - - @Override - public Mono requireContext(BackofficeContext context, boolean requireContract, boolean requireProduct) { - return super.requireContext(context, requireContract, requireProduct); - } - - @Override - public Mono requireBackofficePermission(BackofficeContext context, String permission) { - return super.requireBackofficePermission(context, permission); - } - - @Override - public Mono requireBackofficeRole(BackofficeContext context, String role) { - return super.requireBackofficeRole(context, role); - } - - // Setter for injecting mock in tests - public void setContextResolver(BackofficeContextResolver resolver) { - // Use reflection to set the private field - try { - var field = AbstractBackofficeResourceController.class.getDeclaredField("contextResolver"); - field.setAccessible(true); - field.set(this, resolver); - } catch (Exception e) { - throw new RuntimeException(e); - } + /** Concrete subclass exposing the protected base methods for testing (same package). */ + static class TestController extends AbstractBackofficeResourceController { + TestController(BackofficeContextResolver resolver) { + super(resolver); } } } diff --git a/src/test/java/org/fireflyframework/common/backoffice/util/BackofficeSessionContextMapperTest.java b/src/test/java/org/fireflyframework/common/backoffice/util/BackofficeSessionContextMapperTest.java deleted file mode 100644 index d5648c9..0000000 --- a/src/test/java/org/fireflyframework/common/backoffice/util/BackofficeSessionContextMapperTest.java +++ /dev/null @@ -1,149 +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.backoffice.util; - -import org.fireflyframework.common.application.spi.SessionContext; -import org.junit.jupiter.api.Test; - -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.*; - -class BackofficeSessionContextMapperTest { - - @Test - void shouldHandleNullSessionContextForRoles() { - Set roles = BackofficeSessionContextMapper.extractBackofficeRoles(null); - - assertNotNull(roles); - assertTrue(roles.isEmpty()); - } - - @Test - void shouldHandleNullSessionContextForPermissions() { - Set permissions = BackofficeSessionContextMapper.extractBackofficePermissions(null); - - assertNotNull(permissions); - assertTrue(permissions.isEmpty()); - } - - @Test - void shouldReturnFalseForNullSessionInRoleCheck() { - assertFalse(BackofficeSessionContextMapper.hasBackofficeRole(null, "admin")); - } - - @Test - void shouldReturnFalseForNullRoleInRoleCheck() { - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.hasBackofficeRole(session, null)); - } - - @Test - void shouldReturnFalseForNullSessionInPermissionCheck() { - assertFalse(BackofficeSessionContextMapper.hasBackofficePermission(null, "customers", "read")); - } - - @Test - void shouldReturnFalseForNullResourceInPermissionCheck() { - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.hasBackofficePermission(session, null, "read")); - } - - @Test - void shouldReturnFalseForNullActionInPermissionCheck() { - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.hasBackofficePermission(session, "customers", null)); - } - - @Test - void shouldReturnFalseForNullSessionInAnyRoleCheck() { - assertFalse(BackofficeSessionContextMapper.hasAnyBackofficeRole(null, "admin", "support")); - } - - @Test - void shouldReturnFalseForNullRolesInAnyRoleCheck() { - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.hasAnyBackofficeRole(session, (String[]) null)); - } - - @Test - void shouldReturnFalseForEmptyRolesInAnyRoleCheck() { - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.hasAnyBackofficeRole(session)); - } - - @Test - void shouldReturnFalseForNullSessionInAllRolesCheck() { - assertFalse(BackofficeSessionContextMapper.hasAllBackofficeRoles(null, "admin", "support")); - } - - @Test - void shouldReturnFalseForNullRolesInAllRolesCheck() { - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.hasAllBackofficeRoles(session, (String[]) null)); - } - - @Test - void shouldReturnFalseForEmptyRolesInAllRolesCheck() { - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.hasAllBackofficeRoles(session)); - } - - @Test - void shouldCheckIsAdminCorrectly() { - assertFalse(BackofficeSessionContextMapper.isAdmin(null)); - - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.isAdmin(session)); - } - - @Test - void shouldCheckCanReadCustomersCorrectly() { - assertFalse(BackofficeSessionContextMapper.canReadCustomers(null)); - - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.canReadCustomers(session)); - } - - @Test - void shouldCheckCanWriteCustomersCorrectly() { - assertFalse(BackofficeSessionContextMapper.canWriteCustomers(null)); - - SessionContext session = SessionContext.builder().build(); - assertFalse(BackofficeSessionContextMapper.canWriteCustomers(session)); - } - - @Test - void shouldExtractEmptyRolesForEmptySession() { - SessionContext session = SessionContext.builder().build(); - - Set roles = BackofficeSessionContextMapper.extractBackofficeRoles(session); - - assertNotNull(roles); - assertTrue(roles.isEmpty()); - } - - @Test - void shouldExtractEmptyPermissionsForEmptySession() { - SessionContext session = SessionContext.builder().build(); - - Set permissions = BackofficeSessionContextMapper.extractBackofficePermissions(session); - - assertNotNull(permissions); - assertTrue(permissions.isEmpty()); - } -}