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