From f1949004ea9cf8c56241fbd68267ff22a6a86f5f Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 13 Aug 2026 14:42:46 +0400 Subject: [PATCH 01/12] docs(AF-621): specify SCIM 2.0 provisioning and admin config endpoints --- docs/04-api-spec.md | 156 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index 72fdfbe7..a6c4534f 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -2492,6 +2492,11 @@ on the CSV export with `row_count` / `truncated` / the applied filters in its me | `PUT` | `/admin/ai-config` | Update AI provider, model, API key *(ADMIN only)* | | `GET` | `/admin/saml-config` | Get SAML configuration *(ADMIN only)* | | `PUT` | `/admin/saml-config` | Update SAML configuration *(ADMIN only)* | +| `GET` | `/admin/scim-config` | Get SCIM provisioning configuration (#621) *(ADMIN only)* | +| `PUT` | `/admin/scim-config` | Update SCIM provisioning configuration *(ADMIN only)* | +| `GET` | `/admin/scim/tokens` | List SCIM bearer tokens — prefixes only, never the raw token *(ADMIN only)* | +| `POST` | `/admin/scim/tokens` | Create a SCIM bearer token; the raw value is returned exactly once *(ADMIN only)* | +| `DELETE` | `/admin/scim/tokens/{id}` | Revoke a SCIM bearer token *(ADMIN only)* | | `GET` | `/admin/slack-app-config` | Get the Slack app configuration; `404` when unconfigured *(ADMIN only)* | | `PUT` | `/admin/slack-app-config` | Create or update the Slack app configuration *(ADMIN only)* | | `DELETE` | `/admin/slack-app-config` | Delete the Slack app configuration *(ADMIN only)* | @@ -4057,6 +4062,92 @@ Validation: free-text fields ≤ 1024 chars (idp/sp/acs/slo URLs and entity IDs) **Response 200:** Updated configuration (same shape as GET, `signing_cert_pem` replaced with `"********"` if set). **Response 400:** Validation error. +### SCIM Configuration (`/admin/scim-config`, `/admin/scim/tokens`) *(ADMIN only)* (#621) + +Per-organization SCIM 2.0 provisioning settings (singleton row) plus the long-lived bearer tokens +identity providers use against the [SCIM provisioning endpoints](#scim-20-provisioning-endpoints-621). +Gated by `SSO_CONFIGURE`. + +#### GET /admin/scim-config + +Returns the org's configuration, or an all-default view (disabled, default mappings) when none exists. + +**Response 200:** +```json +{ + "id": "uuid", + "organization_id": "uuid", + "enabled": false, + "attr_email": "userName", + "attr_display_name": "displayName", + "default_role": "ANALYST", + "created_at": "2026-08-13T10:00:00Z", + "updated_at": "2026-08-13T10:00:00Z" +} +``` + +`attr_email` is the SCIM attribute the user's email is read from (`userName` or `emails.primary`); +`attr_display_name` is the SCIM attribute the display name is read from (`displayName`, +`name.formatted`, or `userName`). `default_role` is the system role assigned to SCIM-provisioned +users (mirrors `saml_config.default_role` / `oauth2_config.default_role`). + +#### PUT /admin/scim-config + +Partial update / upsert. Any omitted field is left unchanged. + +**Request body:** `{ "enabled": true, "attr_email": "userName", "attr_display_name": "displayName", "default_role": "ANALYST" }` + +Validation: `attr_email` ∈ {`userName`, `emails.primary`}; `attr_display_name` ∈ {`displayName`, +`name.formatted`, `userName`}; `default_role` a system-role enum name. + +**Response 200:** Updated configuration (same shape as GET). Emits a `SCIM_CONFIG_UPDATED` audit row. +**Response 400:** Validation error. + +#### GET /admin/scim/tokens + +Lists the org's SCIM bearer tokens, newest first. Only the `token_prefix` is ever returned — the +raw token is not recoverable after creation. + +**Response 200:** +```json +[ + { + "id": "uuid", + "name": "okta-prod", + "token_prefix": "af_scim_AbCd", + "created_at": "2026-08-13T10:00:00Z", + "last_used_at": "2026-08-13T11:30:00Z", + "revoked_at": null + } +] +``` + +#### POST /admin/scim/tokens + +Creates a named bearer token. The response is the **only** time the raw token is returned; only a +SHA-256 hash is persisted. + +**Request body:** `{ "name": "okta-prod" }` — 1–100 chars, unique per organization. + +**Response 201:** +```json +{ + "token": { "id": "uuid", "name": "okta-prod", "token_prefix": "af_scim_AbCd", "created_at": "…", "last_used_at": null, "revoked_at": null }, + "raw_token": "af_scim_AbCd…" +} +``` + +**Response 409:** A token with this name already exists. `error: SCIM_TOKEN_NAME_CONFLICT`. + +Emits a `SCIM_TOKEN_CREATED` audit row. + +#### DELETE /admin/scim/tokens/{id} + +Revokes the token (idempotent; revoked tokens fail SCIM authentication immediately). + +**Response 204.** **Response 404:** unknown token id (`error: SCIM_TOKEN_NOT_FOUND`). Emits a +`SCIM_TOKEN_REVOKED` audit row. + ### Langfuse Configuration (`/admin/langfuse-config`) *(ADMIN only)* Per-organization Langfuse integration settings (singleton row). Drives analyzer **tracing** and **prompt management** — see [docs/05-backend.md → "Langfuse integration"](05-backend.md#langfuse-integration). @@ -4525,6 +4616,69 @@ count). A breach is rejected with `409 Conflict` and a localized `detail` naming --- +## SCIM 2.0 Provisioning Endpoints (#621) + +The SCIM 2.0 service-provider surface identity providers (Okta, Microsoft Entra ID, Keycloak, +OneLogin) call to drive user and group lifecycle. **Not under `/api/v1`** — SCIM mandates its own +base path and PascalCase resource names (RFC 7644), a deliberate exception to the kebab-case rule. + +- **Base path:** `/scim/v2` +- **Authentication:** `Authorization: Bearer ` with a per-organization SCIM token created via + [`POST /admin/scim/tokens`](#post-adminscimtokens). Never a JWT. The org is derived from the + token; requests are rejected with 401 when the token is unknown/revoked, when the org's SCIM + config is disabled, or when the organization itself is disabled. +- **Media types:** `application/scim+json` and `application/json` are both accepted and produced. +- **Errors:** the SCIM error envelope — **not** RFC 9457 `ProblemDetail`: + +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "400", + "scimType": "invalidFilter", + "detail": "Unsupported filter expression" +} +``` + +`scimType` values used: `invalidFilter`, `uniqueness`, `invalidPath`, `invalidValue`. SCIM error +`detail` strings are intentionally not localized (the consumer is an IdP provisioning engine). + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/scim/v2/ServiceProviderConfig` | Capability discovery (patch supported, filter max 200, no bulk/sort/etag) | +| `GET` | `/scim/v2/ResourceTypes` | Resource-type discovery (User, Group) | +| `GET` | `/scim/v2/Schemas` | Schema discovery | +| `GET` | `/scim/v2/Users` | List/filter users — `filter=userName eq "…"`, `externalId eq "…"`, `emails eq "…"`; `startIndex` (1-based), `count` (max 200) | +| `POST` | `/scim/v2/Users` | Provision a user (201; 409 `uniqueness` when the email exists; 403 when the org user quota is exceeded) | +| `GET` | `/scim/v2/Users/{id}` | Fetch a user (404 when unknown in this org) | +| `PUT` | `/scim/v2/Users/{id}` | Replace SCIM-owned attributes (mapped email, display name, `externalId`, `active`) | +| `PATCH` | `/scim/v2/Users/{id}` | PatchOp — `replace`/`add` on `active`, `displayName`, `externalId`, the mapped email attribute | +| `DELETE` | `/scim/v2/Users/{id}` | Deactivates the user (AccessFlow never hard-deletes users) — 204 | +| `GET` | `/scim/v2/Groups` | List/filter groups — `filter=displayName eq "…"`, `externalId eq "…"` | +| `POST` | `/scim/v2/Groups` | Create a group (409 `uniqueness` on duplicate name/externalId) | +| `GET` | `/scim/v2/Groups/{id}` | Fetch a group with members | +| `PUT` | `/scim/v2/Groups/{id}` | Replace `displayName`, `externalId`, and SCIM-sourced members | +| `PATCH` | `/scim/v2/Groups/{id}` | `add`/`remove`/`replace` on `members` (including the `members[value eq "…"]` path form Entra sends) and `replace` on `displayName` | +| `DELETE` | `/scim/v2/Groups/{id}` | Delete the group — cascades memberships **and group-based grants** — 204 | + +Semantics worth knowing (full detail in [docs/07-security.md → SCIM 2.0 provisioning](07-security.md)): + +- **List responses** use the `urn:ietf:params:scim:api:messages:2.0:ListResponse` envelope with + `totalResults`, `startIndex`, `itemsPerPage`, `Resources`. +- **`userName`** echoes the configured email-source attribute (default: the user's email). `id` is + the AccessFlow user UUID. `meta.created`/`meta.lastModified` map to `created_at`/`updated_at`; + `meta.location` is the absolute resource URL. +- **User responses never contain password data**, and SCIM can never write `role`, `platform_admin`, + TOTP settings, or row-security `attributes` — only the mapped email, display name, `externalId`, + `active`, and group memberships. +- **`active: false`** (via PUT, PATCH, or DELETE) deactivates the user: login is disabled, refresh + tokens are revoked, and active JIT access grants are revoked. Deactivating an already-inactive + user is an idempotent no-op. +- **Group memberships written by SCIM are tracked with `source=SCIM`** — they coexist with + memberships an admin created manually (`MANUAL`) and with SSO-login group mapping (`IDP`); SCIM + member ops never touch the other two sources, and vice versa. +- **Provisioned users** are created with `auth_provider=SCIM`, no password, and the configured + `default_role`; they sign in through the org's SAML/OIDC SSO. + ## Slack Integration Endpoints (AF-362) Upgrades the one-way `SLACK` notification channel to a Slack **app** with interactive Approve / Reject buttons. See [docs/08-notifications.md → Slack app](08-notifications.md#slack-app-interactive-approve--reject--af-362) and [docs/07-security.md → Slack request verification](07-security.md#slack-request-verification-af-362). @@ -5219,6 +5373,8 @@ The following codes are returned in addition to the per-endpoint codes documente | `API_REQUEST_PERMISSION_DENIED` | 403 | Caller lacks read/write/break-glass on the connector. | | `API_REQUEST_VALIDATION_ERROR` | 422 | The call failed validation against the connector schema (`reason`). | | `API_EXECUTION_FAILED` | 502 | The upstream API call could not be executed (`reason`). | +| `SCIM_TOKEN_NOT_FOUND` | 404 | Unknown SCIM token id for the caller's organization (#621). | +| `SCIM_TOKEN_NAME_CONFLICT` | 409 | A SCIM token with that name already exists in the org (#621). | | Code | HTTP | Source | Notes | |------|------|--------|-------| From 8822a8bd74b4d1ed85eb57d866de086ae998d90d Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 13 Aug 2026 14:48:32 +0400 Subject: [PATCH 02/12] feat(AF-621): unify user-deactivation side-effects via UserDeactivatedEvent Deactivation (admin update, admin delete, and soon SCIM) now publishes core.events.UserDeactivatedEvent on the true->false transition. The security module revokes all refresh tokens (moved out of the controller), and the access module revokes the user's APPROVED JIT grants through the existing revocation path. Fixes the gap where PUT /admin/users/{id} active=false revoked nothing. --- .../UserDeactivationGrantRevoker.java | 45 +++++++++++ .../repo/AccessGrantRequestRepository.java | 5 ++ .../core/events/UserDeactivatedEvent.java | 14 ++++ .../core/internal/UserAdminServiceImpl.java | 13 +++- .../internal/UserDeactivationListener.java | 27 +++++++ .../internal/web/AdminUserController.java | 4 +- .../UserDeactivationGrantRevokerTest.java | 74 +++++++++++++++++++ .../internal/UserAdminServiceImplTest.java | 61 ++++++++++++++- .../UserDeactivationListenerTest.java | 28 +++++++ .../AdminUserControllerIntegrationTest.java | 6 +- 10 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevoker.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/events/UserDeactivatedEvent.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/security/internal/UserDeactivationListener.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevokerTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/security/internal/UserDeactivationListenerTest.java diff --git a/backend/src/main/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevoker.java b/backend/src/main/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevoker.java new file mode 100644 index 00000000..d9a0735f --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevoker.java @@ -0,0 +1,45 @@ +package com.bablsoft.accessflow.access.internal; + +import com.bablsoft.accessflow.access.api.AccessGrantStatus; +import com.bablsoft.accessflow.access.internal.persistence.repo.AccessGrantRequestRepository; +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Component; + +/** + * Revokes every APPROVED JIT access grant of a user the moment they are deactivated, so standing + * database/API access disappears together with login. Reuses the ordinary revocation path + * ({@link AccessGrantRequestStateService#revoke}), system-attributed ({@code revokedByUserId=null}). + * + *

Per-row failures are swallowed so one broken grant cannot block the rest of the fan-out; + * {@code revoke} itself is idempotent (non-APPROVED rows are a no-op) and tolerates permissions + * that were already removed out-of-band. + */ +@Component +@RequiredArgsConstructor +@Slf4j +class UserDeactivationGrantRevoker { + + private final AccessGrantRequestRepository requestRepository; + private final AccessGrantRequestStateService stateService; + + @ApplicationModuleListener + void onUserDeactivated(UserDeactivatedEvent event) { + var grantIds = requestRepository.findIdsByRequesterIdAndStatus( + event.userId(), AccessGrantStatus.APPROVED); + for (var grantId : grantIds) { + try { + stateService.revoke(grantId, null); + } catch (RuntimeException ex) { + log.error("Failed to revoke access grant {} for deactivated user {}", + grantId, event.userId(), ex); + } + } + if (!grantIds.isEmpty()) { + log.info("Revoked {} active access grant(s) for deactivated user {}", + grantIds.size(), event.userId()); + } + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/access/internal/persistence/repo/AccessGrantRequestRepository.java b/backend/src/main/java/com/bablsoft/accessflow/access/internal/persistence/repo/AccessGrantRequestRepository.java index 0516903d..65081824 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/access/internal/persistence/repo/AccessGrantRequestRepository.java +++ b/backend/src/main/java/com/bablsoft/accessflow/access/internal/persistence/repo/AccessGrantRequestRepository.java @@ -35,6 +35,11 @@ List findAllByOrganizationIdAndStatusOrderByCreatedAtA List findIdsByStatusAndExpiresAtBefore(@Param("status") AccessGrantStatus status, @Param("now") Instant now); + @Query("select a.id from AccessGrantRequestEntity a " + + "where a.requesterId = :requesterId and a.status = :status") + List findIdsByRequesterIdAndStatus(@Param("requesterId") UUID requesterId, + @Param("status") AccessGrantStatus status); + List findAllByOrganizationIdAndRequesterIdAndDatasourceIdAndStatusAndPreApproveQueriesTrueAndExpiresAtAfter( UUID organizationId, UUID requesterId, UUID datasourceId, diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/events/UserDeactivatedEvent.java b/backend/src/main/java/com/bablsoft/accessflow/core/events/UserDeactivatedEvent.java new file mode 100644 index 00000000..03183ac0 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/events/UserDeactivatedEvent.java @@ -0,0 +1,14 @@ +package com.bablsoft.accessflow.core.events; + +import java.util.UUID; + +/** + * Published when a user's {@code active} flag transitions {@code true -> false}, regardless of the + * initiating path (admin API update/delete, SCIM deprovisioning). Not published when deactivating + * an already-inactive user. + * + *

Consumers own the deactivation side-effects: the security module revokes all refresh tokens, + * the access module revokes the user's active JIT grants. + */ +public record UserDeactivatedEvent(UUID userId, UUID organizationId) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImpl.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImpl.java index a8aeacb6..100f1fdd 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImpl.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImpl.java @@ -20,8 +20,10 @@ import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; import com.bablsoft.accessflow.core.internal.persistence.repo.RolePermissionRepository; import com.bablsoft.accessflow.core.internal.persistence.repo.RoleRepository; +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import tools.jackson.core.type.TypeReference; @@ -45,6 +47,7 @@ class UserAdminServiceImpl implements UserAdminService { private final RolePermissionRepository rolePermissionRepository; private final QuotaService quotaService; private final ObjectMapper objectMapper; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional(readOnly = true) @@ -99,7 +102,12 @@ public UserView updateUser(UUID id, UUID organizationId, UUID currentUserId, applyRole(entity, organizationId, command.role(), command.roleId()); } if (command.active() != null) { + var wasActive = entity.isActive(); entity.setActive(command.active()); + if (wasActive && !command.active()) { + eventPublisher.publishEvent( + new UserDeactivatedEvent(entity.getId(), organizationId)); + } } if (command.displayName() != null) { entity.setDisplayName(command.displayName()); @@ -124,7 +132,10 @@ public UserView deactivateUser(UUID id, UUID organizationId, UUID currentUserId) "Admin users cannot deactivate themselves"); } var entity = loadInOrganization(id, organizationId); - entity.setActive(false); + if (entity.isActive()) { + entity.setActive(false); + eventPublisher.publishEvent(new UserDeactivatedEvent(entity.getId(), organizationId)); + } return toView(entity); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/UserDeactivationListener.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/UserDeactivationListener.java new file mode 100644 index 00000000..a2793d65 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/UserDeactivationListener.java @@ -0,0 +1,27 @@ +package com.bablsoft.accessflow.security.internal; + +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; +import com.bablsoft.accessflow.security.internal.token.RefreshTokenStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Component; + +/** + * Revokes every refresh token of a user the moment they are deactivated — whatever the initiating + * path (admin update, admin delete, SCIM deprovisioning). The user's outstanding access tokens + * expire naturally within {@code ACCESSFLOW_JWT_ACCESS_TOKEN_EXPIRY} (default 15 minutes). + */ +@Component +@RequiredArgsConstructor +@Slf4j +class UserDeactivationListener { + + private final RefreshTokenStore refreshTokenStore; + + @ApplicationModuleListener + void onUserDeactivated(UserDeactivatedEvent event) { + refreshTokenStore.revokeAllForUser(event.userId().toString()); + log.info("Revoked all refresh tokens for deactivated user {}", event.userId()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/AdminUserController.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/AdminUserController.java index 62b5f4d3..2f6b3cd3 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/AdminUserController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/AdminUserController.java @@ -9,7 +9,6 @@ import com.bablsoft.accessflow.core.api.UpdateUserCommand; import com.bablsoft.accessflow.core.api.UserAdminService; import com.bablsoft.accessflow.security.api.JwtClaims; -import com.bablsoft.accessflow.security.internal.token.RefreshTokenStore; import com.bablsoft.accessflow.security.internal.web.model.AdminUserResponse; import com.bablsoft.accessflow.security.internal.web.model.CreateUserRequest; import com.bablsoft.accessflow.security.internal.web.model.UpdateUserRequest; @@ -51,7 +50,6 @@ class AdminUserController { private final UserAdminService userAdminService; private final PasswordEncoder passwordEncoder; - private final RefreshTokenStore refreshTokenStore; private final AuditLogService auditLogService; @GetMapping @@ -130,8 +128,8 @@ UserAttributesResponse getUserAttributes(@PathVariable UUID id, Authentication a ResponseEntity deactivateUser(@PathVariable UUID id, Authentication authentication, RequestAuditContext auditContext) { var caller = currentClaims(authentication); + // Refresh-token + JIT-grant revocation fan out from UserDeactivatedEvent listeners. userAdminService.deactivateUser(id, caller.organizationId(), caller.userId()); - refreshTokenStore.revokeAllForUser(id.toString()); recordAudit(AuditAction.USER_DEACTIVATED, id, caller, auditContext, Map.of()); return ResponseEntity.noContent().build(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevokerTest.java b/backend/src/test/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevokerTest.java new file mode 100644 index 00000000..cc53cb82 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevokerTest.java @@ -0,0 +1,74 @@ +package com.bablsoft.accessflow.access.internal; + +import com.bablsoft.accessflow.access.api.AccessGrantStatus; +import com.bablsoft.accessflow.access.internal.persistence.repo.AccessGrantRequestRepository; +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; +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.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserDeactivationGrantRevokerTest { + + @Mock AccessGrantRequestRepository requestRepository; + @Mock AccessGrantRequestStateService stateService; + + UserDeactivationGrantRevoker revoker; + + private final UUID userId = UUID.randomUUID(); + private final UUID orgId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + revoker = new UserDeactivationGrantRevoker(requestRepository, stateService); + } + + @Test + void revokesEveryApprovedGrantSystemAttributed() { + var grantA = UUID.randomUUID(); + var grantB = UUID.randomUUID(); + when(requestRepository.findIdsByRequesterIdAndStatus(userId, AccessGrantStatus.APPROVED)) + .thenReturn(List.of(grantA, grantB)); + + revoker.onUserDeactivated(new UserDeactivatedEvent(userId, orgId)); + + verify(stateService).revoke(eq(grantA), isNull()); + verify(stateService).revoke(eq(grantB), isNull()); + } + + @Test + void noGrantsMeansNoRevocations() { + when(requestRepository.findIdsByRequesterIdAndStatus(userId, AccessGrantStatus.APPROVED)) + .thenReturn(List.of()); + + revoker.onUserDeactivated(new UserDeactivatedEvent(userId, orgId)); + + verify(stateService, never()).revoke(any(), any()); + } + + @Test + void perGrantFailureDoesNotStopTheRest() { + var failing = UUID.randomUUID(); + var succeeding = UUID.randomUUID(); + when(requestRepository.findIdsByRequesterIdAndStatus(userId, AccessGrantStatus.APPROVED)) + .thenReturn(List.of(failing, succeeding)); + doThrow(new IllegalStateException("boom")).when(stateService).revoke(eq(failing), isNull()); + + revoker.onUserDeactivated(new UserDeactivatedEvent(userId, orgId)); + + verify(stateService).revoke(eq(succeeding), isNull()); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImplTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImplTest.java index cd22609b..1485917f 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImplTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImplTest.java @@ -10,6 +10,7 @@ import com.bablsoft.accessflow.core.api.UpdateUserCommand; import com.bablsoft.accessflow.core.api.UserNotFoundException; import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; @@ -21,6 +22,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageImpl; import tools.jackson.databind.ObjectMapper; @@ -45,6 +47,7 @@ class UserAdminServiceImplTest { @Mock RoleRepository roleRepository; @Mock RolePermissionRepository rolePermissionRepository; @Mock QuotaService quotaService; + @Mock ApplicationEventPublisher eventPublisher; UserAdminServiceImpl service; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -56,7 +59,7 @@ class UserAdminServiceImplTest { @BeforeEach void setUp() { service = new UserAdminServiceImpl(userRepository, organizationRepository, roleRepository, - rolePermissionRepository, quotaService, objectMapper); + rolePermissionRepository, quotaService, objectMapper, eventPublisher); // System-role row missing → the service keeps the legacy enum-column behaviour. lenient().when(roleRepository.findByNameAndSystemTrue(any())).thenReturn(Optional.empty()); } @@ -205,6 +208,62 @@ void deactivateUserSetsActiveFalse() { assertThat(result.active()).isFalse(); } + @Test + void deactivateUserPublishesDeactivatedEvent() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + service.deactivateUser(userId, orgId, adminId); + + verify(eventPublisher).publishEvent(new UserDeactivatedEvent(userId, orgId)); + } + + @Test + void deactivateUserAlreadyInactiveDoesNotPublish() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + entity.setActive(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + var result = service.deactivateUser(userId, orgId, adminId); + + assertThat(result.active()).isFalse(); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void updateUserActiveFalsePublishesDeactivatedEvent() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + service.updateUser(userId, orgId, adminId, new UpdateUserCommand(null, false, null, null)); + + verify(eventPublisher).publishEvent(new UserDeactivatedEvent(userId, orgId)); + } + + @Test + void updateUserActiveFalseOnInactiveUserDoesNotPublish() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + entity.setActive(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + service.updateUser(userId, orgId, adminId, new UpdateUserCommand(null, false, null, null)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void updateUserReactivationDoesNotPublish() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + entity.setActive(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + var result = service.updateUser(userId, orgId, adminId, + new UpdateUserCommand(null, true, null, null)); + + assertThat(result.active()).isTrue(); + verify(eventPublisher, never()).publishEvent(any()); + } + @Test void deactivateUserBlocksSelfDeactivation() { assertThatThrownBy(() -> service.deactivateUser(adminId, orgId, adminId)) diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/UserDeactivationListenerTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/UserDeactivationListenerTest.java new file mode 100644 index 00000000..97914037 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/UserDeactivationListenerTest.java @@ -0,0 +1,28 @@ +package com.bablsoft.accessflow.security.internal; + +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; +import com.bablsoft.accessflow.security.internal.token.RefreshTokenStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class UserDeactivationListenerTest { + + @Mock RefreshTokenStore refreshTokenStore; + + @Test + void revokesAllRefreshTokensForDeactivatedUser() { + var listener = new UserDeactivationListener(refreshTokenStore); + var userId = UUID.randomUUID(); + + listener.onUserDeactivated(new UserDeactivatedEvent(userId, UUID.randomUUID())); + + verify(refreshTokenStore).revokeAllForUser(userId.toString()); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/AdminUserControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/AdminUserControllerIntegrationTest.java index fc27482a..5e2267b7 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/AdminUserControllerIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/AdminUserControllerIntegrationTest.java @@ -27,10 +27,12 @@ import java.security.KeyPairGenerator; import java.security.interfaces.RSAPrivateCrtKey; +import java.time.Duration; import java.util.Base64; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; @SpringBootTest @@ -228,7 +230,9 @@ void deactivateUserReturns204AndRevokesRefreshTokens() { assertThat(result).hasStatus(204); var reloaded = userRepository.findById(analyst.getId()).orElseThrow(); assertThat(reloaded.isActive()).isFalse(); - assertThat(refreshTokenStore.isRevoked("rt-analyst")).isTrue(); + // Revocation now runs in an async UserDeactivatedEvent listener (AFTER_COMMIT). + await().atMost(Duration.ofSeconds(5)).untilAsserted( + () -> assertThat(refreshTokenStore.isRevoked("rt-analyst")).isTrue()); } @Test From 7284af1598fbd78c55803a455e6f7fa4bad4063f Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 13 Aug 2026 14:58:21 +0400 Subject: [PATCH 03/12] feat(AF-621): SCIM schema and core directory/group primitives V137-V141: scim_config + scim_tokens tables, users/user_groups scim_external_id (+ per-org partial unique indexes), users.updated_at, SCIM values on user_group_membership_source and auth_provider_type. Core: ExternalUserDirectoryService (system-actor create/update/find/ offset-list, quota + uniqueness guards, deactivation event), source- scoped group member ops for group-centric SCIM sync, and a fix so SSO-login IDP sync neither wipes nor PK-collides with SCIM-sourced memberships. --- .../accessflow/core/api/AuthProviderType.java | 4 +- .../core/api/CreateExternalUserCommand.java | 16 ++ .../core/api/CreateUserGroupCommand.java | 10 +- .../accessflow/core/api/DirectoryPage.java | 11 + .../api/ExternalIdAlreadyExistsException.java | 9 + .../api/ExternalUserDirectoryService.java | 45 ++++ .../core/api/UpdateExternalUserCommand.java | 14 + .../core/api/UpdateUserGroupCommand.java | 10 +- .../core/api/UserAdminException.java | 3 +- .../api/UserGroupMembershipSourceType.java | 4 +- .../accessflow/core/api/UserGroupService.java | 26 +- .../accessflow/core/api/UserGroupView.java | 11 +- .../accessflow/core/api/UserView.java | 18 +- .../DefaultExternalUserDirectoryService.java | 161 ++++++++++++ .../internal/DefaultUserGroupService.java | 98 ++++++- .../core/internal/OffsetPageable.java | 72 +++++ .../accessflow/core/internal/UserViews.java | 4 +- .../persistence/entity/UserEntity.java | 14 + .../persistence/entity/UserGroupEntity.java | 4 + .../entity/UserGroupMembershipSource.java | 3 +- .../persistence/repo/UserGroupRepository.java | 3 + .../persistence/repo/UserRepository.java | 9 + .../db/migration/V137__create_scim_config.sql | 15 ++ .../db/migration/V138__create_scim_tokens.sql | 19 ++ .../db/migration/V139__add_scim_columns.sql | 13 + .../V140__add_scim_membership_source.sql | 3 + .../V140__add_scim_membership_source.sql.conf | 1 + .../V141__add_scim_auth_provider.sql | 4 + .../V141__add_scim_auth_provider.sql.conf | 1 + ...faultExternalUserDirectoryServiceTest.java | 246 ++++++++++++++++++ .../internal/DefaultUserGroupServiceTest.java | 142 ++++++++++ .../core/internal/OffsetPageableTest.java | 45 ++++ 32 files changed, 1019 insertions(+), 19 deletions(-) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/CreateExternalUserCommand.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/DirectoryPage.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/ExternalIdAlreadyExistsException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/ExternalUserDirectoryService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateExternalUserCommand.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/core/internal/OffsetPageable.java create mode 100644 backend/src/main/resources/db/migration/V137__create_scim_config.sql create mode 100644 backend/src/main/resources/db/migration/V138__create_scim_tokens.sql create mode 100644 backend/src/main/resources/db/migration/V139__add_scim_columns.sql create mode 100644 backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql create mode 100644 backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql.conf create mode 100644 backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql create mode 100644 backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql.conf create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryServiceTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/core/internal/OffsetPageableTest.java diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/AuthProviderType.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/AuthProviderType.java index bc0f2b9f..0c4f7019 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/AuthProviderType.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/AuthProviderType.java @@ -3,5 +3,7 @@ public enum AuthProviderType { LOCAL, SAML, - OAUTH2 + OAUTH2, + /** Provisioned by an identity provider over SCIM 2.0 (#621); no password, signs in via SSO. */ + SCIM } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateExternalUserCommand.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateExternalUserCommand.java new file mode 100644 index 00000000..a5cb1753 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateExternalUserCommand.java @@ -0,0 +1,16 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.UUID; + +/** + * Create command for an externally provisioned (SCIM, #621) user: no password, provider + * {@link AuthProviderType#SCIM}, role fixed to the org's configured default system role. + */ +public record CreateExternalUserCommand( + UUID organizationId, + String email, + String displayName, + String scimExternalId, + UserRoleType defaultRole +) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateUserGroupCommand.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateUserGroupCommand.java index 1a88639c..2b70eba0 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateUserGroupCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/CreateUserGroupCommand.java @@ -5,5 +5,11 @@ public record CreateUserGroupCommand( UUID organizationId, String name, - String description -) {} + String description, + String scimExternalId +) { + /** Convenience constructor for callers that predate the SCIM external id (#621). */ + public CreateUserGroupCommand(UUID organizationId, String name, String description) { + this(organizationId, name, description, null); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/DirectoryPage.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/DirectoryPage.java new file mode 100644 index 00000000..456da7f8 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/DirectoryPage.java @@ -0,0 +1,11 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.List; + +/** + * An offset-based page (#621). Unlike {@link PageResponse}, the window starts at an arbitrary + * zero-based {@code offset} rather than a page boundary — SCIM's {@code startIndex} is 1-based + * and not required to be page-aligned. + */ +public record DirectoryPage(List content, long totalResults) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/ExternalIdAlreadyExistsException.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/ExternalIdAlreadyExistsException.java new file mode 100644 index 00000000..c1d1d170 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/ExternalIdAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.bablsoft.accessflow.core.api; + +/** Another user in the organization already carries this SCIM externalId (#621). */ +public final class ExternalIdAlreadyExistsException extends UserAdminException { + + public ExternalIdAlreadyExistsException(String externalId) { + super("User already exists with external id: " + externalId); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/ExternalUserDirectoryService.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/ExternalUserDirectoryService.java new file mode 100644 index 00000000..99a4afe9 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/ExternalUserDirectoryService.java @@ -0,0 +1,45 @@ +package com.bablsoft.accessflow.core.api; + +import java.util.Optional; +import java.util.UUID; + +/** + * System-actor user primitives for external directory provisioning (SCIM, #621). Unlike + * {@link UserAdminService} there is no acting user: the self-deactivation and self-demotion + * guards do not apply, and the caller (the scim module) is responsible for authenticating the + * organization the operations are scoped to. + * + *

Deactivation (active {@code true -> false}) publishes + * {@code core.events.UserDeactivatedEvent} exactly like the admin paths. + */ +public interface ExternalUserDirectoryService { + + /** + * Create an externally provisioned user. + * + * @throws EmailAlreadyExistsException when the email exists anywhere (emails are + * globally unique across organizations) + * @throws ExternalIdAlreadyExistsException when the externalId is taken in this org + * @throws QuotaExceededException when the org's user quota is exhausted + */ + UserView createExternal(CreateExternalUserCommand command); + + /** + * Partially update an externally managed user. Only SCIM-owned attributes are touched. + * + * @throws UserNotFoundException when the user is not in this organization + * @throws EmailAlreadyExistsException when a changed email collides globally + * @throws ExternalIdAlreadyExistsException when a changed externalId collides in this org + * @throws QuotaExceededException when reactivation would exceed the user quota + */ + UserView updateExternal(UUID organizationId, UUID userId, UpdateExternalUserCommand command); + + Optional findById(UUID organizationId, UUID userId); + + Optional findByEmail(UUID organizationId, String email); + + Optional findByExternalId(UUID organizationId, String scimExternalId); + + /** Offset-based listing ordered by creation time then id; offset is zero-based. */ + DirectoryPage list(UUID organizationId, int offset, int limit); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateExternalUserCommand.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateExternalUserCommand.java new file mode 100644 index 00000000..b8531c3b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateExternalUserCommand.java @@ -0,0 +1,14 @@ +package com.bablsoft.accessflow.core.api; + +/** + * Partial update for an externally managed (SCIM, #621) user. Null fields are left unchanged. + * Deliberately excludes everything SCIM does not own: password, role, platform_admin, TOTP, + * row-security attributes, auth provider. + */ +public record UpdateExternalUserCommand( + String email, + String displayName, + String scimExternalId, + Boolean active +) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateUserGroupCommand.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateUserGroupCommand.java index f61047cf..e2b10937 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateUserGroupCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/UpdateUserGroupCommand.java @@ -2,5 +2,11 @@ public record UpdateUserGroupCommand( String name, - String description -) {} + String description, + String scimExternalId +) { + /** Convenience constructor for callers that predate the SCIM external id (#621). */ + public UpdateUserGroupCommand(String name, String description) { + this(name, description, null); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserAdminException.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserAdminException.java index 22aca30a..665d53b1 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserAdminException.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserAdminException.java @@ -1,7 +1,8 @@ package com.bablsoft.accessflow.core.api; public sealed class UserAdminException extends RuntimeException - permits EmailAlreadyExistsException, UserNotFoundException, IllegalUserOperationException, + permits EmailAlreadyExistsException, ExternalIdAlreadyExistsException, + UserNotFoundException, IllegalUserOperationException, SetupAlreadyCompletedException { protected UserAdminException(String message) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupMembershipSourceType.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupMembershipSourceType.java index 5d355737..ad3c40bb 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupMembershipSourceType.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupMembershipSourceType.java @@ -2,5 +2,7 @@ public enum UserGroupMembershipSourceType { MANUAL, - IDP + IDP, + /** Pushed by an identity provider over SCIM 2.0 (#621). */ + SCIM } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupService.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupService.java index 72ee8ef1..82d09430 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupService.java @@ -25,9 +25,33 @@ public interface UserGroupService { void removeMember(UUID groupId, UUID userId, UUID organizationId); + /** + * Add a member with an explicit provenance (#621). Idempotent: when the user already has a + * membership row in the group — whatever its source — that row is returned untouched + * (first source wins). + */ + UserGroupMembershipView addMember(UUID groupId, UUID userId, UUID organizationId, + UserGroupMembershipSourceType source); + + /** + * Remove the member's row only when it carries the given provenance (#621). A row of another + * source, or no row at all, is a quiet no-op — SCIM must never delete MANUAL/IDP memberships. + */ + void removeMemberBySource(UUID groupId, UUID userId, UUID organizationId, + UserGroupMembershipSourceType source); + + /** + * Group-centric replace (#621): make exactly the given users the group's members of the given + * provenance. Rows of other sources are untouched; users unknown in the organization are + * skipped. Returns the user ids that now hold a row of that source in the group. + */ + Set replaceMembersBySource(UUID groupId, UUID organizationId, Collection userIds, + UserGroupMembershipSourceType source); + /** * Replace this user's IDP-sourced group memberships with exactly the given set. - * MANUAL memberships are left untouched. Returns the new IDP-sourced membership set. + * Memberships of every other source (MANUAL, SCIM) are left untouched. Returns the new + * IDP-sourced membership set. */ Set syncIdpMemberships(UUID userId, UUID organizationId, Collection groupIds); diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupView.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupView.java index 544e2b53..379f2d7f 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserGroupView.java @@ -10,5 +10,12 @@ public record UserGroupView( String description, long memberCount, Instant createdAt, - Instant updatedAt -) {} + Instant updatedAt, + String scimExternalId +) { + /** Convenience constructor for callers that predate the SCIM external id (#621). */ + public UserGroupView(UUID id, UUID organizationId, String name, String description, + long memberCount, Instant createdAt, Instant updatedAt) { + this(id, organizationId, name, description, memberCount, createdAt, updatedAt, null); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserView.java b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserView.java index e5b8e6f1..8574ae75 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/api/UserView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/api/UserView.java @@ -18,15 +18,31 @@ public record UserView( String preferredLanguage, boolean totpEnabled, boolean platformAdmin, - Instant createdAt + Instant createdAt, + String scimExternalId, + Instant updatedAt ) { /** * {@code role} is the legacy system-role enum — null for users on a custom role (AF-522). * {@code roleName} is always populated: the assigned role's name (system or custom). + * {@code scimExternalId} is the IdP-side SCIM identifier, null unless SCIM-managed (#621); + * {@code updatedAt} feeds SCIM {@code meta.lastModified} and may be null for views built by + * legacy callers. */ public UserView { } + /** Convenience constructor for callers that predate the SCIM columns (#621). */ + public UserView(UUID id, String email, String displayName, UserRoleType role, UUID roleId, + String roleName, UUID organizationId, boolean active, + AuthProviderType authProvider, String passwordHash, Instant lastLoginAt, + String preferredLanguage, boolean totpEnabled, boolean platformAdmin, + Instant createdAt) { + this(id, email, displayName, role, roleId, roleName, organizationId, active, authProvider, + passwordHash, lastLoginAt, preferredLanguage, totpEnabled, platformAdmin, + createdAt, null, createdAt); + } + /** Convenience constructor for a system-role, non-platform-admin user (tests/legacy callers). */ public UserView(UUID id, String email, String displayName, UserRoleType role, UUID organizationId, boolean active, AuthProviderType authProvider, diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryService.java new file mode 100644 index 00000000..910ddbe5 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryService.java @@ -0,0 +1,161 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.CreateExternalUserCommand; +import com.bablsoft.accessflow.core.api.DirectoryPage; +import com.bablsoft.accessflow.core.api.EmailAlreadyExistsException; +import com.bablsoft.accessflow.core.api.ExternalIdAlreadyExistsException; +import com.bablsoft.accessflow.core.api.ExternalUserDirectoryService; +import com.bablsoft.accessflow.core.api.QuotaService; +import com.bablsoft.accessflow.core.api.UpdateExternalUserCommand; +import com.bablsoft.accessflow.core.api.UserNotFoundException; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.api.UserView; +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.RoleRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +class DefaultExternalUserDirectoryService implements ExternalUserDirectoryService { + + private static final Sort STABLE_ORDER = Sort.by("createdAt", "id").ascending(); + + private final UserRepository userRepository; + private final OrganizationRepository organizationRepository; + private final RoleRepository roleRepository; + private final QuotaService quotaService; + private final ApplicationEventPublisher eventPublisher; + + @Override + @Transactional + public UserView createExternal(CreateExternalUserCommand command) { + var email = normalizeEmail(command.email()); + if (userRepository.existsByEmail(email)) { + throw new EmailAlreadyExistsException(email); + } + if (command.scimExternalId() != null) { + userRepository.findByOrganization_IdAndScimExternalId( + command.organizationId(), command.scimExternalId()) + .ifPresent(existing -> { + throw new ExternalIdAlreadyExistsException(command.scimExternalId()); + }); + } + quotaService.checkUserQuota(command.organizationId()); + + var entity = new UserEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganization(organizationRepository.getReferenceById(command.organizationId())); + entity.setEmail(email); + entity.setDisplayName(command.displayName()); + entity.setAuthProvider(AuthProviderType.SCIM); + entity.setScimExternalId(command.scimExternalId()); + entity.setActive(true); + applySystemRole(entity, command.defaultRole()); + return UserViews.toView(userRepository.save(entity)); + } + + @Override + @Transactional + public UserView updateExternal(UUID organizationId, UUID userId, + UpdateExternalUserCommand command) { + var entity = userRepository.findByOrganization_IdAndId(organizationId, userId) + .orElseThrow(() -> new UserNotFoundException(userId)); + + if (command.email() != null) { + var email = normalizeEmail(command.email()); + if (!email.equals(entity.getEmail()) && userRepository.existsByEmail(email)) { + throw new EmailAlreadyExistsException(email); + } + entity.setEmail(email); + } + if (command.displayName() != null) { + entity.setDisplayName(command.displayName()); + } + if (command.scimExternalId() != null + && !command.scimExternalId().equals(entity.getScimExternalId())) { + userRepository.findByOrganization_IdAndScimExternalId( + organizationId, command.scimExternalId()) + .filter(other -> !other.getId().equals(userId)) + .ifPresent(other -> { + throw new ExternalIdAlreadyExistsException(command.scimExternalId()); + }); + entity.setScimExternalId(command.scimExternalId()); + } + if (command.active() != null) { + applyActive(entity, organizationId, command.active()); + } + return UserViews.toView(entity); + } + + @Override + @Transactional(readOnly = true) + public Optional findById(UUID organizationId, UUID userId) { + return userRepository.findByOrganization_IdAndId(organizationId, userId) + .map(UserViews::toView); + } + + @Override + @Transactional(readOnly = true) + public Optional findByEmail(UUID organizationId, String email) { + return userRepository.findByOrganization_IdAndEmail(organizationId, normalizeEmail(email)) + .map(UserViews::toView); + } + + @Override + @Transactional(readOnly = true) + public Optional findByExternalId(UUID organizationId, String scimExternalId) { + return userRepository.findByOrganization_IdAndScimExternalId(organizationId, scimExternalId) + .map(UserViews::toView); + } + + @Override + @Transactional(readOnly = true) + public DirectoryPage list(UUID organizationId, int offset, int limit) { + var page = userRepository.findAllByOrganization_Id( + organizationId, new OffsetPageable(offset, limit, STABLE_ORDER)); + return new DirectoryPage<>( + page.getContent().stream().map(UserViews::toView).toList(), + page.getTotalElements()); + } + + private void applyActive(UserEntity entity, UUID organizationId, boolean active) { + if (entity.isActive() == active) { + return; + } + if (active) { + // Reactivation counts against the org's user quota, exactly like a create. + quotaService.checkUserQuota(organizationId); + entity.setActive(true); + return; + } + entity.setActive(false); + eventPublisher.publishEvent(new UserDeactivatedEvent(entity.getId(), organizationId)); + } + + /** + * Mirrors {@code UserAdminServiceImpl.applyRole}'s system-role path: link the system-role row + * and keep the legacy enum column in sync; fall back to the enum alone when the row is missing + * (pre-V114 data mid-deploy). + */ + private void applySystemRole(UserEntity entity, UserRoleType role) { + var roleRef = roleRepository.findByNameAndSystemTrue(role.name()).orElse(null); + entity.setRoleRef(roleRef); + entity.setRole(role); + } + + private static String normalizeEmail(String email) { + return email == null ? null : email.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupService.java index c022d046..244b4f2b 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupService.java @@ -93,6 +93,7 @@ public UserGroupView createGroup(CreateUserGroupCommand command) { entity.setOrganization(organization); entity.setName(normalizedName); entity.setDescription(command.description()); + entity.setScimExternalId(command.scimExternalId()); entity.setCreatedAt(Instant.now()); entity.setUpdatedAt(entity.getCreatedAt()); return toView(userGroupRepository.save(entity), 0L); @@ -117,6 +118,10 @@ public UserGroupView updateGroup(UUID groupId, UUID organizationId, if (command.description() != null) { entity.setDescription(command.description()); } + if (command.scimExternalId() != null) { + entity.setScimExternalId( + command.scimExternalId().isBlank() ? null : command.scimExternalId()); + } entity.setUpdatedAt(Instant.now()); return toView(entity, membershipRepository.countByGroup_Id(groupId)); } @@ -144,6 +149,13 @@ public List listMembers(UUID groupId, UUID organization @Override @Transactional public UserGroupMembershipView addMember(UUID groupId, UUID userId, UUID organizationId) { + return addMember(groupId, userId, organizationId, UserGroupMembershipSourceType.MANUAL); + } + + @Override + @Transactional + public UserGroupMembershipView addMember(UUID groupId, UUID userId, UUID organizationId, + UserGroupMembershipSourceType source) { var group = loadInOrganization(groupId, organizationId); var user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException(userId)); @@ -161,7 +173,7 @@ public UserGroupMembershipView addMember(UUID groupId, UUID userId, UUID organiz membership.setId(new UserGroupMembershipEntity.Id(userId, groupId)); membership.setUser(user); membership.setGroup(group); - membership.setSource(UserGroupMembershipSource.MANUAL); + membership.setSource(toEntitySource(source)); membership.setJoinedAt(Instant.now()); return toMembershipView(membershipRepository.save(membership)); } @@ -176,6 +188,68 @@ public void removeMember(UUID groupId, UUID userId, UUID organizationId) { membershipRepository.deleteByUserIdAndGroupId(userId, groupId); } + @Override + @Transactional + public void removeMemberBySource(UUID groupId, UUID userId, UUID organizationId, + UserGroupMembershipSourceType source) { + loadInOrganization(groupId, organizationId); + membershipRepository.findAllByGroup_Id(groupId).stream() + .filter(m -> m.getUser().getId().equals(userId)) + .filter(m -> m.getSource() == toEntitySource(source)) + .findFirst() + .ifPresent(membershipRepository::delete); + } + + @Override + @Transactional + public Set replaceMembersBySource(UUID groupId, UUID organizationId, + Collection userIds, + UserGroupMembershipSourceType source) { + var group = loadInOrganization(groupId, organizationId); + var entitySource = toEntitySource(source); + var desired = userIds == null ? Set.of() : new LinkedHashSet<>(userIds); + var existing = membershipRepository.findAllByGroup_Id(groupId); + var existingBySource = existing.stream() + .filter(m -> m.getSource() == entitySource) + .collect(Collectors.toMap(m -> m.getUser().getId(), m -> m)); + var otherSourceUserIds = existing.stream() + .filter(m -> m.getSource() != entitySource) + .map(m -> m.getUser().getId()) + .collect(Collectors.toSet()); + + for (var entry : existingBySource.entrySet()) { + if (!desired.contains(entry.getKey())) { + membershipRepository.delete(entry.getValue()); + } + } + + var result = new HashSet(); + for (UUID userId : desired) { + if (existingBySource.containsKey(userId)) { + result.add(userId); + continue; + } + // A row of another source already makes the user a member — first source wins. + if (otherSourceUserIds.contains(userId)) { + continue; + } + var user = userRepository.findByOrganization_IdAndId(organizationId, userId) + .orElse(null); + if (user == null) { + continue; + } + var membership = new UserGroupMembershipEntity(); + membership.setId(new UserGroupMembershipEntity.Id(userId, groupId)); + membership.setUser(user); + membership.setGroup(group); + membership.setSource(entitySource); + membership.setJoinedAt(Instant.now()); + membershipRepository.save(membership); + result.add(userId); + } + return result; + } + @Override @Transactional public Set syncIdpMemberships(UUID userId, UUID organizationId, @@ -185,8 +259,10 @@ public Set syncIdpMemberships(UUID userId, UUID organizationId, var idpExistingByGroup = existing.stream() .filter(m -> m.getSource() == UserGroupMembershipSource.IDP) .collect(Collectors.toMap(m -> m.getGroup().getId(), m -> m)); - var manualGroupIds = existing.stream() - .filter(m -> m.getSource() == UserGroupMembershipSource.MANUAL) + // Everything not IDP-sourced (MANUAL, SCIM) is out of this sync's ownership: never + // removed, and never duplicated with an IDP row (#621). + var nonIdpGroupIds = existing.stream() + .filter(m -> m.getSource() != UserGroupMembershipSource.IDP) .map(m -> m.getGroup().getId()) .collect(Collectors.toSet()); @@ -197,9 +273,9 @@ public Set syncIdpMemberships(UUID userId, UUID organizationId, } } - // Add new desired rows (skip already-manual rows — a manual membership wins). + // Add new desired rows (skip rows another source owns — that membership wins). for (UUID groupId : desired) { - if (manualGroupIds.contains(groupId) || idpExistingByGroup.containsKey(groupId)) { + if (nonIdpGroupIds.contains(groupId) || idpExistingByGroup.containsKey(groupId)) { continue; } var group = userGroupRepository.findById(groupId).orElse(null); @@ -252,7 +328,8 @@ private UserGroupView toView(UserGroupEntity entity, long memberCount) { entity.getDescription(), memberCount, entity.getCreatedAt(), - entity.getUpdatedAt()); + entity.getUpdatedAt(), + entity.getScimExternalId()); } private static UserGroupMembershipView toMembershipView(UserGroupMembershipEntity entity) { @@ -270,6 +347,15 @@ private static UserGroupMembershipSourceType mapSource(UserGroupMembershipSource return switch (source) { case MANUAL -> UserGroupMembershipSourceType.MANUAL; case IDP -> UserGroupMembershipSourceType.IDP; + case SCIM -> UserGroupMembershipSourceType.SCIM; + }; + } + + private static UserGroupMembershipSource toEntitySource(UserGroupMembershipSourceType source) { + return switch (source) { + case MANUAL -> UserGroupMembershipSource.MANUAL; + case IDP -> UserGroupMembershipSource.IDP; + case SCIM -> UserGroupMembershipSource.SCIM; }; } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/OffsetPageable.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/OffsetPageable.java new file mode 100644 index 00000000..9ee774a7 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/OffsetPageable.java @@ -0,0 +1,72 @@ +package com.bablsoft.accessflow.core.internal; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * A {@link Pageable} whose window starts at an arbitrary zero-based offset instead of a page + * boundary (#621) — SCIM's {@code startIndex} is not required to be page-aligned. + */ +final class OffsetPageable implements Pageable { + + private final long offset; + private final int limit; + private final Sort sort; + + OffsetPageable(long offset, int limit, Sort sort) { + if (offset < 0) { + throw new IllegalArgumentException("offset must be >= 0"); + } + if (limit < 1) { + throw new IllegalArgumentException("limit must be >= 1"); + } + this.offset = offset; + this.limit = limit; + this.sort = sort == null ? Sort.unsorted() : sort; + } + + @Override + public int getPageNumber() { + return (int) (offset / limit); + } + + @Override + public int getPageSize() { + return limit; + } + + @Override + public long getOffset() { + return offset; + } + + @Override + public Sort getSort() { + return sort; + } + + @Override + public Pageable next() { + return new OffsetPageable(offset + limit, limit, sort); + } + + @Override + public Pageable previousOrFirst() { + return hasPrevious() ? new OffsetPageable(Math.max(0, offset - limit), limit, sort) : first(); + } + + @Override + public Pageable first() { + return new OffsetPageable(0, limit, sort); + } + + @Override + public Pageable withPage(int pageNumber) { + return new OffsetPageable((long) pageNumber * limit, limit, sort); + } + + @Override + public boolean hasPrevious() { + return offset > 0; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/UserViews.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/UserViews.java index 70a8ed45..87f866e5 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/UserViews.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/UserViews.java @@ -25,7 +25,9 @@ static UserView toView(UserEntity entity) { entity.getPreferredLanguage(), entity.isTotpEnabled(), entity.isPlatformAdmin(), - entity.getCreatedAt() + entity.getCreatedAt(), + entity.getScimExternalId(), + entity.getUpdatedAt() ); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserEntity.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserEntity.java index cf8c079a..c69e0448 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserEntity.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserEntity.java @@ -10,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; @@ -97,9 +98,22 @@ public class UserEntity { @Column(name = "attributes", nullable = false, columnDefinition = "jsonb") private String attributes = "{}"; + // IdP-side identifier (SCIM externalId, #621); unique per org when set. + @Column(name = "scim_external_id", length = 255) + private String scimExternalId; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt = Instant.now(); + // Feeds SCIM meta.lastModified (#621). + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } + /** * The user's role name — the custom role's name when one is assigned, otherwise the legacy * system-role enum name. Role-targeted policy matching (masking, row security, routing, diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserGroupEntity.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserGroupEntity.java index 9fff91c7..e4152108 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserGroupEntity.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserGroupEntity.java @@ -35,6 +35,10 @@ public class UserGroupEntity { @Column(length = 512) private String description; + // IdP-side identifier (SCIM externalId, #621); unique per org when set. + @Column(name = "scim_external_id", length = 255) + private String scimExternalId; + @Version @Column(nullable = false) private long version; diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserGroupMembershipSource.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserGroupMembershipSource.java index 478c1a86..d2f96332 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserGroupMembershipSource.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/entity/UserGroupMembershipSource.java @@ -2,5 +2,6 @@ public enum UserGroupMembershipSource { MANUAL, - IDP + IDP, + SCIM } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/UserGroupRepository.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/UserGroupRepository.java index 76508349..3be1338b 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/UserGroupRepository.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/UserGroupRepository.java @@ -21,4 +21,7 @@ public interface UserGroupRepository extends JpaRepository findByOrganizationIdAndNameIgnoreCase( @Param("organizationId") UUID organizationId, @Param("name") String name); + + Optional findByOrganization_IdAndScimExternalId( + UUID organizationId, String scimExternalId); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/UserRepository.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/UserRepository.java index c92d9249..6ad527c3 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/UserRepository.java +++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/UserRepository.java @@ -23,6 +23,15 @@ public interface UserRepository extends JpaRepository { long countByOrganization_IdAndActiveTrue(UUID organizationId); + long countByOrganization_Id(UUID organizationId); + + Optional findByOrganization_IdAndId(UUID organizationId, UUID id); + + Optional findByOrganization_IdAndEmail(UUID organizationId, String email); + + Optional findByOrganization_IdAndScimExternalId( + UUID organizationId, String scimExternalId); + List findAllByOrganization_Id(UUID organizationId); Page findAllByOrganization_Id(UUID organizationId, Pageable pageable); diff --git a/backend/src/main/resources/db/migration/V137__create_scim_config.sql b/backend/src/main/resources/db/migration/V137__create_scim_config.sql new file mode 100644 index 00000000..87c5c0bb --- /dev/null +++ b/backend/src/main/resources/db/migration/V137__create_scim_config.sql @@ -0,0 +1,15 @@ +-- #621: per-organization SCIM 2.0 provisioning configuration (singleton row per org). +-- attr_email / attr_display_name name the SCIM attribute the corresponding user field is read +-- from (attribute mapping); default_role is the system role assigned to SCIM-provisioned users, +-- mirroring saml_config.default_role / oauth2_config.default_role. +CREATE TABLE scim_config ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + attr_email VARCHAR(255) NOT NULL DEFAULT 'userName', + attr_display_name VARCHAR(255) NOT NULL DEFAULT 'displayName', + default_role user_role_type NOT NULL DEFAULT 'ANALYST', + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/src/main/resources/db/migration/V138__create_scim_tokens.sql b/backend/src/main/resources/db/migration/V138__create_scim_tokens.sql new file mode 100644 index 00000000..d3de6a09 --- /dev/null +++ b/backend/src/main/resources/db/migration/V138__create_scim_tokens.sql @@ -0,0 +1,19 @@ +-- #621: long-lived SCIM bearer tokens (one or more named tokens per org, so an operator can +-- rotate without downtime). Only a SHA-256 hex hash is stored; the raw token is shown exactly +-- once at creation. Shape mirrors api_keys (V32). +CREATE TABLE scim_tokens ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + token_prefix VARCHAR(16) NOT NULL, + token_hash VARCHAR(128) NOT NULL UNIQUE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT scim_tokens_unique_name_per_org UNIQUE (organization_id, name) +); + +CREATE INDEX idx_scim_tokens_org ON scim_tokens (organization_id); +-- Authentication does a hash lookup on every SCIM request; revoked tokens drop out of the index. +CREATE INDEX idx_scim_tokens_active_hash ON scim_tokens (token_hash) WHERE revoked_at IS NULL; diff --git a/backend/src/main/resources/db/migration/V139__add_scim_columns.sql b/backend/src/main/resources/db/migration/V139__add_scim_columns.sql new file mode 100644 index 00000000..b194974e --- /dev/null +++ b/backend/src/main/resources/db/migration/V139__add_scim_columns.sql @@ -0,0 +1,13 @@ +-- #621: SCIM resource identity columns. +-- scim_external_id is the IdP-side identifier (SCIM externalId), unique per org when set. +-- users.updated_at feeds SCIM meta.lastModified (backfilled from created_at). +ALTER TABLE users ADD COLUMN scim_external_id VARCHAR(255); +CREATE UNIQUE INDEX uq_users_org_scim_external_id + ON users (organization_id, scim_external_id) WHERE scim_external_id IS NOT NULL; + +ALTER TABLE users ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP; +UPDATE users SET updated_at = created_at; + +ALTER TABLE user_groups ADD COLUMN scim_external_id VARCHAR(255); +CREATE UNIQUE INDEX uq_user_groups_org_scim_external_id + ON user_groups (organization_id, scim_external_id) WHERE scim_external_id IS NOT NULL; diff --git a/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql b/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql new file mode 100644 index 00000000..9040af70 --- /dev/null +++ b/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql @@ -0,0 +1,3 @@ +-- #621: SCIM-pushed group memberships get their own provenance so neither SSO-login IDP sync +-- (which replaces all IDP rows) nor admin MANUAL edits ever touch them. +ALTER TYPE user_group_membership_source ADD VALUE IF NOT EXISTS 'SCIM'; diff --git a/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql.conf b/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql.conf new file mode 100644 index 00000000..73bd53a1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql.conf @@ -0,0 +1 @@ +executeInTransaction=false diff --git a/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql b/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql new file mode 100644 index 00000000..cf8f560f --- /dev/null +++ b/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql @@ -0,0 +1,4 @@ +-- #621: SCIM-provisioned users carry their own auth provider. They have no password (local login +-- impossible) and sign in through the org's SAML/OIDC SSO, whose email match accepts non-LOCAL +-- rows without the local-account takeover guard firing. +ALTER TYPE auth_provider_type ADD VALUE IF NOT EXISTS 'SCIM'; diff --git a/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql.conf b/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql.conf new file mode 100644 index 00000000..73bd53a1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql.conf @@ -0,0 +1 @@ +executeInTransaction=false diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryServiceTest.java new file mode 100644 index 00000000..c94addc1 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryServiceTest.java @@ -0,0 +1,246 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.CreateExternalUserCommand; +import com.bablsoft.accessflow.core.api.EmailAlreadyExistsException; +import com.bablsoft.accessflow.core.api.ExternalIdAlreadyExistsException; +import com.bablsoft.accessflow.core.api.QuotaExceededException; +import com.bablsoft.accessflow.core.api.QuotaService; +import com.bablsoft.accessflow.core.api.QuotaType; +import com.bablsoft.accessflow.core.api.UpdateExternalUserCommand; +import com.bablsoft.accessflow.core.api.UserNotFoundException; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.RoleRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +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.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultExternalUserDirectoryServiceTest { + + @Mock UserRepository userRepository; + @Mock OrganizationRepository organizationRepository; + @Mock RoleRepository roleRepository; + @Mock QuotaService quotaService; + @Mock ApplicationEventPublisher eventPublisher; + + DefaultExternalUserDirectoryService service; + + private final UUID orgId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new DefaultExternalUserDirectoryService(userRepository, organizationRepository, + roleRepository, quotaService, eventPublisher); + lenient().when(roleRepository.findByNameAndSystemTrue(any())).thenReturn(Optional.empty()); + } + + @Test + void createExternalPersistsScimProviderWithoutPassword() { + var org = new OrganizationEntity(); + org.setId(orgId); + when(userRepository.existsByEmail("jane@example.com")).thenReturn(false); + when(organizationRepository.getReferenceById(orgId)).thenReturn(org); + when(userRepository.save(any(UserEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + var view = service.createExternal(new CreateExternalUserCommand( + orgId, "Jane@Example.com ", "Jane", "ext-1", UserRoleType.ANALYST)); + + assertThat(view.email()).isEqualTo("jane@example.com"); + assertThat(view.authProvider()).isEqualTo(AuthProviderType.SCIM); + assertThat(view.passwordHash()).isNull(); + assertThat(view.scimExternalId()).isEqualTo("ext-1"); + assertThat(view.active()).isTrue(); + assertThat(view.role()).isEqualTo(UserRoleType.ANALYST); + verify(quotaService).checkUserQuota(orgId); + } + + @Test + void createExternalRejectsDuplicateEmail() { + when(userRepository.existsByEmail("dup@example.com")).thenReturn(true); + + assertThatThrownBy(() -> service.createExternal(new CreateExternalUserCommand( + orgId, "dup@example.com", "Dup", null, UserRoleType.ANALYST))) + .isInstanceOf(EmailAlreadyExistsException.class); + verify(userRepository, never()).save(any()); + } + + @Test + void createExternalRejectsDuplicateExternalId() { + when(userRepository.existsByEmail("new@example.com")).thenReturn(false); + when(userRepository.findByOrganization_IdAndScimExternalId(orgId, "ext-1")) + .thenReturn(Optional.of(user(UUID.randomUUID(), true))); + + assertThatThrownBy(() -> service.createExternal(new CreateExternalUserCommand( + orgId, "new@example.com", "New", "ext-1", UserRoleType.ANALYST))) + .isInstanceOf(ExternalIdAlreadyExistsException.class); + verify(userRepository, never()).save(any()); + } + + @Test + void createExternalEnforcesQuota() { + when(userRepository.existsByEmail("over@example.com")).thenReturn(false); + doThrow(new QuotaExceededException(QuotaType.USER, orgId, 5, 5)) + .when(quotaService).checkUserQuota(orgId); + + assertThatThrownBy(() -> service.createExternal(new CreateExternalUserCommand( + orgId, "over@example.com", "Over", null, UserRoleType.ANALYST))) + .isInstanceOf(QuotaExceededException.class); + verify(userRepository, never()).save(any()); + } + + @Test + void updateExternalDeactivationPublishesEventOnce() { + var entity = user(userId, true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + + service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, null, false)); + + assertThat(entity.isActive()).isFalse(); + verify(eventPublisher).publishEvent(new UserDeactivatedEvent(userId, orgId)); + } + + @Test + void updateExternalDeactivatingInactiveUserIsIdempotent() { + var entity = user(userId, false); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + + service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, null, false)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void updateExternalReactivationChecksQuota() { + var entity = user(userId, false); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + doThrow(new QuotaExceededException(QuotaType.USER, orgId, 5, 5)) + .when(quotaService).checkUserQuota(orgId); + + assertThatThrownBy(() -> service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, null, true))) + .isInstanceOf(QuotaExceededException.class); + assertThat(entity.isActive()).isFalse(); + } + + @Test + void updateExternalRejectsEmailCollision() { + var entity = user(userId, true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + when(userRepository.existsByEmail("taken@example.com")).thenReturn(true); + + assertThatThrownBy(() -> service.updateExternal(orgId, userId, + new UpdateExternalUserCommand("taken@example.com", null, null, null))) + .isInstanceOf(EmailAlreadyExistsException.class); + } + + @Test + void updateExternalRejectsExternalIdCollision() { + var entity = user(userId, true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + when(userRepository.findByOrganization_IdAndScimExternalId(orgId, "ext-9")) + .thenReturn(Optional.of(user(UUID.randomUUID(), true))); + + assertThatThrownBy(() -> service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, "ext-9", null))) + .isInstanceOf(ExternalIdAlreadyExistsException.class); + } + + @Test + void updateExternalNeverTouchesUnownedFields() { + var entity = user(userId, true); + entity.setPasswordHash("hash"); + entity.setPlatformAdmin(true); + entity.setTotpEnabled(true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + + service.updateExternal(orgId, userId, + new UpdateExternalUserCommand("new@example.com", "New Name", "ext-2", null)); + + assertThat(entity.getPasswordHash()).isEqualTo("hash"); + assertThat(entity.isPlatformAdmin()).isTrue(); + assertThat(entity.isTotpEnabled()).isTrue(); + assertThat(entity.getAuthProvider()).isEqualTo(AuthProviderType.LOCAL); + } + + @Test + void updateExternalUnknownUserThrowsNotFound() { + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, null, null))) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + void findersMapToViews() { + var entity = user(userId, true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + when(userRepository.findByOrganization_IdAndEmail(orgId, "jane@example.com")) + .thenReturn(Optional.of(entity)); + when(userRepository.findByOrganization_IdAndScimExternalId(orgId, "ext-1")) + .thenReturn(Optional.of(entity)); + + assertThat(service.findById(orgId, userId)).isPresent(); + assertThat(service.findByEmail(orgId, "Jane@Example.com")).isPresent(); + assertThat(service.findByExternalId(orgId, "ext-1")).isPresent(); + } + + @Test + void listReturnsOffsetPage() { + var entity = user(userId, true); + when(userRepository.findAllByOrganization_Id(eq(orgId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(entity), Pageable.ofSize(2), 7)); + + var page = service.list(orgId, 4, 2); + + assertThat(page.content()).hasSize(1); + assertThat(page.totalResults()).isEqualTo(7); + } + + private UserEntity user(UUID id, boolean active) { + var org = new OrganizationEntity(); + org.setId(orgId); + var entity = new UserEntity(); + entity.setId(id); + entity.setOrganization(org); + entity.setEmail(id + "@example.com"); + entity.setActive(active); + return entity; + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupServiceTest.java index bea355f1..db1dbadb 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupServiceTest.java @@ -4,6 +4,7 @@ import com.bablsoft.accessflow.core.api.PageRequest; import com.bablsoft.accessflow.core.api.UpdateUserGroupCommand; import com.bablsoft.accessflow.core.api.UserGroupMembershipNotFoundException; +import com.bablsoft.accessflow.core.api.UserGroupMembershipSourceType; import com.bablsoft.accessflow.core.api.UserGroupNameAlreadyExistsException; import com.bablsoft.accessflow.core.api.UserGroupNotFoundException; import com.bablsoft.accessflow.core.api.UserNotFoundException; @@ -203,6 +204,147 @@ void syncIdpMembershipsReplacesOnlyIdpRows() { assertThat(result).containsExactlyInAnyOrder(idpKeepGroupId, idpAddGroupId); } + @Test + void syncIdpMembershipsPreservesScimRows() { + var userId = UUID.randomUUID(); + var scimGroupId = UUID.randomUUID(); + var scimMembership = membership(userId, scimGroupId, UserGroupMembershipSource.SCIM); + when(membershipRepository.findAllByUser_Id(userId)).thenReturn(List.of(scimMembership)); + + // The SCIM-sourced group is also in the desired IdP set: it must be neither deleted nor + // duplicated with an IDP row (PK is (user_id, group_id)). + var result = service.syncIdpMemberships(userId, orgId, Set.of(scimGroupId)); + + verify(membershipRepository, never()).delete(any(UserGroupMembershipEntity.class)); + verify(membershipRepository, never()).save(any(UserGroupMembershipEntity.class)); + assertThat(result).containsExactly(scimGroupId); + } + + @Test + void addMemberWithScimSourcePersistsScimMembership() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + var user = user(UUID.randomUUID(), orgId); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(membershipRepository.existsByUser_IdAndGroup_Id(user.getId(), group.getId())) + .thenReturn(false); + when(membershipRepository.save(any(UserGroupMembershipEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var view = service.addMember(group.getId(), user.getId(), orgId, + UserGroupMembershipSourceType.SCIM); + + assertThat(view.source()).isEqualTo(UserGroupMembershipSourceType.SCIM); + } + + @Test + void addMemberWithSourceIsIdempotentWhenRowExists() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + var user = user(UUID.randomUUID(), orgId); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(membershipRepository.existsByUser_IdAndGroup_Id(user.getId(), group.getId())) + .thenReturn(true); + var existing = membership(user.getId(), group.getId(), UserGroupMembershipSource.MANUAL); + existing.setUser(user); + existing.setGroup(group); + when(membershipRepository.findAllByGroup_Id(group.getId())).thenReturn(List.of(existing)); + + var view = service.addMember(group.getId(), user.getId(), orgId, + UserGroupMembershipSourceType.SCIM); + + // First source wins: the MANUAL row is returned untouched. + assertThat(view.source()).isEqualTo(UserGroupMembershipSourceType.MANUAL); + verify(membershipRepository, never()).save(any()); + } + + @Test + void removeMemberBySourceOnlyRemovesMatchingSource() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + var user = user(UUID.randomUUID(), orgId); + var manualRow = membership(user.getId(), group.getId(), UserGroupMembershipSource.MANUAL); + manualRow.setUser(user); + manualRow.setGroup(group); + when(membershipRepository.findAllByGroup_Id(group.getId())).thenReturn(List.of(manualRow)); + + service.removeMemberBySource(group.getId(), user.getId(), orgId, + UserGroupMembershipSourceType.SCIM); + + verify(membershipRepository, never()).delete(any(UserGroupMembershipEntity.class)); + } + + @Test + void removeMemberBySourceRemovesOwnRow() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + var user = user(UUID.randomUUID(), orgId); + var scimRow = membership(user.getId(), group.getId(), UserGroupMembershipSource.SCIM); + scimRow.setUser(user); + scimRow.setGroup(group); + when(membershipRepository.findAllByGroup_Id(group.getId())).thenReturn(List.of(scimRow)); + + service.removeMemberBySource(group.getId(), user.getId(), orgId, + UserGroupMembershipSourceType.SCIM); + + verify(membershipRepository).delete(scimRow); + } + + @Test + void replaceMembersBySourceReplacesOnlyScimRows() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + + var manualUser = user(UUID.randomUUID(), orgId); + var staleScimUser = user(UUID.randomUUID(), orgId); + var keptScimUser = user(UUID.randomUUID(), orgId); + var newUser = user(UUID.randomUUID(), orgId); + + var manualRow = membership(manualUser.getId(), group.getId(), + UserGroupMembershipSource.MANUAL); + manualRow.setUser(manualUser); + manualRow.setGroup(group); + var staleScimRow = membership(staleScimUser.getId(), group.getId(), + UserGroupMembershipSource.SCIM); + staleScimRow.setUser(staleScimUser); + staleScimRow.setGroup(group); + var keptScimRow = membership(keptScimUser.getId(), group.getId(), + UserGroupMembershipSource.SCIM); + keptScimRow.setUser(keptScimUser); + keptScimRow.setGroup(group); + when(membershipRepository.findAllByGroup_Id(group.getId())) + .thenReturn(List.of(manualRow, staleScimRow, keptScimRow)); + when(userRepository.findByOrganization_IdAndId(orgId, newUser.getId())) + .thenReturn(Optional.of(newUser)); + when(membershipRepository.save(any(UserGroupMembershipEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var result = service.replaceMembersBySource(group.getId(), orgId, + List.of(keptScimUser.getId(), newUser.getId(), manualUser.getId()), + UserGroupMembershipSourceType.SCIM); + + verify(membershipRepository).delete(staleScimRow); + verify(membershipRepository, never()).delete(manualRow); + // manualUser keeps the MANUAL row — no SCIM duplicate is inserted for them. + assertThat(result).containsExactlyInAnyOrder(keptScimUser.getId(), newUser.getId()); + } + + @Test + void replaceMembersBySourceSkipsUnknownUsers() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + when(membershipRepository.findAllByGroup_Id(group.getId())).thenReturn(List.of()); + var unknownUserId = UUID.randomUUID(); + when(userRepository.findByOrganization_IdAndId(orgId, unknownUserId)) + .thenReturn(Optional.empty()); + + var result = service.replaceMembersBySource(group.getId(), orgId, + List.of(unknownUserId), UserGroupMembershipSourceType.SCIM); + + assertThat(result).isEmpty(); + verify(membershipRepository, never()).save(any()); + } + private UserGroupEntity group(String name) { var org = new OrganizationEntity(); org.setId(orgId); diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/OffsetPageableTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/OffsetPageableTest.java new file mode 100644 index 00000000..85944fe9 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/OffsetPageableTest.java @@ -0,0 +1,45 @@ +package com.bablsoft.accessflow.core.internal; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OffsetPageableTest { + + @Test + void exposesArbitraryOffset() { + var pageable = new OffsetPageable(7, 3, Sort.by("id")); + + assertThat(pageable.getOffset()).isEqualTo(7); + assertThat(pageable.getPageSize()).isEqualTo(3); + assertThat(pageable.getPageNumber()).isEqualTo(2); + assertThat(pageable.getSort()).isEqualTo(Sort.by("id")); + } + + @Test + void navigationKeepsLimitAndSort() { + var pageable = new OffsetPageable(5, 10, Sort.unsorted()); + + assertThat(pageable.next().getOffset()).isEqualTo(15); + assertThat(pageable.previousOrFirst().getOffset()).isZero(); + assertThat(pageable.first().getOffset()).isZero(); + assertThat(pageable.withPage(3).getOffset()).isEqualTo(30); + assertThat(pageable.hasPrevious()).isTrue(); + assertThat(pageable.first().hasPrevious()).isFalse(); + } + + @Test + void nullSortBecomesUnsorted() { + assertThat(new OffsetPageable(0, 1, null).getSort()).isEqualTo(Sort.unsorted()); + } + + @Test + void rejectsInvalidArguments() { + assertThatThrownBy(() -> new OffsetPageable(-1, 10, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new OffsetPageable(0, 0, null)) + .isInstanceOf(IllegalArgumentException.class); + } +} From cc707d6c825671f4905a77abaadc1597f4b16e92 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 13 Aug 2026 15:22:50 +0400 Subject: [PATCH 04/12] feat(AF-621): SCIM 2.0 provisioning server module New scim Modulith module: /scim/v2 Users + Groups + discovery endpoints behind an @Order(0) bearer-token filter chain (per-org tokens, SHA-256 at rest, shown once; per-request enabled/org-disabled checks; SCIM error envelope, never ProblemDetail). Hand-rolled RFC 7644 subset: eq-filters, startIndex/count, PUT + PatchOp incl. Okta and Entra payload shapes. SCIM writes only its owned attributes - never role, platform_admin, password, or TOTP. Admin surface /api/v1/admin/scim-* gated by SSO_CONFIGURE. New SCIM_* audit actions; admin-facing i18n keys in all seven locales. --- .../accessflow/audit/api/AuditAction.java | 11 +- .../audit/api/AuditResourceType.java | 4 +- .../accessflow/scim/api/IssuedScimToken.java | 5 + .../scim/api/ScimAttributeMapping.java | 20 + .../scim/api/ScimConfigService.java | 20 + .../accessflow/scim/api/ScimConfigView.java | 23 ++ .../accessflow/scim/api/ScimException.java | 9 + .../scim/api/ScimInvalidMappingException.java | 8 + .../accessflow/scim/api/ScimPrincipal.java | 10 + .../api/ScimTokenNameConflictException.java | 8 + .../scim/api/ScimTokenNotFoundException.java | 10 + .../accessflow/scim/api/ScimTokenService.java | 31 ++ .../accessflow/scim/api/ScimTokenView.java | 15 + .../scim/api/UpdateScimConfigCommand.java | 12 + .../accessflow/scim/api/package-info.java | 4 + .../internal/DefaultScimConfigService.java | 97 +++++ .../internal/DefaultScimTokenService.java | 98 +++++ .../scim/internal/ScimGroupOrchestrator.java | 347 ++++++++++++++++++ .../scim/internal/ScimTokenHasher.java | 56 +++ .../scim/internal/ScimUserOrchestrator.java | 317 ++++++++++++++++ .../scim/internal/ScimUserWriteResult.java | 11 + .../config/ScimSecurityConfiguration.java | 39 ++ .../internal/config/ScimWebConfiguration.java | 33 ++ .../persistence/entity/ScimConfigEntity.java | 62 ++++ .../persistence/entity/ScimTokenEntity.java | 49 +++ .../repo/ScimConfigRepository.java | 14 + .../persistence/repo/ScimTokenRepository.java | 19 + .../scim/internal/protocol/ScimEmail.java | 12 + .../scim/internal/protocol/ScimError.java | 17 + .../scim/internal/protocol/ScimFilter.java | 5 + .../internal/protocol/ScimFilterParser.java | 43 +++ .../internal/protocol/ScimGroupResource.java | 22 ++ .../protocol/ScimInvalidFilterException.java | 8 + .../protocol/ScimInvalidPathException.java | 8 + .../protocol/ScimInvalidValueException.java | 8 + .../internal/protocol/ScimListResponse.java | 21 ++ .../scim/internal/protocol/ScimMemberRef.java | 13 + .../scim/internal/protocol/ScimMeta.java | 13 + .../scim/internal/protocol/ScimName.java | 12 + .../internal/protocol/ScimPatchOperation.java | 12 + .../internal/protocol/ScimPatchRequest.java | 16 + .../protocol/ScimProtocolException.java | 27 ++ .../ScimResourceNotFoundException.java | 8 + .../scim/internal/protocol/ScimSchemas.java | 18 + .../protocol/ScimUniquenessException.java | 8 + .../internal/protocol/ScimUserResource.java | 29 ++ .../web/admin/CreateScimTokenRequest.java | 10 + .../web/admin/CreatedScimTokenResponse.java | 12 + .../web/admin/ScimAdminConfigController.java | 79 ++++ .../web/admin/ScimAdminExceptionHandler.java | 59 +++ .../web/admin/ScimAdminTokenController.java | 102 +++++ .../web/admin/ScimConfigResponse.java | 30 ++ .../internal/web/admin/ScimTokenResponse.java | 25 ++ .../web/admin/UpdateScimConfigRequest.java | 20 + .../internal/web/scim/ScimAuditWriter.java | 49 +++ .../scim/ScimAuthenticationEntryPoint.java | 33 ++ .../web/scim/ScimAuthenticationToken.java | 33 ++ .../web/scim/ScimDiscoveryController.java | 88 +++++ .../internal/web/scim/ScimErrorHandler.java | 46 +++ .../web/scim/ScimGroupController.java | 144 ++++++++ .../internal/web/scim/ScimMediaTypes.java | 13 + .../scim/ScimTokenAuthenticationFilter.java | 60 +++ .../internal/web/scim/ScimUserController.java | 147 ++++++++ .../accessflow/scim/package-info.java | 4 + .../main/resources/i18n/messages.properties | 9 + .../resources/i18n/messages_de.properties | 9 + .../resources/i18n/messages_es.properties | 9 + .../resources/i18n/messages_fr.properties | 9 + .../resources/i18n/messages_hy.properties | 9 + .../resources/i18n/messages_ru.properties | 9 + .../resources/i18n/messages_zh_CN.properties | 9 + .../DefaultScimConfigServiceTest.java | 105 ++++++ .../internal/DefaultScimTokenServiceTest.java | 149 ++++++++ .../internal/ScimGroupOrchestratorTest.java | 243 ++++++++++++ .../scim/internal/ScimTokenHasherTest.java | 41 +++ .../internal/ScimUserOrchestratorTest.java | 325 ++++++++++++++++ .../protocol/ScimFilterParserTest.java | 55 +++ .../ScimAdminControllerIntegrationTest.java | 241 ++++++++++++ .../web/admin/ScimAdminWebModelsTest.java | 55 +++ .../web/scim/ScimAuditWriterTest.java | 63 ++++ .../ScimAuthenticationEntryPointTest.java | 27 ++ .../web/scim/ScimAuthenticationTokenTest.java | 24 ++ .../web/scim/ScimDiscoveryControllerTest.java | 62 ++++ .../scim/ScimEndpointsIntegrationTest.java | 319 ++++++++++++++++ .../web/scim/ScimErrorHandlerTest.java | 51 +++ .../ScimTokenAuthenticationFilterTest.java | 115 ++++++ 86 files changed, 4522 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/IssuedScimToken.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimAttributeMapping.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimConfigService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimConfigView.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimInvalidMappingException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimPrincipal.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenNameConflictException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenNotFoundException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenView.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/UpdateScimConfigCommand.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/api/package-info.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestrator.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasher.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestrator.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimUserWriteResult.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/config/ScimSecurityConfiguration.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/config/ScimWebConfiguration.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/entity/ScimConfigEntity.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/entity/ScimTokenEntity.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/repo/ScimConfigRepository.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/repo/ScimTokenRepository.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimEmail.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimError.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilter.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParser.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimGroupResource.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidFilterException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidPathException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidValueException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimListResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimMemberRef.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimMeta.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimName.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimPatchOperation.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimPatchRequest.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimProtocolException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimResourceNotFoundException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimSchemas.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimUniquenessException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimUserResource.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/CreateScimTokenRequest.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/CreatedScimTokenResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminConfigController.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminExceptionHandler.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminTokenController.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimConfigResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimTokenResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/UpdateScimConfigRequest.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriter.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPoint.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationToken.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryController.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandler.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimGroupController.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimMediaTypes.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilter.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimUserController.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/scim/package-info.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigServiceTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenServiceTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestratorTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasherTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestratorTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParserTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminControllerIntegrationTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminWebModelsTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriterTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPointTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationTokenTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryControllerTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimEndpointsIntegrationTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandlerTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilterTest.java diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java index b244ecfd..fedc7ac1 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java +++ b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java @@ -172,5 +172,14 @@ public enum AuditAction { DISCOVERY_SCAN_COMPLETED, DISCOVERY_FINDING_CONFIRMED, - DISCOVERY_FINDING_DISMISSED + DISCOVERY_FINDING_DISMISSED, + + SCIM_CONFIG_UPDATED, + SCIM_TOKEN_CREATED, + SCIM_TOKEN_REVOKED, + SCIM_USER_PROVISIONED, + SCIM_USER_UPDATED, + SCIM_USER_DEACTIVATED, + SCIM_GROUP_SYNCED, + SCIM_GROUP_DELETED } diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditResourceType.java b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditResourceType.java index b34150bd..aff5fb27 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditResourceType.java +++ b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditResourceType.java @@ -48,7 +48,9 @@ public enum AuditResourceType { DELETION_REQUEST("deletion_request"), REQUEST_GROUP("request_group"), QUERY_TICKET("query_ticket"), - DISCOVERY_FINDING("discovery_finding"); + DISCOVERY_FINDING("discovery_finding"), + SCIM_CONFIG("scim_config"), + SCIM_TOKEN("scim_token"); private final String dbValue; diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/IssuedScimToken.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/IssuedScimToken.java new file mode 100644 index 00000000..8d6eb5ed --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/IssuedScimToken.java @@ -0,0 +1,5 @@ +package com.bablsoft.accessflow.scim.api; + +/** Result of creating a SCIM token (#621): {@code rawToken} is returned exactly once. */ +public record IssuedScimToken(ScimTokenView token, String rawToken) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimAttributeMapping.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimAttributeMapping.java new file mode 100644 index 00000000..9c58211e --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimAttributeMapping.java @@ -0,0 +1,20 @@ +package com.bablsoft.accessflow.scim.api; + +import java.util.Set; + +/** Allowed values for the per-org SCIM attribute mapping (#621). */ +public final class ScimAttributeMapping { + + /** SCIM attributes the user's email may be read from. */ + public static final Set EMAIL_SOURCES = Set.of("userName", "emails.primary"); + + /** SCIM attributes the user's display name may be read from. */ + public static final Set DISPLAY_NAME_SOURCES = + Set.of("displayName", "name.formatted", "userName"); + + public static final String DEFAULT_EMAIL_SOURCE = "userName"; + public static final String DEFAULT_DISPLAY_NAME_SOURCE = "displayName"; + + private ScimAttributeMapping() { + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimConfigService.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimConfigService.java new file mode 100644 index 00000000..29b5de10 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimConfigService.java @@ -0,0 +1,20 @@ +package com.bablsoft.accessflow.scim.api; + +import java.util.UUID; + +/** Per-organization SCIM 2.0 provisioning settings (#621), a singleton row per org. */ +public interface ScimConfigService { + + /** The org's configuration, or an all-default (disabled) view when none was saved yet. */ + ScimConfigView get(UUID organizationId); + + /** + * Partial update / upsert. + * + * @throws ScimInvalidMappingException when an attribute-mapping value is not allowed + */ + ScimConfigView update(UUID organizationId, UpdateScimConfigCommand command); + + /** Whether SCIM provisioning is enabled for the org — checked on every SCIM request. */ + boolean isEnabled(UUID organizationId); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimConfigView.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimConfigView.java new file mode 100644 index 00000000..a6a4ba4b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimConfigView.java @@ -0,0 +1,23 @@ +package com.bablsoft.accessflow.scim.api; + +import com.bablsoft.accessflow.core.api.UserRoleType; + +import java.time.Instant; +import java.util.UUID; + +/** + * The organization's SCIM 2.0 provisioning settings (#621). {@code attrEmail} and + * {@code attrDisplayName} name the SCIM attribute the corresponding user field is read from — + * see {@link ScimAttributeMapping} for the allowed values. + */ +public record ScimConfigView( + UUID id, + UUID organizationId, + boolean enabled, + String attrEmail, + String attrDisplayName, + UserRoleType defaultRole, + Instant createdAt, + Instant updatedAt +) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimException.java new file mode 100644 index 00000000..1688f552 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimException.java @@ -0,0 +1,9 @@ +package com.bablsoft.accessflow.scim.api; + +/** Base of the scim module's admin-facing exception hierarchy (#621). */ +public abstract class ScimException extends RuntimeException { + + protected ScimException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimInvalidMappingException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimInvalidMappingException.java new file mode 100644 index 00000000..ad6387a4 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimInvalidMappingException.java @@ -0,0 +1,8 @@ +package com.bablsoft.accessflow.scim.api; + +public final class ScimInvalidMappingException extends ScimException { + + public ScimInvalidMappingException(String attribute, String value) { + super("Invalid SCIM attribute mapping: " + attribute + " = " + value); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimPrincipal.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimPrincipal.java new file mode 100644 index 00000000..c9bdf520 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimPrincipal.java @@ -0,0 +1,10 @@ +package com.bablsoft.accessflow.scim.api; + +import java.util.UUID; + +/** + * The authenticated identity of a SCIM request (#621): the organization is derived from the + * bearer token — never from the request path or body. + */ +public record ScimPrincipal(UUID organizationId, UUID tokenId, String tokenName) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenNameConflictException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenNameConflictException.java new file mode 100644 index 00000000..13a5be63 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenNameConflictException.java @@ -0,0 +1,8 @@ +package com.bablsoft.accessflow.scim.api; + +public final class ScimTokenNameConflictException extends ScimException { + + public ScimTokenNameConflictException(String name) { + super("A SCIM token with this name already exists: " + name); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenNotFoundException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenNotFoundException.java new file mode 100644 index 00000000..4d20a386 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenNotFoundException.java @@ -0,0 +1,10 @@ +package com.bablsoft.accessflow.scim.api; + +import java.util.UUID; + +public final class ScimTokenNotFoundException extends ScimException { + + public ScimTokenNotFoundException(UUID tokenId) { + super("SCIM token not found: " + tokenId); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenService.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenService.java new file mode 100644 index 00000000..6ec94c16 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenService.java @@ -0,0 +1,31 @@ +package com.bablsoft.accessflow.scim.api; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Long-lived SCIM bearer tokens, one or more named tokens per organization (#621). */ +public interface ScimTokenService { + + List list(UUID organizationId); + + /** + * Create a named token; the raw value is available only on the returned object. + * + * @throws ScimTokenNameConflictException when the org already has a token with this name + */ + IssuedScimToken create(UUID organizationId, String name, UUID createdBy); + + /** + * Revoke the token (idempotent). + * + * @throws ScimTokenNotFoundException when the id is unknown in this org + */ + void revoke(UUID organizationId, UUID tokenId); + + /** + * Resolve a raw bearer token to its principal: empty when the token is unknown or revoked. + * Bumps {@code last_used_at} best-effort. + */ + Optional authenticate(String rawToken); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenView.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenView.java new file mode 100644 index 00000000..7a5c13c9 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/ScimTokenView.java @@ -0,0 +1,15 @@ +package com.bablsoft.accessflow.scim.api; + +import java.time.Instant; +import java.util.UUID; + +/** A SCIM bearer token's metadata (#621) — never the raw token or its hash. */ +public record ScimTokenView( + UUID id, + String name, + String tokenPrefix, + Instant createdAt, + Instant lastUsedAt, + Instant revokedAt +) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/UpdateScimConfigCommand.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/UpdateScimConfigCommand.java new file mode 100644 index 00000000..de1aeaa8 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/UpdateScimConfigCommand.java @@ -0,0 +1,12 @@ +package com.bablsoft.accessflow.scim.api; + +import com.bablsoft.accessflow.core.api.UserRoleType; + +/** Partial update / upsert for the org's SCIM config (#621); null fields are left unchanged. */ +public record UpdateScimConfigCommand( + Boolean enabled, + String attrEmail, + String attrDisplayName, + UserRoleType defaultRole +) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/api/package-info.java b/backend/src/main/java/com/bablsoft/accessflow/scim/api/package-info.java new file mode 100644 index 00000000..3c8c15d4 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/api/package-info.java @@ -0,0 +1,4 @@ +@NamedInterface +package com.bablsoft.accessflow.scim.api; + +import org.springframework.modulith.NamedInterface; diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigService.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigService.java new file mode 100644 index 00000000..b97f36a5 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigService.java @@ -0,0 +1,97 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.scim.api.ScimAttributeMapping; +import com.bablsoft.accessflow.scim.api.ScimConfigService; +import com.bablsoft.accessflow.scim.api.ScimConfigView; +import com.bablsoft.accessflow.scim.api.ScimInvalidMappingException; +import com.bablsoft.accessflow.scim.api.UpdateScimConfigCommand; +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimConfigEntity; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimConfigRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +class DefaultScimConfigService implements ScimConfigService { + + private final ScimConfigRepository configRepository; + + @Override + @Transactional(readOnly = true) + public ScimConfigView get(UUID organizationId) { + return configRepository.findByOrganizationId(organizationId) + .map(DefaultScimConfigService::toView) + .orElseGet(() -> defaultView(organizationId)); + } + + @Override + @Transactional + public ScimConfigView update(UUID organizationId, UpdateScimConfigCommand command) { + validate(command); + var entity = configRepository.findByOrganizationId(organizationId) + .orElseGet(() -> { + var created = new ScimConfigEntity(); + created.setId(UUID.randomUUID()); + created.setOrganizationId(organizationId); + return created; + }); + if (command.enabled() != null) { + entity.setEnabled(command.enabled()); + } + if (command.attrEmail() != null) { + entity.setAttrEmail(command.attrEmail()); + } + if (command.attrDisplayName() != null) { + entity.setAttrDisplayName(command.attrDisplayName()); + } + if (command.defaultRole() != null) { + entity.setDefaultRole(command.defaultRole()); + } + return toView(configRepository.save(entity)); + } + + @Override + @Transactional(readOnly = true) + public boolean isEnabled(UUID organizationId) { + return configRepository.existsByOrganizationIdAndEnabledTrue(organizationId); + } + + private static void validate(UpdateScimConfigCommand command) { + if (command.attrEmail() != null + && !ScimAttributeMapping.EMAIL_SOURCES.contains(command.attrEmail())) { + throw new ScimInvalidMappingException("attr_email", command.attrEmail()); + } + if (command.attrDisplayName() != null + && !ScimAttributeMapping.DISPLAY_NAME_SOURCES.contains(command.attrDisplayName())) { + throw new ScimInvalidMappingException("attr_display_name", command.attrDisplayName()); + } + } + + private static ScimConfigView toView(ScimConfigEntity entity) { + return new ScimConfigView( + entity.getId(), + entity.getOrganizationId(), + entity.isEnabled(), + entity.getAttrEmail(), + entity.getAttrDisplayName(), + entity.getDefaultRole(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } + + private static ScimConfigView defaultView(UUID organizationId) { + return new ScimConfigView( + null, + organizationId, + false, + ScimAttributeMapping.DEFAULT_EMAIL_SOURCE, + ScimAttributeMapping.DEFAULT_DISPLAY_NAME_SOURCE, + UserRoleType.ANALYST, + null, + null); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenService.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenService.java new file mode 100644 index 00000000..72569814 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenService.java @@ -0,0 +1,98 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.scim.api.IssuedScimToken; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.api.ScimTokenNameConflictException; +import com.bablsoft.accessflow.scim.api.ScimTokenNotFoundException; +import com.bablsoft.accessflow.scim.api.ScimTokenService; +import com.bablsoft.accessflow.scim.api.ScimTokenView; +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimTokenEntity; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +class DefaultScimTokenService implements ScimTokenService { + + private final ScimTokenRepository tokenRepository; + + @Override + @Transactional(readOnly = true) + public List list(UUID organizationId) { + return tokenRepository.findAllByOrganizationIdOrderByCreatedAtDesc(organizationId).stream() + .map(DefaultScimTokenService::toView) + .toList(); + } + + @Override + @Transactional + public IssuedScimToken create(UUID organizationId, String name, UUID createdBy) { + var trimmed = name == null ? null : name.trim(); + if (tokenRepository.existsByOrganizationIdAndName(organizationId, trimmed)) { + throw new ScimTokenNameConflictException(trimmed); + } + var rawToken = ScimTokenHasher.generate(); + var entity = new ScimTokenEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(organizationId); + entity.setName(trimmed); + entity.setTokenPrefix(ScimTokenHasher.prefixOf(rawToken)); + entity.setTokenHash(ScimTokenHasher.hash(rawToken)); + entity.setCreatedBy(createdBy); + return new IssuedScimToken(toView(tokenRepository.save(entity)), rawToken); + } + + @Override + @Transactional + public void revoke(UUID organizationId, UUID tokenId) { + var entity = tokenRepository.findByOrganizationIdAndId(organizationId, tokenId) + .orElseThrow(() -> new ScimTokenNotFoundException(tokenId)); + if (entity.getRevokedAt() == null) { + entity.setRevokedAt(Instant.now()); + } + } + + @Override + @Transactional + public Optional authenticate(String rawToken) { + if (!ScimTokenHasher.hasExpectedShape(rawToken)) { + return Optional.empty(); + } + var entity = tokenRepository.findByTokenHash(ScimTokenHasher.hash(rawToken)).orElse(null); + if (entity == null || entity.getRevokedAt() != null) { + return Optional.empty(); + } + touchLastUsedAt(entity); + return Optional.of(new ScimPrincipal( + entity.getOrganizationId(), entity.getId(), entity.getName())); + } + + private void touchLastUsedAt(ScimTokenEntity entity) { + try { + entity.setLastUsedAt(Instant.now()); + tokenRepository.save(entity); + } catch (RuntimeException ex) { + // Best-effort freshness marker — never fail authentication over it. + log.debug("Failed to bump last_used_at for SCIM token {}", entity.getId(), ex); + } + } + + private static ScimTokenView toView(ScimTokenEntity entity) { + return new ScimTokenView( + entity.getId(), + entity.getName(), + entity.getTokenPrefix(), + entity.getCreatedAt(), + entity.getLastUsedAt(), + entity.getRevokedAt()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestrator.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestrator.java new file mode 100644 index 00000000..252e2957 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestrator.java @@ -0,0 +1,347 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.core.api.CreateUserGroupCommand; +import com.bablsoft.accessflow.core.api.UpdateUserGroupCommand; +import com.bablsoft.accessflow.core.api.UserGroupMembershipSourceType; +import com.bablsoft.accessflow.core.api.UserGroupNameAlreadyExistsException; +import com.bablsoft.accessflow.core.api.UserGroupNotFoundException; +import com.bablsoft.accessflow.core.api.UserGroupService; +import com.bablsoft.accessflow.core.api.UserGroupView; +import com.bablsoft.accessflow.core.api.UserNotFoundException; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.protocol.ScimFilterParser; +import com.bablsoft.accessflow.scim.internal.protocol.ScimGroupResource; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidFilterException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidPathException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidValueException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimListResponse; +import com.bablsoft.accessflow.scim.internal.protocol.ScimMemberRef; +import com.bablsoft.accessflow.scim.internal.protocol.ScimMeta; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import com.bablsoft.accessflow.scim.internal.protocol.ScimResourceNotFoundException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimSchemas; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUniquenessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import tools.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * Maps the SCIM Group wire contract onto {@link UserGroupService} (#621). All member writes carry + * {@code source=SCIM}, so MANUAL and SSO-IDP memberships are never touched. Group listings are + * resolved in memory over {@code listAll} — per-org group counts are small by construction. + */ +@Service +@RequiredArgsConstructor +public class ScimGroupOrchestrator { + + static final String RESOURCE_TYPE = "Group"; + + /** Entra's remove-one-member path form: {@code members[value eq ""]}. */ + private static final Pattern MEMBERS_VALUE_PATH = Pattern.compile( + "^members\\[value\\s+eq\\s+\"((?:[^\"\\\\]|\\\\.)*)\"\\]$", Pattern.CASE_INSENSITIVE); + + private final UserGroupService userGroupService; + + public ScimListResponse list(ScimPrincipal principal, String filterExpression, + int startIndex, int count, String baseUrl) { + var filter = ScimFilterParser.parse(filterExpression); + var all = userGroupService.listAll(principal.organizationId()); + List matches; + if (filter == null) { + matches = all; + } else { + matches = switch (filter.attribute()) { + case "displayname" -> all.stream() + .filter(g -> g.name() != null && g.name().equalsIgnoreCase(filter.value())) + .toList(); + case "externalid" -> all.stream() + .filter(g -> filter.value().equals(g.scimExternalId())) + .toList(); + default -> throw new ScimInvalidFilterException( + "Unsupported filter attribute: " + filter.attribute()); + }; + } + var normalizedStart = Math.max(1, startIndex); + var normalizedCount = Math.clamp(count, 1, 200); + var window = matches.stream() + .skip(normalizedStart - 1L) + .limit(normalizedCount) + .map(g -> toResource(principal, g, baseUrl, true)) + .toList(); + return new ScimListResponse<>(List.of(ScimSchemas.LIST_RESPONSE), matches.size(), + normalizedStart, window.size(), window); + } + + public ScimGroupResource create(ScimPrincipal principal, ScimGroupResource resource, String baseUrl) { + if (resource.displayName() == null || resource.displayName().isBlank()) { + throw new ScimInvalidValueException("Missing required attribute: displayName"); + } + var externalId = blankToNull(resource.externalId()); + if (externalId != null && findByExternalId(principal, externalId) != null) { + throw new ScimUniquenessException("A group with this externalId already exists"); + } + UserGroupView created; + try { + created = userGroupService.createGroup(new CreateUserGroupCommand( + principal.organizationId(), resource.displayName(), null, externalId)); + } catch (UserGroupNameAlreadyExistsException ex) { + throw new ScimUniquenessException("A group with this displayName already exists"); + } + if (resource.members() != null && !resource.members().isEmpty()) { + userGroupService.replaceMembersBySource(created.id(), principal.organizationId(), + memberIds(resource.members()), UserGroupMembershipSourceType.SCIM); + } + return get(principal, created.id(), baseUrl); + } + + public ScimGroupResource get(ScimPrincipal principal, UUID id, String baseUrl) { + try { + var group = userGroupService.getGroup(id, principal.organizationId()); + return toResource(principal, group, baseUrl, false); + } catch (UserGroupNotFoundException ex) { + throw new ScimResourceNotFoundException(RESOURCE_TYPE, id.toString()); + } + } + + /** PUT — replaces displayName/externalId and the SCIM-sourced member set. */ + public ScimGroupResource replace(ScimPrincipal principal, UUID id, ScimGroupResource resource, + String baseUrl) { + requireExists(principal, id); + if (resource.displayName() == null || resource.displayName().isBlank()) { + throw new ScimInvalidValueException("Missing required attribute: displayName"); + } + var externalId = blankToNull(resource.externalId()); + if (externalId != null) { + var conflicting = findByExternalId(principal, externalId); + if (conflicting != null && !conflicting.id().equals(id)) { + throw new ScimUniquenessException("A group with this externalId already exists"); + } + } + try { + userGroupService.updateGroup(id, principal.organizationId(), + new UpdateUserGroupCommand(resource.displayName(), null, externalId)); + } catch (UserGroupNameAlreadyExistsException ex) { + throw new ScimUniquenessException("A group with this displayName already exists"); + } + if (resource.members() != null) { + userGroupService.replaceMembersBySource(id, principal.organizationId(), + memberIds(resource.members()), UserGroupMembershipSourceType.SCIM); + } + return get(principal, id, baseUrl); + } + + public ScimGroupResource patch(ScimPrincipal principal, UUID id, ScimPatchRequest patch, + String baseUrl) { + requireExists(principal, id); + if (patch == null || patch.operations() == null || patch.operations().isEmpty()) { + throw new ScimInvalidValueException("PatchOp must carry at least one operation"); + } + for (var operation : patch.operations()) { + var op = operation.op() == null ? "" : operation.op().toLowerCase(Locale.ROOT); + var path = operation.path() == null ? null + : operation.path().trim().toLowerCase(Locale.ROOT); + var value = operation.value(); + + if (path != null) { + var memberMatcher = MEMBERS_VALUE_PATH.matcher(operation.path().trim()); + if (memberMatcher.matches()) { + if (!op.equals("remove")) { + throw new ScimInvalidPathException( + "Filtered members path supports only remove"); + } + removeMember(principal, id, memberMatcher.group(1)); + continue; + } + } + + switch (op) { + case "add", "replace" -> applyAddOrReplace(principal, id, op, path, value); + case "remove" -> applyRemove(principal, id, path, value); + default -> throw new ScimInvalidPathException("Unsupported patch op: " + + operation.op()); + } + } + return get(principal, id, baseUrl); + } + + /** @return the deleted group's view — the controller audits its name and member count. */ + public UserGroupView delete(ScimPrincipal principal, UUID id) { + try { + var group = userGroupService.getGroup(id, principal.organizationId()); + userGroupService.deleteGroup(id, principal.organizationId()); + return group; + } catch (UserGroupNotFoundException ex) { + throw new ScimResourceNotFoundException(RESOURCE_TYPE, id.toString()); + } + } + + private void applyAddOrReplace(ScimPrincipal principal, UUID groupId, String op, String path, + JsonNode value) { + if (path == null) { + if (value == null || !value.isObject()) { + throw new ScimInvalidValueException("Patch value must be an object"); + } + if (value.has("displayName")) { + rename(principal, groupId, value.get("displayName").asString()); + } + if (value.has("externalId")) { + reExternalId(principal, groupId, value.get("externalId").asString()); + } + if (value.has("members")) { + replaceOrAddMembers(principal, groupId, op, value.get("members")); + } + return; + } + switch (path) { + case "displayname" -> rename(principal, groupId, textOf(value)); + case "externalid" -> reExternalId(principal, groupId, textOf(value)); + case "members" -> replaceOrAddMembers(principal, groupId, op, value); + default -> throw new ScimInvalidPathException("Unsupported patch path: " + path); + } + } + + private void applyRemove(ScimPrincipal principal, UUID groupId, String path, JsonNode value) { + if (!"members".equals(path)) { + throw new ScimInvalidPathException("remove supports only the members path"); + } + if (value == null || value.isNull()) { + // remove with path "members" and no value clears the SCIM-sourced member set. + userGroupService.replaceMembersBySource(groupId, principal.organizationId(), + List.of(), UserGroupMembershipSourceType.SCIM); + return; + } + for (var memberId : memberIdsFromNode(value)) { + userGroupService.removeMemberBySource(groupId, memberId, principal.organizationId(), + UserGroupMembershipSourceType.SCIM); + } + } + + private void replaceOrAddMembers(ScimPrincipal principal, UUID groupId, String op, + JsonNode value) { + var memberIds = memberIdsFromNode(value); + if ("replace".equals(op)) { + userGroupService.replaceMembersBySource(groupId, principal.organizationId(), memberIds, + UserGroupMembershipSourceType.SCIM); + return; + } + for (var memberId : memberIds) { + try { + userGroupService.addMember(groupId, memberId, principal.organizationId(), + UserGroupMembershipSourceType.SCIM); + } catch (UserNotFoundException ex) { + // Unknown member ids are skipped — the IdP may race a user delete. + } + } + } + + private void removeMember(ScimPrincipal principal, UUID groupId, String rawMemberId) { + userGroupService.removeMemberBySource(groupId, parseMemberId(rawMemberId), + principal.organizationId(), UserGroupMembershipSourceType.SCIM); + } + + private void rename(ScimPrincipal principal, UUID groupId, String displayName) { + if (displayName == null || displayName.isBlank()) { + throw new ScimInvalidValueException("displayName must not be blank"); + } + try { + userGroupService.updateGroup(groupId, principal.organizationId(), + new UpdateUserGroupCommand(displayName, null, null)); + } catch (UserGroupNameAlreadyExistsException ex) { + throw new ScimUniquenessException("A group with this displayName already exists"); + } + } + + private void reExternalId(ScimPrincipal principal, UUID groupId, String externalId) { + var normalized = blankToNull(externalId); + if (normalized != null) { + var conflicting = findByExternalId(principal, normalized); + if (conflicting != null && !conflicting.id().equals(groupId)) { + throw new ScimUniquenessException("A group with this externalId already exists"); + } + } + userGroupService.updateGroup(groupId, principal.organizationId(), + new UpdateUserGroupCommand(null, null, normalized == null ? "" : normalized)); + } + + private UserGroupView findByExternalId(ScimPrincipal principal, String externalId) { + return userGroupService.listAll(principal.organizationId()).stream() + .filter(g -> externalId.equals(g.scimExternalId())) + .findFirst() + .orElse(null); + } + + private void requireExists(ScimPrincipal principal, UUID id) { + try { + userGroupService.getGroup(id, principal.organizationId()); + } catch (UserGroupNotFoundException ex) { + throw new ScimResourceNotFoundException(RESOURCE_TYPE, id.toString()); + } + } + + private ScimGroupResource toResource(ScimPrincipal principal, UserGroupView group, + String baseUrl, boolean omitMembers) { + List members = null; + if (!omitMembers) { + members = userGroupService.listMembers(group.id(), principal.organizationId()).stream() + .map(m -> new ScimMemberRef(m.userId().toString(), m.userEmail())) + .toList(); + } + return new ScimGroupResource( + List.of(ScimSchemas.GROUP), + group.id().toString(), + group.scimExternalId(), + group.name(), + members, + new ScimMeta(RESOURCE_TYPE, group.createdAt(), group.updatedAt(), + baseUrl + "/Groups/" + group.id())); + } + + private static List memberIds(List members) { + var ids = new LinkedHashSet(); + for (var member : members) { + ids.add(parseMemberId(member.value())); + } + return new ArrayList<>(ids); + } + + private static List memberIdsFromNode(JsonNode value) { + if (value == null || !value.isArray()) { + throw new ScimInvalidValueException("members value must be an array"); + } + var ids = new ArrayList(); + for (JsonNode member : value) { + var idNode = member.isObject() ? member.get("value") : member; + ids.add(parseMemberId(idNode == null || idNode.isNull() ? null : idNode.asString())); + } + return ids; + } + + private static UUID parseMemberId(String raw) { + if (raw == null || raw.isBlank()) { + throw new ScimInvalidValueException("Member value must be a user id"); + } + try { + return UUID.fromString(raw.trim()); + } catch (IllegalArgumentException ex) { + throw new ScimInvalidValueException("Member value is not a valid user id: " + raw); + } + } + + private static String textOf(JsonNode value) { + if (value == null || value.isNull()) { + return null; + } + return value.isString() ? value.asString() : value.toString(); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasher.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasher.java new file mode 100644 index 00000000..0d33474b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasher.java @@ -0,0 +1,56 @@ +package com.bablsoft.accessflow.scim.internal; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Raw-token generation and SHA-256 hashing for SCIM bearer tokens (#621). A deliberate small + * sibling of {@code security.internal.apikey.ApiKeyHasher} — that class is module-private to the + * security module and cannot be imported here. 32 bytes of {@link SecureRandom} entropy make the + * unsalted hash safe to look up directly (same reasoning as API keys, docs/07-security.md). + */ +final class ScimTokenHasher { + + static final String PREFIX = "af_scim_"; + static final int PREFIX_LENGTH = 12; + private static final int RANDOM_BYTES = 32; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private ScimTokenHasher() { + } + + static String generate() { + var bytes = new byte[RANDOM_BYTES]; + SECURE_RANDOM.nextBytes(bytes); + return PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + static String hash(String rawToken) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + var bytes = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + var sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 not available", ex); + } + } + + static String prefixOf(String rawToken) { + if (rawToken == null || rawToken.length() < PREFIX_LENGTH) { + return rawToken == null ? "" : rawToken; + } + return rawToken.substring(0, PREFIX_LENGTH); + } + + static boolean hasExpectedShape(String rawToken) { + return rawToken != null && rawToken.startsWith(PREFIX) + && rawToken.length() > PREFIX.length(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestrator.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestrator.java new file mode 100644 index 00000000..1fbdc7ff --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestrator.java @@ -0,0 +1,317 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.core.api.CreateExternalUserCommand; +import com.bablsoft.accessflow.core.api.EmailAlreadyExistsException; +import com.bablsoft.accessflow.core.api.ExternalIdAlreadyExistsException; +import com.bablsoft.accessflow.core.api.ExternalUserDirectoryService; +import com.bablsoft.accessflow.core.api.UpdateExternalUserCommand; +import com.bablsoft.accessflow.core.api.UserView; +import com.bablsoft.accessflow.scim.api.ScimConfigService; +import com.bablsoft.accessflow.scim.api.ScimConfigView; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.protocol.ScimEmail; +import com.bablsoft.accessflow.scim.internal.protocol.ScimFilter; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidFilterException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidPathException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidValueException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimListResponse; +import com.bablsoft.accessflow.scim.internal.protocol.ScimMeta; +import com.bablsoft.accessflow.scim.internal.protocol.ScimName; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchOperation; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import com.bablsoft.accessflow.scim.internal.protocol.ScimResourceNotFoundException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimSchemas; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUniquenessException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUserResource; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import tools.jackson.databind.JsonNode; + +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** + * Maps the SCIM User wire contract onto {@link ExternalUserDirectoryService} (#621): attribute + * mapping resolution, filter dispatch, PatchOp application, and the SCIM-owned-attributes-only + * write boundary. Everything org-scoped through the authenticated {@link ScimPrincipal}. + */ +@Service +@RequiredArgsConstructor +public class ScimUserOrchestrator { + + static final String RESOURCE_TYPE = "User"; + + private final ExternalUserDirectoryService directory; + private final ScimConfigService configService; + + public ScimListResponse list(ScimPrincipal principal, String filterExpression, + int startIndex, int count, String baseUrl) { + var config = configService.get(principal.organizationId()); + var normalizedStart = Math.max(1, startIndex); + var normalizedCount = Math.clamp(count, 1, 200); + var filter = com.bablsoft.accessflow.scim.internal.protocol.ScimFilterParser + .parse(filterExpression); + if (filter == null) { + var page = directory.list(principal.organizationId(), normalizedStart - 1, + normalizedCount); + return ScimListResponse.of(page.totalResults(), normalizedStart, + page.content().stream().map(u -> toResource(u, config, baseUrl)).toList()); + } + var match = findByFilter(principal.organizationId(), filter); + return ScimListResponse.of(match == null ? 0 : 1, 1, + match == null ? List.of() : List.of(toResource(match, config, baseUrl))); + } + + public ScimUserResource create(ScimPrincipal principal, ScimUserResource resource, String baseUrl) { + var config = configService.get(principal.organizationId()); + var email = extractEmail(resource, config); + if (email == null || email.isBlank()) { + throw new ScimInvalidValueException( + "Missing required email source attribute: " + config.attrEmail()); + } + try { + var created = directory.createExternal(new CreateExternalUserCommand( + principal.organizationId(), + email, + extractDisplayName(resource, config), + blankToNull(resource.externalId()), + config.defaultRole())); + return toResource(created, config, baseUrl); + } catch (EmailAlreadyExistsException ex) { + throw new ScimUniquenessException("A user with this email already exists"); + } catch (ExternalIdAlreadyExistsException ex) { + throw new ScimUniquenessException("A user with this externalId already exists"); + } + } + + public ScimUserResource get(ScimPrincipal principal, UUID id, String baseUrl) { + var config = configService.get(principal.organizationId()); + return directory.findById(principal.organizationId(), id) + .map(u -> toResource(u, config, baseUrl)) + .orElseThrow(() -> new ScimResourceNotFoundException(RESOURCE_TYPE, id.toString())); + } + + /** PUT — full replace of the SCIM-owned attributes only. */ + public ScimUserWriteResult replace(ScimPrincipal principal, UUID id, ScimUserResource resource, + String baseUrl) { + var config = configService.get(principal.organizationId()); + var existing = loadOrThrow(principal, id); + var email = extractEmail(resource, config); + if (email == null || email.isBlank()) { + throw new ScimInvalidValueException( + "Missing required email source attribute: " + config.attrEmail()); + } + var command = new UpdateExternalUserCommand( + email, + extractDisplayName(resource, config), + blankToNull(resource.externalId()), + resource.active()); + return apply(principal, existing, command, config, baseUrl); + } + + public ScimUserWriteResult patch(ScimPrincipal principal, UUID id, ScimPatchRequest patch, + String baseUrl) { + var config = configService.get(principal.organizationId()); + var existing = loadOrThrow(principal, id); + + String email = null; + String displayName = null; + String externalId = null; + Boolean active = null; + for (var operation : operationsOf(patch)) { + var op = operation.op() == null ? "" : operation.op().toLowerCase(Locale.ROOT); + if (!op.equals("add") && !op.equals("replace")) { + throw new ScimInvalidPathException("Unsupported patch op: " + operation.op()); + } + var path = normalizePath(operation.path()); + var value = operation.value(); + if (path == null) { + // No path: the value is an object of attribute -> new value. + if (value == null || !value.isObject()) { + throw new ScimInvalidValueException("Patch value must be an object"); + } + var it = value.properties().iterator(); + while (it.hasNext()) { + var entry = it.next(); + switch (entry.getKey().toLowerCase(Locale.ROOT)) { + case "active" -> active = parseBoolean(entry.getValue()); + case "displayname" -> displayName = textOf(entry.getValue()); + case "externalid" -> externalId = textOf(entry.getValue()); + case "username" -> email = emailFromUserName(entry.getValue(), config); + default -> { + // Unknown attributes (password, name.*, emails rewrites, enterprise + // extension fields) are ignored — SCIM owns a narrow attribute set. + } + } + } + continue; + } + switch (path) { + case "active" -> active = parseBoolean(value); + case "displayname", "name.formatted" -> displayName = textOf(value); + case "externalid" -> externalId = textOf(value); + case "username" -> email = emailFromUserName(value, config); + default -> { + if (path.startsWith("emails")) { + email = "emails.primary".equals(config.attrEmail()) ? textOf(value) : email; + } + // Other paths are outside the SCIM-owned attribute set — ignored. + } + } + } + var command = new UpdateExternalUserCommand(email, displayName, externalId, active); + return apply(principal, existing, command, config, baseUrl); + } + + /** DELETE — AccessFlow never hard-deletes users; this deactivates (idempotently). */ + public boolean delete(ScimPrincipal principal, UUID id) { + var existing = loadOrThrow(principal, id); + if (!existing.active()) { + return false; + } + directory.updateExternal(principal.organizationId(), id, + new UpdateExternalUserCommand(null, null, null, false)); + return true; + } + + private ScimUserWriteResult apply(ScimPrincipal principal, UserView existing, + UpdateExternalUserCommand command, ScimConfigView config, + String baseUrl) { + try { + var updated = directory.updateExternal(principal.organizationId(), existing.id(), + command); + var deactivated = existing.active() && !updated.active(); + return new ScimUserWriteResult(toResource(updated, config, baseUrl), deactivated); + } catch (EmailAlreadyExistsException ex) { + throw new ScimUniquenessException("A user with this email already exists"); + } catch (ExternalIdAlreadyExistsException ex) { + throw new ScimUniquenessException("A user with this externalId already exists"); + } + } + + private UserView loadOrThrow(ScimPrincipal principal, UUID id) { + return directory.findById(principal.organizationId(), id) + .orElseThrow(() -> new ScimResourceNotFoundException(RESOURCE_TYPE, id.toString())); + } + + private UserView findByFilter(UUID organizationId, ScimFilter filter) { + return switch (filter.attribute()) { + case "username", "emails", "emails.value" -> + directory.findByEmail(organizationId, filter.value()).orElse(null); + case "externalid" -> + directory.findByExternalId(organizationId, filter.value()).orElse(null); + case "id" -> { + try { + yield directory.findById(organizationId, UUID.fromString(filter.value())) + .orElse(null); + } catch (IllegalArgumentException ex) { + yield null; + } + } + default -> throw new ScimInvalidFilterException( + "Unsupported filter attribute: " + filter.attribute()); + }; + } + + private String extractEmail(ScimUserResource resource, ScimConfigView config) { + if ("emails.primary".equals(config.attrEmail())) { + var emails = resource.emails(); + if (emails == null || emails.isEmpty()) { + return null; + } + return emails.stream() + .filter(e -> Boolean.TRUE.equals(e.primary())) + .map(ScimEmail::value) + .findFirst() + .orElse(emails.get(0).value()); + } + return resource.userName(); + } + + private String extractDisplayName(ScimUserResource resource, ScimConfigView config) { + var mapped = switch (config.attrDisplayName()) { + case "name.formatted" -> resource.name() == null ? null : resource.name().formatted(); + case "userName" -> resource.userName(); + default -> resource.displayName(); + }; + if (mapped != null && !mapped.isBlank()) { + return mapped; + } + if (resource.displayName() != null && !resource.displayName().isBlank()) { + return resource.displayName(); + } + if (resource.name() != null && resource.name().formatted() != null + && !resource.name().formatted().isBlank()) { + return resource.name().formatted(); + } + return blankToNull(resource.userName()); + } + + private String emailFromUserName(JsonNode value, ScimConfigView config) { + // userName is the email source unless the mapping reads emails.primary. + return "userName".equals(config.attrEmail()) ? textOf(value) : null; + } + + static ScimUserResource toResource(UserView user, ScimConfigView config, String baseUrl) { + return new ScimUserResource( + List.of(ScimSchemas.USER), + user.id().toString(), + user.scimExternalId(), + user.email(), + user.displayName(), + user.displayName() == null ? null : new ScimName(user.displayName(), null, null), + List.of(new ScimEmail(user.email(), "work", true)), + user.active(), + new ScimMeta(RESOURCE_TYPE, user.createdAt(), + user.updatedAt() != null ? user.updatedAt() : user.createdAt(), + baseUrl + "/Users/" + user.id())); + } + + private static List operationsOf(ScimPatchRequest patch) { + if (patch == null || patch.operations() == null || patch.operations().isEmpty()) { + throw new ScimInvalidValueException("PatchOp must carry at least one operation"); + } + return patch.operations(); + } + + private static String normalizePath(String path) { + if (path == null || path.isBlank()) { + return null; + } + var lastColon = path.lastIndexOf(':'); + var stripped = lastColon >= 0 ? path.substring(lastColon + 1) : path; + return stripped.toLowerCase(Locale.ROOT); + } + + /** Entra sends booleans as strings ("True"/"False"); Okta sends real booleans. */ + static Boolean parseBoolean(JsonNode value) { + if (value == null || value.isNull()) { + throw new ScimInvalidValueException("Missing boolean value"); + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isString()) { + var text = value.asString().trim().toLowerCase(Locale.ROOT); + if (text.equals("true")) { + return true; + } + if (text.equals("false")) { + return false; + } + } + throw new ScimInvalidValueException("Expected a boolean value"); + } + + private static String textOf(JsonNode value) { + if (value == null || value.isNull()) { + return null; + } + return value.isString() ? value.asString() : value.toString(); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimUserWriteResult.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimUserWriteResult.java new file mode 100644 index 00000000..ef39ca9b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimUserWriteResult.java @@ -0,0 +1,11 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.scim.internal.protocol.ScimUserResource; + +/** + * Outcome of a SCIM user mutation (#621): {@code deactivated} is true only when this call flipped + * the user active → inactive, so the controller can audit {@code SCIM_USER_DEACTIVATED} instead of + * {@code SCIM_USER_UPDATED}. + */ +public record ScimUserWriteResult(ScimUserResource resource, boolean deactivated) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/config/ScimSecurityConfiguration.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/config/ScimSecurityConfiguration.java new file mode 100644 index 00000000..3d9d5c19 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/config/ScimSecurityConfiguration.java @@ -0,0 +1,39 @@ +package com.bablsoft.accessflow.scim.internal.config; + +import com.bablsoft.accessflow.scim.internal.web.scim.ScimAuthenticationEntryPoint; +import com.bablsoft.accessflow.scim.internal.web.scim.ScimTokenAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * The {@code /scim/v2/**} security chain (#621). {@code @Order(0)} wins over the security + * module's chains (1 = SAML, 2 = OAuth2, 3 = catch-all) so SCIM traffic never reaches the JWT + * filter or the ProblemDetail-emitting entry point. CORS is deliberately disabled: SCIM is + * server-to-server from the IdP's provisioning engine — a browser never calls it (same reasoning + * as the SAML chain). + */ +@Configuration(proxyBeanMethods = false) +class ScimSecurityConfiguration { + + @Bean + @Order(0) + SecurityFilterChain scimFilterChain(HttpSecurity http, + ScimTokenAuthenticationFilter tokenFilter, + ScimAuthenticationEntryPoint entryPoint) throws Exception { + http + .securityMatcher("/scim/v2/**") + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .exceptionHandling(ex -> ex.authenticationEntryPoint(entryPoint)) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/config/ScimWebConfiguration.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/config/ScimWebConfiguration.java new file mode 100644 index 00000000..a85a0629 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/config/ScimWebConfiguration.java @@ -0,0 +1,33 @@ +package com.bablsoft.accessflow.scim.internal.config; + +import com.bablsoft.accessflow.scim.internal.web.scim.ScimMediaTypes; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.ArrayList; +import java.util.List; + +/** + * Teaches the Jackson HTTP converter the {@code application/scim+json} media type (#621), so IdPs + * sending SCIM content types get normal (de)serialization. Purely additive — plain JSON handling + * is unchanged. + */ +@Configuration(proxyBeanMethods = false) +class ScimWebConfiguration implements WebMvcConfigurer { + + @Override + public void extendMessageConverters(List> converters) { + for (var converter : converters) { + if (converter instanceof JacksonJsonHttpMessageConverter jackson) { + var mediaTypes = new ArrayList(jackson.getSupportedMediaTypes()); + if (!mediaTypes.contains(ScimMediaTypes.SCIM_JSON)) { + mediaTypes.add(ScimMediaTypes.SCIM_JSON); + jackson.setSupportedMediaTypes(mediaTypes); + } + } + } + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/entity/ScimConfigEntity.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/entity/ScimConfigEntity.java new file mode 100644 index 00000000..b5d390fe --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/entity/ScimConfigEntity.java @@ -0,0 +1,62 @@ +package com.bablsoft.accessflow.scim.internal.persistence.entity; + +import com.bablsoft.accessflow.core.api.UserRoleType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.type.PostgreSQLEnumJdbcType; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "scim_config") +@Getter +@Setter +@NoArgsConstructor +public class ScimConfigEntity { + + @Id + private UUID id; + + @Column(name = "organization_id", nullable = false, unique = true) + private UUID organizationId; + + @Column(nullable = false) + private boolean enabled = false; + + @Column(name = "attr_email", nullable = false, length = 255) + private String attrEmail = "userName"; + + @Column(name = "attr_display_name", nullable = false, length = 255) + private String attrDisplayName = "displayName"; + + @Enumerated(EnumType.STRING) + @JdbcType(PostgreSQLEnumJdbcType.class) + @Column(name = "default_role", nullable = false, columnDefinition = "user_role_type") + private UserRoleType defaultRole = UserRoleType.ANALYST; + + @Version + @Column(nullable = false) + private long version; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/entity/ScimTokenEntity.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/entity/ScimTokenEntity.java new file mode 100644 index 00000000..8bb726d0 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/entity/ScimTokenEntity.java @@ -0,0 +1,49 @@ +package com.bablsoft.accessflow.scim.internal.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "scim_tokens") +@Getter +@Setter +@NoArgsConstructor +public class ScimTokenEntity { + + @Id + private UUID id; + + @Column(name = "organization_id", nullable = false) + private UUID organizationId; + + @Column(nullable = false, length = 100) + private String name; + + @Column(name = "token_prefix", nullable = false, length = 16) + private String tokenPrefix; + + @JsonIgnore + @Column(name = "token_hash", nullable = false, unique = true, length = 128) + private String tokenHash; + + @Column(name = "created_by") + private UUID createdBy; + + @Column(name = "last_used_at") + private Instant lastUsedAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/repo/ScimConfigRepository.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/repo/ScimConfigRepository.java new file mode 100644 index 00000000..03c047b0 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/repo/ScimConfigRepository.java @@ -0,0 +1,14 @@ +package com.bablsoft.accessflow.scim.internal.persistence.repo; + +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimConfigEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface ScimConfigRepository extends JpaRepository { + + Optional findByOrganizationId(UUID organizationId); + + boolean existsByOrganizationIdAndEnabledTrue(UUID organizationId); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/repo/ScimTokenRepository.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/repo/ScimTokenRepository.java new file mode 100644 index 00000000..f968e764 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/persistence/repo/ScimTokenRepository.java @@ -0,0 +1,19 @@ +package com.bablsoft.accessflow.scim.internal.persistence.repo; + +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimTokenEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ScimTokenRepository extends JpaRepository { + + List findAllByOrganizationIdOrderByCreatedAtDesc(UUID organizationId); + + Optional findByTokenHash(String tokenHash); + + Optional findByOrganizationIdAndId(UUID organizationId, UUID id); + + boolean existsByOrganizationIdAndName(UUID organizationId, String name); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimEmail.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimEmail.java new file mode 100644 index 00000000..162f8d4c --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimEmail.java @@ -0,0 +1,12 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimEmail(String value, String type, Boolean primary) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimError.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimError.java new file mode 100644 index 00000000..ccfbd848 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimError.java @@ -0,0 +1,17 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +/** The SCIM error envelope — used instead of RFC 9457 ProblemDetail on {@code /scim/v2/**}. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimError(List schemas, String status, String scimType, String detail) { + + public static ScimError of(int status, String scimType, String detail) { + return new ScimError(List.of(ScimSchemas.ERROR), String.valueOf(status), scimType, detail); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilter.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilter.java new file mode 100644 index 00000000..d6aff366 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilter.java @@ -0,0 +1,5 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +/** A parsed equality filter: {@code attribute} lowercased, sub-attribute paths preserved. */ +public record ScimFilter(String attribute, String value) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParser.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParser.java new file mode 100644 index 00000000..5ef5f692 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParser.java @@ -0,0 +1,43 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Parses the only filter form the supported IdPs send: {@code attribute eq "value"} (#621). + * Anything else — other operators, and/or/not, grouping — is rejected with + * {@code scimType=invalidFilter}, which provisioning engines handle gracefully. + */ +public final class ScimFilterParser { + + private static final Pattern EQ_FILTER = Pattern.compile( + "^\\s*([A-Za-z0-9:._$-]+)\\s+eq\\s+\"((?:[^\"\\\\]|\\\\.)*)\"\\s*$", + Pattern.CASE_INSENSITIVE); + + private ScimFilterParser() { + } + + /** + * @return the parsed filter, or null when {@code filter} is null/blank (no filtering) + * @throws ScimInvalidFilterException on any non-{@code eq} expression + */ + public static ScimFilter parse(String filter) { + if (filter == null || filter.isBlank()) { + return null; + } + var matcher = EQ_FILTER.matcher(filter); + if (!matcher.matches()) { + throw new ScimInvalidFilterException( + "Unsupported filter expression; only 'attribute eq \"value\"' is supported"); + } + var attribute = stripUrnPrefix(matcher.group(1)).toLowerCase(Locale.ROOT); + var value = matcher.group(2).replace("\\\"", "\"").replace("\\\\", "\\"); + return new ScimFilter(attribute, value); + } + + /** {@code urn:ietf:params:scim:schemas:core:2.0:User:userName} → {@code userName}. */ + private static String stripUrnPrefix(String attribute) { + var lastColon = attribute.lastIndexOf(':'); + return lastColon >= 0 ? attribute.substring(lastColon + 1) : attribute; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimGroupResource.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimGroupResource.java new file mode 100644 index 00000000..dbd67066 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimGroupResource.java @@ -0,0 +1,22 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +/** The SCIM Group resource, pragmatic subset (#621). */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimGroupResource( + List schemas, + String id, + String externalId, + String displayName, + List members, + ScimMeta meta +) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidFilterException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidFilterException.java new file mode 100644 index 00000000..ca0cfd40 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidFilterException.java @@ -0,0 +1,8 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +public final class ScimInvalidFilterException extends ScimProtocolException { + + public ScimInvalidFilterException(String detail) { + super(400, "invalidFilter", detail); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidPathException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidPathException.java new file mode 100644 index 00000000..03ab08ce --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidPathException.java @@ -0,0 +1,8 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +public final class ScimInvalidPathException extends ScimProtocolException { + + public ScimInvalidPathException(String detail) { + super(400, "invalidPath", detail); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidValueException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidValueException.java new file mode 100644 index 00000000..9e96bb2f --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimInvalidValueException.java @@ -0,0 +1,8 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +public final class ScimInvalidValueException extends ScimProtocolException { + + public ScimInvalidValueException(String detail) { + super(400, "invalidValue", detail); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimListResponse.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimListResponse.java new file mode 100644 index 00000000..d2184994 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimListResponse.java @@ -0,0 +1,21 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimListResponse( + List schemas, + long totalResults, + int startIndex, + int itemsPerPage, + @JsonProperty("Resources") List resources +) { + public static ScimListResponse of(long totalResults, int startIndex, List resources) { + return new ScimListResponse<>(List.of(ScimSchemas.LIST_RESPONSE), totalResults, startIndex, + resources.size(), resources); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimMemberRef.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimMemberRef.java new file mode 100644 index 00000000..3b47ef6b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimMemberRef.java @@ -0,0 +1,13 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +/** A group member reference; {@code value} is the member's AccessFlow user UUID. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimMemberRef(String value, String display) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimMeta.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimMeta.java new file mode 100644 index 00000000..0f4daf89 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimMeta.java @@ -0,0 +1,13 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.Instant; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimMeta(String resourceType, Instant created, Instant lastModified, + String location) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimName.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimName.java new file mode 100644 index 00000000..5651d084 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimName.java @@ -0,0 +1,12 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimName(String formatted, String givenName, String familyName) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimPatchOperation.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimPatchOperation.java new file mode 100644 index 00000000..6613efc7 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimPatchOperation.java @@ -0,0 +1,12 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +/** One PatchOp operation; {@code value} stays a raw tree — its shape depends on op and path. */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimPatchOperation(String op, String path, JsonNode value) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimPatchRequest.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimPatchRequest.java new file mode 100644 index 00000000..3adcbb3c --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimPatchRequest.java @@ -0,0 +1,16 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimPatchRequest( + List schemas, + @JsonProperty("Operations") List operations +) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimProtocolException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimProtocolException.java new file mode 100644 index 00000000..998e4463 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimProtocolException.java @@ -0,0 +1,27 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +/** + * A SCIM-protocol-level failure (#621), rendered as the SCIM error envelope by + * {@code ScimErrorHandler}. Detail strings are deliberately not localized — the consumer is an + * IdP provisioning engine, and operators debug sync against fixed-language messages. + */ +public abstract class ScimProtocolException extends RuntimeException { + + private final int status; + private final String scimType; + + protected ScimProtocolException(int status, String scimType, String detail) { + super(detail); + this.status = status; + this.scimType = scimType; + } + + public int status() { + return status; + } + + /** RFC 7644 {@code scimType}, or null when the status alone carries the meaning. */ + public String scimType() { + return scimType; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimResourceNotFoundException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimResourceNotFoundException.java new file mode 100644 index 00000000..74a9cf32 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimResourceNotFoundException.java @@ -0,0 +1,8 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +public final class ScimResourceNotFoundException extends ScimProtocolException { + + public ScimResourceNotFoundException(String resourceType, String id) { + super(404, null, resourceType + " " + id + " not found"); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimSchemas.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimSchemas.java new file mode 100644 index 00000000..0377b064 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimSchemas.java @@ -0,0 +1,18 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +/** SCIM 2.0 schema URNs (RFC 7643/7644) used by the pragmatic subset (#621). */ +public final class ScimSchemas { + + public static final String USER = "urn:ietf:params:scim:schemas:core:2.0:User"; + public static final String GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group"; + public static final String LIST_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; + public static final String PATCH_OP = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; + public static final String ERROR = "urn:ietf:params:scim:api:messages:2.0:Error"; + public static final String SERVICE_PROVIDER_CONFIG = + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"; + public static final String RESOURCE_TYPE = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"; + public static final String SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Schema"; + + private ScimSchemas() { + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimUniquenessException.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimUniquenessException.java new file mode 100644 index 00000000..6090decb --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimUniquenessException.java @@ -0,0 +1,8 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +public final class ScimUniquenessException extends ScimProtocolException { + + public ScimUniquenessException(String detail) { + super(409, "uniqueness", detail); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimUserResource.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimUserResource.java new file mode 100644 index 00000000..93cdfe46 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/protocol/ScimUserResource.java @@ -0,0 +1,29 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; + +/** + * The SCIM User resource, pragmatic subset (#621). Deliberately has no password-shaped field: + * AccessFlow never accepts or emits credentials over SCIM — unknown request fields (including + * {@code password}, which Okta may send) are ignored on read and can never be echoed back. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public record ScimUserResource( + List schemas, + String id, + String externalId, + String userName, + String displayName, + ScimName name, + List emails, + Boolean active, + ScimMeta meta +) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/CreateScimTokenRequest.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/CreateScimTokenRequest.java new file mode 100644 index 00000000..754023a8 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/CreateScimTokenRequest.java @@ -0,0 +1,10 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +record CreateScimTokenRequest( + @NotBlank(message = "{validation.scim.token_name_required}") + @Size(max = 100, message = "{validation.scim.token_name_size}") + String name) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/CreatedScimTokenResponse.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/CreatedScimTokenResponse.java new file mode 100644 index 00000000..a6ace86a --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/CreatedScimTokenResponse.java @@ -0,0 +1,12 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.scim.api.IssuedScimToken; + +/** {@code rawToken} appears here and nowhere else — it is not recoverable afterwards. */ +record CreatedScimTokenResponse(ScimTokenResponse token, String rawToken) { + + static CreatedScimTokenResponse from(IssuedScimToken issued) { + return new CreatedScimTokenResponse(ScimTokenResponse.from(issued.token()), + issued.rawToken()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminConfigController.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminConfigController.java new file mode 100644 index 00000000..7ecd3f9e --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminConfigController.java @@ -0,0 +1,79 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditEntry; +import com.bablsoft.accessflow.audit.api.AuditLogService; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.scim.api.ScimConfigService; +import com.bablsoft.accessflow.security.api.JwtClaims; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/admin/scim-config") +@PreAuthorize("hasAuthority('PERM_SSO_CONFIGURE')") +@Tag(name = "Admin SCIM Config", description = "SCIM 2.0 provisioning settings (#621)") +@RequiredArgsConstructor +@Slf4j +class ScimAdminConfigController { + + private final ScimConfigService configService; + private final AuditLogService auditLogService; + + @GetMapping + @Operation(summary = "Get the organization's SCIM provisioning configuration") + @ApiResponse(responseCode = "200", description = "The configuration (defaults when unset)") + @ApiResponse(responseCode = "403", description = "Caller lacks SSO_CONFIGURE") + ScimConfigResponse getConfig(Authentication authentication) { + var caller = currentClaims(authentication); + return ScimConfigResponse.from(configService.get(caller.organizationId())); + } + + @PutMapping + @Operation(summary = "Update the organization's SCIM provisioning configuration") + @ApiResponse(responseCode = "200", description = "Updated configuration") + @ApiResponse(responseCode = "400", description = "Validation error") + ScimConfigResponse updateConfig(@Valid @RequestBody UpdateScimConfigRequest request, + Authentication authentication, + RequestAuditContext auditContext) { + var caller = currentClaims(authentication); + var updated = configService.update(caller.organizationId(), request.toCommand()); + recordAudit(caller, auditContext, Map.of("enabled", updated.enabled())); + return ScimConfigResponse.from(updated); + } + + private void recordAudit(JwtClaims caller, RequestAuditContext auditContext, + Map metadata) { + try { + auditLogService.record(new AuditEntry( + AuditAction.SCIM_CONFIG_UPDATED, + AuditResourceType.SCIM_CONFIG, + null, + caller.organizationId(), + caller.userId(), + metadata, + auditContext.ipAddress(), + auditContext.userAgent())); + } catch (RuntimeException ex) { + log.error("Audit write failed for SCIM_CONFIG_UPDATED", ex); + } + } + + private JwtClaims currentClaims(Authentication authentication) { + return (JwtClaims) authentication.getPrincipal(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminExceptionHandler.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminExceptionHandler.java new file mode 100644 index 00000000..6058cb7e --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminExceptionHandler.java @@ -0,0 +1,59 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.scim.api.ScimInvalidMappingException; +import com.bablsoft.accessflow.scim.api.ScimTokenNameConflictException; +import com.bablsoft.accessflow.scim.api.ScimTokenNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; + +/** + * ProblemDetail mapping for the SCIM admin endpoints (#621). Higher precedence than the security + * module's GlobalExceptionHandler, whose Exception.class catch-all would otherwise win. The + * /scim/v2 protocol surface has its own envelope — see {@code ScimErrorHandler}. + */ +@RestControllerAdvice(assignableTypes = { + ScimAdminConfigController.class, ScimAdminTokenController.class}) +@Order(Ordered.HIGHEST_PRECEDENCE) +@RequiredArgsConstructor +class ScimAdminExceptionHandler { + + private final MessageSource messageSource; + + @ExceptionHandler(ScimTokenNotFoundException.class) + ProblemDetail handleTokenNotFound(ScimTokenNotFoundException ex) { + return problem(HttpStatus.NOT_FOUND, msg("error.scim_token_not_found"), + "SCIM_TOKEN_NOT_FOUND"); + } + + @ExceptionHandler(ScimTokenNameConflictException.class) + ProblemDetail handleTokenNameConflict(ScimTokenNameConflictException ex) { + return problem(HttpStatus.CONFLICT, msg("error.scim_token_name_conflict"), + "SCIM_TOKEN_NAME_CONFLICT"); + } + + @ExceptionHandler(ScimInvalidMappingException.class) + ProblemDetail handleInvalidMapping(ScimInvalidMappingException ex) { + return problem(HttpStatus.BAD_REQUEST, msg("error.scim_invalid_mapping"), + "SCIM_INVALID_MAPPING"); + } + + private String msg(String key) { + return messageSource.getMessage(key, null, LocaleContextHolder.getLocale()); + } + + private static ProblemDetail problem(HttpStatus status, String detail, String errorCode) { + var pd = ProblemDetail.forStatusAndDetail(status, detail); + pd.setProperty("error", errorCode); + pd.setProperty("timestamp", Instant.now()); + return pd; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminTokenController.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminTokenController.java new file mode 100644 index 00000000..dfc1309c --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminTokenController.java @@ -0,0 +1,102 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditEntry; +import com.bablsoft.accessflow.audit.api.AuditLogService; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.scim.api.ScimTokenService; +import com.bablsoft.accessflow.security.api.JwtClaims; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/admin/scim/tokens") +@PreAuthorize("hasAuthority('PERM_SSO_CONFIGURE')") +@Tag(name = "Admin SCIM Tokens", description = "SCIM bearer token management (#621)") +@RequiredArgsConstructor +@Slf4j +class ScimAdminTokenController { + + private final ScimTokenService tokenService; + private final AuditLogService auditLogService; + + @GetMapping + @Operation(summary = "List the organization's SCIM bearer tokens (prefixes only)") + @ApiResponse(responseCode = "200", description = "Tokens, newest first") + List list(Authentication authentication) { + var caller = currentClaims(authentication); + return tokenService.list(caller.organizationId()).stream() + .map(ScimTokenResponse::from) + .toList(); + } + + @PostMapping + @Operation(summary = "Create a SCIM bearer token — the raw value is returned exactly once") + @ApiResponse(responseCode = "201", description = "Token created; response carries the raw value") + @ApiResponse(responseCode = "400", description = "Validation error") + @ApiResponse(responseCode = "409", description = "A token with this name already exists") + ResponseEntity create( + @Valid @RequestBody CreateScimTokenRequest request, + Authentication authentication, + RequestAuditContext auditContext) { + var caller = currentClaims(authentication); + var issued = tokenService.create(caller.organizationId(), request.name(), caller.userId()); + recordAudit(AuditAction.SCIM_TOKEN_CREATED, issued.token().id(), caller, auditContext, + Map.of("name", issued.token().name())); + return ResponseEntity.status(HttpStatus.CREATED) + .body(CreatedScimTokenResponse.from(issued)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Revoke a SCIM bearer token (idempotent)") + @ApiResponse(responseCode = "204", description = "Token revoked") + @ApiResponse(responseCode = "404", description = "Unknown token in this organization") + ResponseEntity revoke(@PathVariable UUID id, Authentication authentication, + RequestAuditContext auditContext) { + var caller = currentClaims(authentication); + tokenService.revoke(caller.organizationId(), id); + recordAudit(AuditAction.SCIM_TOKEN_REVOKED, id, caller, auditContext, Map.of()); + return ResponseEntity.noContent().build(); + } + + private void recordAudit(AuditAction action, UUID resourceId, JwtClaims caller, + RequestAuditContext auditContext, Map metadata) { + try { + auditLogService.record(new AuditEntry( + action, + AuditResourceType.SCIM_TOKEN, + resourceId, + caller.organizationId(), + caller.userId(), + metadata, + auditContext.ipAddress(), + auditContext.userAgent())); + } catch (RuntimeException ex) { + log.error("Audit write failed for {} on {}", action, resourceId, ex); + } + } + + private JwtClaims currentClaims(Authentication authentication) { + return (JwtClaims) authentication.getPrincipal(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimConfigResponse.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimConfigResponse.java new file mode 100644 index 00000000..2a74c8ee --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimConfigResponse.java @@ -0,0 +1,30 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.scim.api.ScimConfigView; + +import java.time.Instant; +import java.util.UUID; + +record ScimConfigResponse( + UUID id, + UUID organizationId, + boolean enabled, + String attrEmail, + String attrDisplayName, + UserRoleType defaultRole, + Instant createdAt, + Instant updatedAt) { + + static ScimConfigResponse from(ScimConfigView view) { + return new ScimConfigResponse( + view.id(), + view.organizationId(), + view.enabled(), + view.attrEmail(), + view.attrDisplayName(), + view.defaultRole(), + view.createdAt(), + view.updatedAt()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimTokenResponse.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimTokenResponse.java new file mode 100644 index 00000000..3981a647 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimTokenResponse.java @@ -0,0 +1,25 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.scim.api.ScimTokenView; + +import java.time.Instant; +import java.util.UUID; + +record ScimTokenResponse( + UUID id, + String name, + String tokenPrefix, + Instant createdAt, + Instant lastUsedAt, + Instant revokedAt) { + + static ScimTokenResponse from(ScimTokenView view) { + return new ScimTokenResponse( + view.id(), + view.name(), + view.tokenPrefix(), + view.createdAt(), + view.lastUsedAt(), + view.revokedAt()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/UpdateScimConfigRequest.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/UpdateScimConfigRequest.java new file mode 100644 index 00000000..79491854 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/admin/UpdateScimConfigRequest.java @@ -0,0 +1,20 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.scim.api.UpdateScimConfigCommand; +import jakarta.validation.constraints.Pattern; + +record UpdateScimConfigRequest( + Boolean enabled, + @Pattern(regexp = "userName|emails\\.primary", + message = "{validation.scim.attr_email}") + String attrEmail, + @Pattern(regexp = "displayName|name\\.formatted|userName", + message = "{validation.scim.attr_display_name}") + String attrDisplayName, + UserRoleType defaultRole) { + + UpdateScimConfigCommand toCommand() { + return new UpdateScimConfigCommand(enabled, attrEmail, attrDisplayName, defaultRole); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriter.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriter.java new file mode 100644 index 00000000..f0c1b425 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriter.java @@ -0,0 +1,49 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditEntry; +import com.bablsoft.accessflow.audit.api.AuditLogService; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Synchronous audit writes for SCIM-driven mutations (#621). The actor is the IdP's provisioning + * engine, not a user — {@code actorId} is null and the token's identity travels in the metadata. + * Failures are swallowed: audit problems must never surface as SCIM errors to the IdP. + */ +@Component +@RequiredArgsConstructor +@Slf4j +class ScimAuditWriter { + + private final AuditLogService auditLogService; + + void record(AuditAction action, AuditResourceType resourceType, UUID resourceId, + ScimPrincipal principal, Map metadata, + RequestAuditContext auditContext) { + try { + var enriched = new HashMap(metadata); + enriched.put("scim_token_id", principal.tokenId().toString()); + enriched.put("scim_token_name", principal.tokenName()); + auditLogService.record(new AuditEntry( + action, + resourceType, + resourceId, + principal.organizationId(), + null, + enriched, + auditContext.ipAddress(), + auditContext.userAgent())); + } catch (RuntimeException ex) { + log.error("Audit write failed for {} on {}", action, resourceId, ex); + } + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPoint.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPoint.java new file mode 100644 index 00000000..b9069a87 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.scim.internal.protocol.ScimError; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; + +/** + * 401 in the SCIM error envelope (#621) — IdP provisioning engines parse + * {@code urn:ietf:params:scim:api:messages:2.0:Error}, not RFC 9457 ProblemDetail, so the shared + * {@code SecurityExceptionHandler} must not answer on this chain. + */ +@Component +@RequiredArgsConstructor +public class ScimAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(ScimMediaTypes.SCIM_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString( + ScimError.of(401, null, "Invalid or missing SCIM bearer token"))); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationToken.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationToken.java new file mode 100644 index 00000000..ccebf3cc --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationToken.java @@ -0,0 +1,33 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +/** + * The authenticated identity of a SCIM request (#621): a per-org bearer token, not a user. The + * single {@code SCIM} authority never overlaps the {@code PERM_*}/{@code ROLE_*} space, so a SCIM + * token can never reach a JWT-guarded endpoint even if a request escaped the /scim/v2 chain. + */ +public class ScimAuthenticationToken extends AbstractAuthenticationToken { + + private final ScimPrincipal principal; + + public ScimAuthenticationToken(ScimPrincipal principal) { + super(List.of(new SimpleGrantedAuthority("SCIM"))); + this.principal = principal; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public ScimPrincipal getPrincipal() { + return principal; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryController.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryController.java new file mode 100644 index 00000000..72b5140f --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryController.java @@ -0,0 +1,88 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.scim.internal.protocol.ScimSchemas; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.util.List; +import java.util.Map; + +/** + * SCIM 2.0 discovery endpoints (#621). IdPs call these once during integration setup to learn + * the server's capabilities: PATCH is supported; bulk, sorting, ETags, and password changes are + * not; filtering is capped at 200 results. + */ +@RestController +@RequestMapping(path = "/scim/v2", produces = {ScimMediaTypes.SCIM_JSON_VALUE, "application/json"}) +@Tag(name = "SCIM Discovery", description = "SCIM 2.0 capability and schema discovery") +class ScimDiscoveryController { + + @GetMapping("/ServiceProviderConfig") + @Operation(summary = "SCIM capability discovery") + @ApiResponse(responseCode = "200", description = "Service provider configuration") + Map serviceProviderConfig() { + return Map.of( + "schemas", List.of(ScimSchemas.SERVICE_PROVIDER_CONFIG), + "documentationUri", "https://accessflow.dev/docs/configuration/auth/#cfg-scim", + "patch", Map.of("supported", true), + "bulk", Map.of("supported", false, "maxOperations", 0, "maxPayloadSize", 0), + "filter", Map.of("supported", true, "maxResults", 200), + "changePassword", Map.of("supported", false), + "sort", Map.of("supported", false), + "etag", Map.of("supported", false), + "authenticationSchemes", List.of(Map.of( + "type", "oauthbearertoken", + "name", "Bearer token", + "description", "Long-lived per-organization bearer token issued by an " + + "AccessFlow admin"))); + } + + @GetMapping("/ResourceTypes") + @Operation(summary = "SCIM resource-type discovery") + @ApiResponse(responseCode = "200", description = "Supported resource types") + List> resourceTypes() { + var base = baseUrl(); + return List.of( + Map.of( + "schemas", List.of(ScimSchemas.RESOURCE_TYPE), + "id", "User", + "name", "User", + "endpoint", "/Users", + "schema", ScimSchemas.USER, + "meta", Map.of("resourceType", "ResourceType", + "location", base + "/ResourceTypes/User")), + Map.of( + "schemas", List.of(ScimSchemas.RESOURCE_TYPE), + "id", "Group", + "name", "Group", + "endpoint", "/Groups", + "schema", ScimSchemas.GROUP, + "meta", Map.of("resourceType", "ResourceType", + "location", base + "/ResourceTypes/Group"))); + } + + @GetMapping("/Schemas") + @Operation(summary = "SCIM schema discovery (minimal descriptors)") + @ApiResponse(responseCode = "200", description = "Supported schemas") + List> schemas() { + return List.of( + Map.of( + "id", ScimSchemas.USER, + "name", "User", + "description", "AccessFlow user (SCIM-owned attributes: userName, " + + "displayName, name.formatted, emails, externalId, active)"), + Map.of( + "id", ScimSchemas.GROUP, + "name", "Group", + "description", "AccessFlow user group (displayName, externalId, members)")); + } + + private static String baseUrl() { + return ServletUriComponentsBuilder.fromCurrentContextPath().path("/scim/v2").toUriString(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandler.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandler.java new file mode 100644 index 00000000..4d3897aa --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandler.java @@ -0,0 +1,46 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.core.api.QuotaExceededException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimError; +import com.bablsoft.accessflow.scim.internal.protocol.ScimProtocolException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Renders every {@code /scim/v2/**} failure as the SCIM error envelope (#621) — never RFC 9457 + * ProblemDetail. Scoped by {@code assignableTypes}, so the rest of the application keeps its + * ProblemDetail contract. Detail strings are intentionally not localized (IdP consumer). + */ +@RestControllerAdvice(assignableTypes = { + ScimUserController.class, ScimGroupController.class, ScimDiscoveryController.class}) +@Order(Ordered.HIGHEST_PRECEDENCE) +@Slf4j +class ScimErrorHandler { + + @ExceptionHandler(ScimProtocolException.class) + ResponseEntity handleProtocol(ScimProtocolException ex) { + return ResponseEntity.status(ex.status()) + .contentType(ScimMediaTypes.SCIM_JSON) + .body(ScimError.of(ex.status(), ex.scimType(), ex.getMessage())); + } + + @ExceptionHandler(QuotaExceededException.class) + ResponseEntity handleQuota(QuotaExceededException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .contentType(ScimMediaTypes.SCIM_JSON) + .body(ScimError.of(403, null, "Organization user quota exceeded")); + } + + @ExceptionHandler(RuntimeException.class) + ResponseEntity handleUnexpected(RuntimeException ex) { + log.error("Unexpected error handling SCIM request", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(ScimMediaTypes.SCIM_JSON) + .body(ScimError.of(500, null, "Internal error")); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimGroupController.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimGroupController.java new file mode 100644 index 00000000..01151162 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimGroupController.java @@ -0,0 +1,144 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.ScimGroupOrchestrator; +import com.bablsoft.accessflow.scim.internal.protocol.ScimGroupResource; +import com.bablsoft.accessflow.scim.internal.protocol.ScimListResponse; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.Map; +import java.util.UUID; + +/** + * SCIM 2.0 Groups endpoint (#621). Member writes are tracked with {@code source=SCIM} and never + * touch MANUAL or SSO-IDP memberships. Deleting a group cascades its memberships and group-based + * grants — the correct semantics for "group removed at the IdP". + */ +@RestController +@RequestMapping(path = "/scim/v2/Groups", + produces = {ScimMediaTypes.SCIM_JSON_VALUE, "application/json"}) +@Tag(name = "SCIM Groups", description = "SCIM 2.0 group provisioning for identity providers") +@RequiredArgsConstructor +class ScimGroupController { + + private final ScimGroupOrchestrator orchestrator; + private final ScimAuditWriter auditWriter; + + @GetMapping + @Operation(summary = "List or filter groups (displayName/externalId eq)") + @ApiResponse(responseCode = "200", description = "SCIM ListResponse") + @ApiResponse(responseCode = "400", description = "Unsupported filter (invalidFilter)") + @ApiResponse(responseCode = "401", description = "Invalid or missing bearer token") + ScimListResponse list( + @RequestParam(required = false) String filter, + @RequestParam(defaultValue = "1") int startIndex, + @RequestParam(defaultValue = "100") int count, + Authentication authentication) { + return orchestrator.list(principal(authentication), filter, startIndex, count, baseUrl()); + } + + @PostMapping + @Operation(summary = "Create a group (optionally with members)") + @ApiResponse(responseCode = "201", description = "Group created") + @ApiResponse(responseCode = "409", description = "displayName or externalId already exists (uniqueness)") + ResponseEntity create(@RequestBody ScimGroupResource resource, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var created = orchestrator.create(principal, resource, baseUrl()); + auditGroupSynced(created, principal, auditContext); + return ResponseEntity.created(URI.create(created.meta().location())).body(created); + } + + @GetMapping("/{id}") + @Operation(summary = "Fetch a group with members") + @ApiResponse(responseCode = "200", description = "The group") + @ApiResponse(responseCode = "404", description = "Unknown group in this organization") + ScimGroupResource get(@PathVariable UUID id, Authentication authentication) { + return orchestrator.get(principal(authentication), id, baseUrl()); + } + + @PutMapping("/{id}") + @Operation(summary = "Replace displayName, externalId, and the SCIM-sourced member set") + @ApiResponse(responseCode = "200", description = "Updated group") + @ApiResponse(responseCode = "404", description = "Unknown group in this organization") + @ApiResponse(responseCode = "409", description = "displayName or externalId already exists (uniqueness)") + ScimGroupResource replace(@PathVariable UUID id, + @RequestBody ScimGroupResource resource, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var updated = orchestrator.replace(principal, id, resource, baseUrl()); + auditGroupSynced(updated, principal, auditContext); + return updated; + } + + @PatchMapping("/{id}") + @Operation(summary = "Apply a SCIM PatchOp (members add/remove/replace, displayName, externalId)") + @ApiResponse(responseCode = "200", description = "Updated group") + @ApiResponse(responseCode = "400", description = "Unsupported op/path (invalidPath, invalidValue)") + @ApiResponse(responseCode = "404", description = "Unknown group in this organization") + ScimGroupResource patch(@PathVariable UUID id, + @RequestBody ScimPatchRequest patch, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var updated = orchestrator.patch(principal, id, patch, baseUrl()); + auditGroupSynced(updated, principal, auditContext); + return updated; + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete a group (cascades memberships and group-based grants)") + @ApiResponse(responseCode = "204", description = "Group deleted") + @ApiResponse(responseCode = "404", description = "Unknown group in this organization") + ResponseEntity delete(@PathVariable UUID id, Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var deleted = orchestrator.delete(principal, id); + auditWriter.record(AuditAction.SCIM_GROUP_DELETED, AuditResourceType.USER_GROUP, id, + principal, + Map.of("name", String.valueOf(deleted.name()), + "member_count", deleted.memberCount()), + auditContext); + return ResponseEntity.noContent().build(); + } + + private void auditGroupSynced(ScimGroupResource group, ScimPrincipal principal, + RequestAuditContext auditContext) { + auditWriter.record(AuditAction.SCIM_GROUP_SYNCED, AuditResourceType.USER_GROUP, + UUID.fromString(group.id()), principal, + Map.of("name", String.valueOf(group.displayName()), + "member_count", group.members() == null ? 0 : group.members().size()), + auditContext); + } + + private static ScimPrincipal principal(Authentication authentication) { + return (ScimPrincipal) authentication.getPrincipal(); + } + + private static String baseUrl() { + return ServletUriComponentsBuilder.fromCurrentContextPath().path("/scim/v2").toUriString(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimMediaTypes.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimMediaTypes.java new file mode 100644 index 00000000..5520a807 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimMediaTypes.java @@ -0,0 +1,13 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import org.springframework.http.MediaType; + +/** The SCIM media type (RFC 7644 §3.1). Requests and responses accept plain JSON too. */ +public final class ScimMediaTypes { + + public static final String SCIM_JSON_VALUE = "application/scim+json"; + public static final MediaType SCIM_JSON = MediaType.parseMediaType(SCIM_JSON_VALUE); + + private ScimMediaTypes() { + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilter.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilter.java new file mode 100644 index 00000000..f3375a52 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilter.java @@ -0,0 +1,60 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.core.api.OrganizationLookupService; +import com.bablsoft.accessflow.scim.api.ScimConfigService; +import com.bablsoft.accessflow.scim.api.ScimTokenService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Authenticates {@code /scim/v2/**} requests by their per-org bearer token (#621). Enforced per + * request, exactly like the JWT and API-key filters: a revoked token, a disabled SCIM config, or + * a disabled organization all leave the security context empty, and + * {@link ScimAuthenticationEntryPoint} answers 401 in the SCIM error envelope. The org is derived + * from the token — never from the request. + */ +@Component +@RequiredArgsConstructor +public class ScimTokenAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final ScimTokenService tokenService; + private final ScimConfigService configService; + private final OrganizationLookupService organizationLookupService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (SecurityContextHolder.getContext().getAuthentication() == null) { + var rawToken = extractBearer(request); + if (rawToken != null) { + tokenService.authenticate(rawToken) + .filter(p -> configService.isEnabled(p.organizationId())) + .filter(p -> !organizationLookupService.isDisabled(p.organizationId())) + .ifPresent(p -> SecurityContextHolder.getContext() + .setAuthentication(new ScimAuthenticationToken(p))); + } + } + filterChain.doFilter(request, response); + } + + private String extractBearer(HttpServletRequest request) { + var authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { + return null; + } + var candidate = authorization.substring(BEARER_PREFIX.length()).trim(); + return candidate.isEmpty() ? null : candidate; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimUserController.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimUserController.java new file mode 100644 index 00000000..7eaeced1 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimUserController.java @@ -0,0 +1,147 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.ScimUserOrchestrator; +import com.bablsoft.accessflow.scim.internal.ScimUserWriteResult; +import com.bablsoft.accessflow.scim.internal.protocol.ScimListResponse; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUserResource; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.Map; +import java.util.UUID; + +/** + * SCIM 2.0 Users endpoint (#621). Paths are RFC 7644's PascalCase — a deliberate exception to the + * kebab-case rule; authentication is the per-org bearer token chain, not JWT. + */ +@RestController +@RequestMapping(path = "/scim/v2/Users", + produces = {ScimMediaTypes.SCIM_JSON_VALUE, "application/json"}) +@Tag(name = "SCIM Users", description = "SCIM 2.0 user provisioning for identity providers") +@RequiredArgsConstructor +class ScimUserController { + + private final ScimUserOrchestrator orchestrator; + private final ScimAuditWriter auditWriter; + + @GetMapping + @Operation(summary = "List or filter users (userName/externalId/emails eq)") + @ApiResponse(responseCode = "200", description = "SCIM ListResponse") + @ApiResponse(responseCode = "400", description = "Unsupported filter (invalidFilter)") + @ApiResponse(responseCode = "401", description = "Invalid or missing bearer token") + ScimListResponse list( + @RequestParam(required = false) String filter, + @RequestParam(defaultValue = "1") int startIndex, + @RequestParam(defaultValue = "100") int count, + Authentication authentication) { + return orchestrator.list(principal(authentication), filter, startIndex, count, baseUrl()); + } + + @PostMapping + @Operation(summary = "Provision a user") + @ApiResponse(responseCode = "201", description = "User created") + @ApiResponse(responseCode = "403", description = "Organization user quota exceeded") + @ApiResponse(responseCode = "409", description = "Email or externalId already exists (uniqueness)") + ResponseEntity create(@RequestBody ScimUserResource resource, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var created = orchestrator.create(principal, resource, baseUrl()); + auditWriter.record(AuditAction.SCIM_USER_PROVISIONED, AuditResourceType.USER, + UUID.fromString(created.id()), principal, + Map.of("email", created.userName(), + "external_id", String.valueOf(created.externalId())), + auditContext); + return ResponseEntity.created(URI.create(created.meta().location())).body(created); + } + + @GetMapping("/{id}") + @Operation(summary = "Fetch a user") + @ApiResponse(responseCode = "200", description = "The user") + @ApiResponse(responseCode = "404", description = "Unknown user in this organization") + ScimUserResource get(@PathVariable UUID id, Authentication authentication) { + return orchestrator.get(principal(authentication), id, baseUrl()); + } + + @PutMapping("/{id}") + @Operation(summary = "Replace the SCIM-owned attributes of a user") + @ApiResponse(responseCode = "200", description = "Updated user") + @ApiResponse(responseCode = "404", description = "Unknown user in this organization") + @ApiResponse(responseCode = "409", description = "Email or externalId already exists (uniqueness)") + ScimUserResource replace(@PathVariable UUID id, + @RequestBody ScimUserResource resource, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var result = orchestrator.replace(principal, id, resource, baseUrl()); + auditUserWrite(result, id, principal, auditContext); + return result.resource(); + } + + @PatchMapping("/{id}") + @Operation(summary = "Apply a SCIM PatchOp (active, displayName, externalId, mapped email)") + @ApiResponse(responseCode = "200", description = "Updated user") + @ApiResponse(responseCode = "400", description = "Unsupported op/path (invalidPath, invalidValue)") + @ApiResponse(responseCode = "404", description = "Unknown user in this organization") + ScimUserResource patch(@PathVariable UUID id, + @RequestBody ScimPatchRequest patch, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var result = orchestrator.patch(principal, id, patch, baseUrl()); + auditUserWrite(result, id, principal, auditContext); + return result.resource(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Deactivate a user (AccessFlow never hard-deletes)") + @ApiResponse(responseCode = "204", description = "User deactivated (idempotent)") + @ApiResponse(responseCode = "404", description = "Unknown user in this organization") + ResponseEntity delete(@PathVariable UUID id, Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + if (orchestrator.delete(principal, id)) { + auditWriter.record(AuditAction.SCIM_USER_DEACTIVATED, AuditResourceType.USER, id, + principal, Map.of("via", "DELETE"), auditContext); + } + return ResponseEntity.noContent().build(); + } + + private void auditUserWrite(ScimUserWriteResult result, UUID id, ScimPrincipal principal, + RequestAuditContext auditContext) { + var action = result.deactivated() + ? AuditAction.SCIM_USER_DEACTIVATED + : AuditAction.SCIM_USER_UPDATED; + auditWriter.record(action, AuditResourceType.USER, id, principal, + Map.of("email", String.valueOf(result.resource().userName())), auditContext); + } + + private static ScimPrincipal principal(Authentication authentication) { + return (ScimPrincipal) authentication.getPrincipal(); + } + + private static String baseUrl() { + return ServletUriComponentsBuilder.fromCurrentContextPath().path("/scim/v2").toUriString(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/package-info.java b/backend/src/main/java/com/bablsoft/accessflow/scim/package-info.java new file mode 100644 index 00000000..2464ea0b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/package-info.java @@ -0,0 +1,4 @@ +@ApplicationModule(displayName = "SCIM Provisioning") +package com.bablsoft.accessflow.scim; + +import org.springframework.modulith.ApplicationModule; diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index 523bee4d..12530b92 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -993,3 +993,12 @@ validation.discovery.scan_interval.range=Scan interval must be between 1 and 720 validation.discovery.finding_ids.required=At least one finding id is required validation.discovery.finding_ids.max=At most 100 findings can be decided per request validation.discovery.decision.required=Decision is required + +# SCIM provisioning (#621) +error.scim_token_not_found=SCIM token not found +error.scim_token_name_conflict=A SCIM token with this name already exists +error.scim_invalid_mapping=Invalid SCIM attribute mapping +validation.scim.attr_email=Email source must be userName or emails.primary +validation.scim.attr_display_name=Display-name source must be displayName, name.formatted, or userName +validation.scim.token_name_required=Token name is required +validation.scim.token_name_size=Token name must be at most 100 characters diff --git a/backend/src/main/resources/i18n/messages_de.properties b/backend/src/main/resources/i18n/messages_de.properties index 981d3669..439bc650 100644 --- a/backend/src/main/resources/i18n/messages_de.properties +++ b/backend/src/main/resources/i18n/messages_de.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=Das Scan-Intervall muss zwischen 1 und validation.discovery.finding_ids.required=Mindestens eine Finding-ID ist erforderlich validation.discovery.finding_ids.max=Pro Anfrage können höchstens 100 Findings entschieden werden validation.discovery.decision.required=Eine Entscheidung ist erforderlich + +# SCIM provisioning (#621) +error.scim_token_not_found=SCIM-Token nicht gefunden +error.scim_token_name_conflict=Ein SCIM-Token mit diesem Namen existiert bereits +error.scim_invalid_mapping=Ungültige SCIM-Attributzuordnung +validation.scim.attr_email=E-Mail-Quelle muss userName oder emails.primary sein +validation.scim.attr_display_name=Anzeigename-Quelle muss displayName, name.formatted oder userName sein +validation.scim.token_name_required=Token-Name ist erforderlich +validation.scim.token_name_size=Token-Name darf höchstens 100 Zeichen lang sein diff --git a/backend/src/main/resources/i18n/messages_es.properties b/backend/src/main/resources/i18n/messages_es.properties index cf0717ed..1e4883fb 100644 --- a/backend/src/main/resources/i18n/messages_es.properties +++ b/backend/src/main/resources/i18n/messages_es.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=El intervalo de escaneo debe estar entr validation.discovery.finding_ids.required=Se requiere al menos un id de hallazgo validation.discovery.finding_ids.max=Se pueden decidir como máximo 100 hallazgos por solicitud validation.discovery.decision.required=La decisión es obligatoria + +# SCIM provisioning (#621) +error.scim_token_not_found=Token SCIM no encontrado +error.scim_token_name_conflict=Ya existe un token SCIM con este nombre +error.scim_invalid_mapping=Asignación de atributos SCIM no válida +validation.scim.attr_email=La fuente del correo debe ser userName o emails.primary +validation.scim.attr_display_name=La fuente del nombre para mostrar debe ser displayName, name.formatted o userName +validation.scim.token_name_required=El nombre del token es obligatorio +validation.scim.token_name_size=El nombre del token debe tener como máximo 100 caracteres diff --git a/backend/src/main/resources/i18n/messages_fr.properties b/backend/src/main/resources/i18n/messages_fr.properties index 58cde7f7..e739e112 100644 --- a/backend/src/main/resources/i18n/messages_fr.properties +++ b/backend/src/main/resources/i18n/messages_fr.properties @@ -975,3 +975,12 @@ validation.discovery.scan_interval.range=L'intervalle d'analyse doit être compr validation.discovery.finding_ids.required=Au moins un identifiant de découverte est requis validation.discovery.finding_ids.max=Au plus 100 découvertes peuvent être décidées par requête validation.discovery.decision.required=La décision est obligatoire + +# SCIM provisioning (#621) +error.scim_token_not_found=Jeton SCIM introuvable +error.scim_token_name_conflict=Un jeton SCIM portant ce nom existe déjà +error.scim_invalid_mapping=Mappage d'attributs SCIM non valide +validation.scim.attr_email=La source de l'e-mail doit être userName ou emails.primary +validation.scim.attr_display_name=La source du nom d'affichage doit être displayName, name.formatted ou userName +validation.scim.token_name_required=Le nom du jeton est obligatoire +validation.scim.token_name_size=Le nom du jeton ne doit pas dépasser 100 caractères diff --git a/backend/src/main/resources/i18n/messages_hy.properties b/backend/src/main/resources/i18n/messages_hy.properties index 7f422234..125f6aee 100644 --- a/backend/src/main/resources/i18n/messages_hy.properties +++ b/backend/src/main/resources/i18n/messages_hy.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=Սկանավորման միջակայք validation.discovery.finding_ids.required=Անհրաժեշտ է առնվազն մեկ հայտնաբերման նույնացուցիչ validation.discovery.finding_ids.max=Մեկ հարցումով կարելի է որոշել առավելագույնը 100 հայտնաբերում validation.discovery.decision.required=Որոշումը պարտադիր է + +# SCIM provisioning (#621) +error.scim_token_not_found=SCIM թոքենը չի գտնվել +error.scim_token_name_conflict=Այս անունով SCIM թոքեն արդեն գոյություն ունի +error.scim_invalid_mapping=SCIM ատրիբուտների անվավեր համապատասխանեցում +validation.scim.attr_email=Էլ. փոստի աղբյուրը պետք է լինի userName կամ emails.primary +validation.scim.attr_display_name=Ցուցադրվող անվան աղբյուրը պետք է լինի displayName, name.formatted կամ userName +validation.scim.token_name_required=Թոքենի անունը պարտադիր է +validation.scim.token_name_size=Թոքենի անունը պետք է լինի առավելագույնը 100 նիշ diff --git a/backend/src/main/resources/i18n/messages_ru.properties b/backend/src/main/resources/i18n/messages_ru.properties index d47e1939..d80696fb 100644 --- a/backend/src/main/resources/i18n/messages_ru.properties +++ b/backend/src/main/resources/i18n/messages_ru.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=Интервал сканировани validation.discovery.finding_ids.required=Требуется хотя бы один идентификатор находки validation.discovery.finding_ids.max=За один запрос можно решить не более 100 находок validation.discovery.decision.required=Решение обязательно + +# SCIM provisioning (#621) +error.scim_token_not_found=SCIM-токен не найден +error.scim_token_name_conflict=SCIM-токен с таким именем уже существует +error.scim_invalid_mapping=Недопустимое сопоставление атрибутов SCIM +validation.scim.attr_email=Источник email должен быть userName или emails.primary +validation.scim.attr_display_name=Источник отображаемого имени должен быть displayName, name.formatted или userName +validation.scim.token_name_required=Имя токена обязательно +validation.scim.token_name_size=Имя токена должно содержать не более 100 символов diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 4f5e8da8..ac43f501 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=扫描间隔必须介于 1 到 720 小 validation.discovery.finding_ids.required=至少需要一个发现项 ID validation.discovery.finding_ids.max=每次请求最多可决定 100 个发现项 validation.discovery.decision.required=必须提供决定 + +# SCIM provisioning (#621) +error.scim_token_not_found=未找到 SCIM 令牌 +error.scim_token_name_conflict=已存在同名的 SCIM 令牌 +error.scim_invalid_mapping=SCIM 属性映射无效 +validation.scim.attr_email=邮箱来源必须为 userName 或 emails.primary +validation.scim.attr_display_name=显示名称来源必须为 displayName、name.formatted 或 userName +validation.scim.token_name_required=令牌名称为必填项 +validation.scim.token_name_size=令牌名称最多 100 个字符 diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigServiceTest.java new file mode 100644 index 00000000..c534f566 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigServiceTest.java @@ -0,0 +1,105 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.scim.api.ScimInvalidMappingException; +import com.bablsoft.accessflow.scim.api.UpdateScimConfigCommand; +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimConfigEntity; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimConfigRepository; +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.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultScimConfigServiceTest { + + @Mock ScimConfigRepository configRepository; + + DefaultScimConfigService service; + + private final UUID orgId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new DefaultScimConfigService(configRepository); + } + + @Test + void getReturnsDefaultsWhenUnset() { + when(configRepository.findByOrganizationId(orgId)).thenReturn(Optional.empty()); + + var view = service.get(orgId); + + assertThat(view.enabled()).isFalse(); + assertThat(view.attrEmail()).isEqualTo("userName"); + assertThat(view.attrDisplayName()).isEqualTo("displayName"); + assertThat(view.defaultRole()).isEqualTo(UserRoleType.ANALYST); + assertThat(view.id()).isNull(); + } + + @Test + void updateCreatesRowOnFirstSave() { + when(configRepository.findByOrganizationId(orgId)).thenReturn(Optional.empty()); + when(configRepository.save(any(ScimConfigEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var view = service.update(orgId, new UpdateScimConfigCommand( + true, "emails.primary", "name.formatted", UserRoleType.READONLY)); + + assertThat(view.enabled()).isTrue(); + assertThat(view.attrEmail()).isEqualTo("emails.primary"); + assertThat(view.attrDisplayName()).isEqualTo("name.formatted"); + assertThat(view.defaultRole()).isEqualTo(UserRoleType.READONLY); + assertThat(view.organizationId()).isEqualTo(orgId); + } + + @Test + void updateLeavesOmittedFieldsUnchanged() { + var entity = new ScimConfigEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(orgId); + entity.setEnabled(true); + entity.setAttrEmail("emails.primary"); + when(configRepository.findByOrganizationId(orgId)).thenReturn(Optional.of(entity)); + when(configRepository.save(any(ScimConfigEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var view = service.update(orgId, new UpdateScimConfigCommand(null, null, null, null)); + + assertThat(view.enabled()).isTrue(); + assertThat(view.attrEmail()).isEqualTo("emails.primary"); + } + + @Test + void updateRejectsUnknownEmailSource() { + assertThatThrownBy(() -> service.update(orgId, + new UpdateScimConfigCommand(null, "nickName", null, null))) + .isInstanceOf(ScimInvalidMappingException.class); + verify(configRepository, never()).save(any()); + } + + @Test + void updateRejectsUnknownDisplayNameSource() { + assertThatThrownBy(() -> service.update(orgId, + new UpdateScimConfigCommand(null, null, "title", null))) + .isInstanceOf(ScimInvalidMappingException.class); + } + + @Test + void isEnabledDelegatesToRepository() { + when(configRepository.existsByOrganizationIdAndEnabledTrue(orgId)).thenReturn(true); + + assertThat(service.isEnabled(orgId)).isTrue(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenServiceTest.java new file mode 100644 index 00000000..e4debed8 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenServiceTest.java @@ -0,0 +1,149 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.scim.api.ScimTokenNameConflictException; +import com.bablsoft.accessflow.scim.api.ScimTokenNotFoundException; +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimTokenEntity; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimTokenRepository; +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.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultScimTokenServiceTest { + + @Mock ScimTokenRepository tokenRepository; + + DefaultScimTokenService service; + + private final UUID orgId = UUID.randomUUID(); + private final UUID creatorId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new DefaultScimTokenService(tokenRepository); + } + + @Test + void createReturnsRawTokenOnceAndStoresOnlyTheHash() { + when(tokenRepository.existsByOrganizationIdAndName(orgId, "okta-prod")).thenReturn(false); + when(tokenRepository.save(any(ScimTokenEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var issued = service.create(orgId, "okta-prod", creatorId); + + assertThat(issued.rawToken()).startsWith("af_scim_"); + assertThat(issued.token().tokenPrefix()).isEqualTo(issued.rawToken().substring(0, 12)); + assertThat(issued.token().name()).isEqualTo("okta-prod"); + assertThat(issued.token().revokedAt()).isNull(); + } + + @Test + void createRejectsDuplicateName() { + when(tokenRepository.existsByOrganizationIdAndName(orgId, "okta-prod")).thenReturn(true); + + assertThatThrownBy(() -> service.create(orgId, " okta-prod ", creatorId)) + .isInstanceOf(ScimTokenNameConflictException.class); + verify(tokenRepository, never()).save(any()); + } + + @Test + void authenticateResolvesActiveTokenByHash() { + when(tokenRepository.existsByOrganizationIdAndName(orgId, "t")).thenReturn(false); + when(tokenRepository.save(any(ScimTokenEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + var issued = service.create(orgId, "t", creatorId); + var entity = new ScimTokenEntity(); + entity.setId(issued.token().id()); + entity.setOrganizationId(orgId); + entity.setName("t"); + entity.setTokenHash(ScimTokenHasher.hash(issued.rawToken())); + when(tokenRepository.findByTokenHash(ScimTokenHasher.hash(issued.rawToken()))) + .thenReturn(Optional.of(entity)); + + var principal = service.authenticate(issued.rawToken()); + + assertThat(principal).isPresent(); + assertThat(principal.get().organizationId()).isEqualTo(orgId); + assertThat(principal.get().tokenName()).isEqualTo("t"); + assertThat(entity.getLastUsedAt()).isNotNull(); + } + + @Test + void authenticateRejectsRevokedToken() { + var entity = new ScimTokenEntity(); + entity.setOrganizationId(orgId); + entity.setRevokedAt(Instant.now()); + var raw = ScimTokenHasher.generate(); + when(tokenRepository.findByTokenHash(ScimTokenHasher.hash(raw))) + .thenReturn(Optional.of(entity)); + + assertThat(service.authenticate(raw)).isEmpty(); + } + + @Test + void authenticateRejectsUnknownAndMalformedTokens() { + var raw = ScimTokenHasher.generate(); + when(tokenRepository.findByTokenHash(ScimTokenHasher.hash(raw))) + .thenReturn(Optional.empty()); + + assertThat(service.authenticate(raw)).isEmpty(); + assertThat(service.authenticate("not-a-scim-token")).isEmpty(); + assertThat(service.authenticate(null)).isEmpty(); + verify(tokenRepository).findByTokenHash(any()); + } + + @Test + void revokeIsIdempotent() { + var tokenId = UUID.randomUUID(); + var entity = new ScimTokenEntity(); + entity.setId(tokenId); + entity.setOrganizationId(orgId); + entity.setRevokedAt(Instant.parse("2026-01-01T00:00:00Z")); + when(tokenRepository.findByOrganizationIdAndId(orgId, tokenId)) + .thenReturn(Optional.of(entity)); + + service.revoke(orgId, tokenId); + + assertThat(entity.getRevokedAt()).isEqualTo(Instant.parse("2026-01-01T00:00:00Z")); + } + + @Test + void revokeUnknownTokenThrows() { + var tokenId = UUID.randomUUID(); + when(tokenRepository.findByOrganizationIdAndId(orgId, tokenId)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.revoke(orgId, tokenId)) + .isInstanceOf(ScimTokenNotFoundException.class); + } + + @Test + void listMapsEntitiesToViews() { + var entity = new ScimTokenEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(orgId); + entity.setName("entra"); + entity.setTokenPrefix("af_scim_AbCd"); + when(tokenRepository.findAllByOrganizationIdOrderByCreatedAtDesc(orgId)) + .thenReturn(java.util.List.of(entity)); + + var views = service.list(orgId); + + assertThat(views).hasSize(1); + assertThat(views.get(0).name()).isEqualTo("entra"); + assertThat(views.get(0).tokenPrefix()).isEqualTo("af_scim_AbCd"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestratorTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestratorTest.java new file mode 100644 index 00000000..ab3a69a4 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestratorTest.java @@ -0,0 +1,243 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.core.api.UserGroupMembershipSourceType; +import com.bablsoft.accessflow.core.api.UserGroupMembershipView; +import com.bablsoft.accessflow.core.api.UserGroupNameAlreadyExistsException; +import com.bablsoft.accessflow.core.api.UserGroupNotFoundException; +import com.bablsoft.accessflow.core.api.UserGroupService; +import com.bablsoft.accessflow.core.api.UserGroupView; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.protocol.ScimGroupResource; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidValueException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimMemberRef; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import com.bablsoft.accessflow.scim.internal.protocol.ScimResourceNotFoundException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUniquenessException; +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.junit.jupiter.MockitoExtension; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScimGroupOrchestratorTest { + + private static final String BASE = "https://af.example.com/scim/v2"; + + @Mock UserGroupService userGroupService; + + ScimGroupOrchestrator orchestrator; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final UUID orgId = UUID.randomUUID(); + private final UUID groupId = UUID.randomUUID(); + private final ScimPrincipal principal = + new ScimPrincipal(orgId, UUID.randomUUID(), "entra-prod"); + + @BeforeEach + void setUp() { + orchestrator = new ScimGroupOrchestrator(userGroupService); + lenient().when(userGroupService.getGroup(groupId, orgId)) + .thenReturn(groupView(groupId, "Engineers", "grp-ext-1")); + lenient().when(userGroupService.listMembers(groupId, orgId)).thenReturn(List.of()); + } + + @Test + void createGroupWithMembersUsesScimSource() { + var memberId = UUID.randomUUID(); + when(userGroupService.listAll(orgId)).thenReturn(List.of()); + when(userGroupService.createGroup(any())) + .thenReturn(groupView(groupId, "Engineers", "grp-ext-1")); + + var created = orchestrator.create(principal, new ScimGroupResource(null, null, + "grp-ext-1", "Engineers", + List.of(new ScimMemberRef(memberId.toString(), null)), null), BASE); + + verify(userGroupService).replaceMembersBySource(eq(groupId), eq(orgId), + eq(List.of(memberId)), eq(UserGroupMembershipSourceType.SCIM)); + assertThat(created.displayName()).isEqualTo("Engineers"); + assertThat(created.meta().location()).isEqualTo(BASE + "/Groups/" + groupId); + } + + @Test + void createWithoutDisplayNameIsInvalidValue() { + assertThatThrownBy(() -> orchestrator.create(principal, + new ScimGroupResource(null, null, null, " ", null, null), BASE)) + .isInstanceOf(ScimInvalidValueException.class); + } + + @Test + void createDuplicateNameBecomesUniqueness() { + when(userGroupService.createGroup(any())) + .thenThrow(new UserGroupNameAlreadyExistsException("Engineers")); + + assertThatThrownBy(() -> orchestrator.create(principal, + new ScimGroupResource(null, null, null, "Engineers", null, null), BASE)) + .isInstanceOf(ScimUniquenessException.class); + } + + @Test + void createDuplicateExternalIdBecomesUniqueness() { + when(userGroupService.listAll(orgId)) + .thenReturn(List.of(groupView(UUID.randomUUID(), "Other", "grp-ext-1"))); + + assertThatThrownBy(() -> orchestrator.create(principal, + new ScimGroupResource(null, null, "grp-ext-1", "Engineers", null, null), BASE)) + .isInstanceOf(ScimUniquenessException.class); + } + + @Test + void listFiltersByDisplayNameCaseInsensitive() { + when(userGroupService.listAll(orgId)).thenReturn(List.of( + groupView(groupId, "Engineers", null), + groupView(UUID.randomUUID(), "Ops", null))); + + var response = orchestrator.list(principal, "displayName eq \"engineers\"", 1, 100, BASE); + + assertThat(response.totalResults()).isEqualTo(1); + assertThat(response.resources().get(0).displayName()).isEqualTo("Engineers"); + } + + @Test + void getIncludesMembers() { + var memberId = UUID.randomUUID(); + when(userGroupService.listMembers(groupId, orgId)).thenReturn(List.of( + new UserGroupMembershipView(memberId, groupId, "jane@example.com", "Jane", + UserGroupMembershipSourceType.SCIM, Instant.now()))); + + var resource = orchestrator.get(principal, groupId, BASE); + + assertThat(resource.members()).hasSize(1); + assertThat(resource.members().get(0).value()).isEqualTo(memberId.toString()); + } + + @Test + void getUnknownGroupIs404() { + var unknown = UUID.randomUUID(); + when(userGroupService.getGroup(unknown, orgId)) + .thenThrow(new UserGroupNotFoundException(unknown)); + + assertThatThrownBy(() -> orchestrator.get(principal, unknown, BASE)) + .isInstanceOf(ScimResourceNotFoundException.class); + } + + @Test + void replaceRenamesAndReplacesScimMembers() { + var memberId = UUID.randomUUID(); + when(userGroupService.updateGroup(eq(groupId), eq(orgId), any())) + .thenReturn(groupView(groupId, "Platform", null)); + + orchestrator.replace(principal, groupId, new ScimGroupResource(null, null, null, + "Platform", List.of(new ScimMemberRef(memberId.toString(), null)), null), BASE); + + verify(userGroupService).replaceMembersBySource(eq(groupId), eq(orgId), + eq(List.of(memberId)), eq(UserGroupMembershipSourceType.SCIM)); + } + + @Test + void entraShapedMemberAddPatch() { + var memberId = UUID.randomUUID(); + var patch = patchRequest(""" + {"Operations":[{"op":"Add","path":"members", + "value":[{"value":"%s"}]}]} + """.formatted(memberId)); + + orchestrator.patch(principal, groupId, patch, BASE); + + verify(userGroupService).addMember(groupId, memberId, orgId, + UserGroupMembershipSourceType.SCIM); + } + + @Test + void entraShapedFilteredMemberRemovePatch() { + var memberId = UUID.randomUUID(); + var patch = patchRequest(""" + {"Operations":[{"op":"Remove","path":"members[value eq \\"%s\\"]"}]} + """.formatted(memberId)); + + orchestrator.patch(principal, groupId, patch, BASE); + + verify(userGroupService).removeMemberBySource(groupId, memberId, orgId, + UserGroupMembershipSourceType.SCIM); + } + + @Test + void oktaShapedMemberReplacePatch() { + var memberId = UUID.randomUUID(); + var patch = patchRequest(""" + {"Operations":[{"op":"replace","path":"members", + "value":[{"value":"%s"}]}]} + """.formatted(memberId)); + + orchestrator.patch(principal, groupId, patch, BASE); + + verify(userGroupService).replaceMembersBySource(eq(groupId), eq(orgId), + eq(List.of(memberId)), eq(UserGroupMembershipSourceType.SCIM)); + } + + @Test + void patchRenameViaValueObject() { + var patch = patchRequest(""" + {"Operations":[{"op":"replace","value":{"displayName":"Platform"}}]} + """); + when(userGroupService.updateGroup(eq(groupId), eq(orgId), any())) + .thenReturn(groupView(groupId, "Platform", null)); + + orchestrator.patch(principal, groupId, patch, BASE); + + verify(userGroupService).updateGroup(eq(groupId), eq(orgId), any()); + } + + @Test + void patchInvalidMemberIdIsInvalidValue() { + var patch = patchRequest(""" + {"Operations":[{"op":"add","path":"members","value":[{"value":"not-a-uuid"}]}]} + """); + + assertThatThrownBy(() -> orchestrator.patch(principal, groupId, patch, BASE)) + .isInstanceOf(ScimInvalidValueException.class); + } + + @Test + void deleteReturnsTheDeletedView() { + var deleted = orchestrator.delete(principal, groupId); + + verify(userGroupService).deleteGroup(groupId, orgId); + assertThat(deleted.name()).isEqualTo("Engineers"); + } + + @Test + void replaceMembersResultIgnoredWhenMembersNull() { + when(userGroupService.updateGroup(eq(groupId), eq(orgId), any())) + .thenReturn(groupView(groupId, "Engineers", null)); + + orchestrator.replace(principal, groupId, new ScimGroupResource(null, null, null, + "Engineers", null, null), BASE); + + verify(userGroupService, org.mockito.Mockito.never()) + .replaceMembersBySource(any(), any(), any(), any()); + } + + private ScimPatchRequest patchRequest(String json) { + return objectMapper.readValue(json, ScimPatchRequest.class); + } + + private static UserGroupView groupView(UUID id, String name, String externalId) { + return new UserGroupView(id, UUID.randomUUID(), name, null, 0, + Instant.parse("2026-08-01T00:00:00Z"), Instant.parse("2026-08-02T00:00:00Z"), + externalId); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasherTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasherTest.java new file mode 100644 index 00000000..b8756d8b --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasherTest.java @@ -0,0 +1,41 @@ +package com.bablsoft.accessflow.scim.internal; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimTokenHasherTest { + + @Test + void generateProducesPrefixedHighEntropyTokens() { + var token = ScimTokenHasher.generate(); + + assertThat(token).startsWith("af_scim_"); + assertThat(token.length()).isGreaterThan(40); + assertThat(ScimTokenHasher.generate()).isNotEqualTo(token); + } + + @Test + void hashIsDeterministicSha256Hex() { + var hash = ScimTokenHasher.hash("af_scim_test"); + + assertThat(hash).hasSize(64).matches("[0-9a-f]+"); + assertThat(ScimTokenHasher.hash("af_scim_test")).isEqualTo(hash); + assertThat(ScimTokenHasher.hash("af_scim_other")).isNotEqualTo(hash); + } + + @Test + void prefixOfTruncatesToTwelveChars() { + assertThat(ScimTokenHasher.prefixOf("af_scim_AbCdEfGh")).isEqualTo("af_scim_AbCd"); + assertThat(ScimTokenHasher.prefixOf("short")).isEqualTo("short"); + assertThat(ScimTokenHasher.prefixOf(null)).isEmpty(); + } + + @Test + void hasExpectedShapeRequiresPrefix() { + assertThat(ScimTokenHasher.hasExpectedShape(ScimTokenHasher.generate())).isTrue(); + assertThat(ScimTokenHasher.hasExpectedShape("af_notscim")).isFalse(); + assertThat(ScimTokenHasher.hasExpectedShape("af_scim_")).isFalse(); + assertThat(ScimTokenHasher.hasExpectedShape(null)).isFalse(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestratorTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestratorTest.java new file mode 100644 index 00000000..8bfb124d --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestratorTest.java @@ -0,0 +1,325 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.CreateExternalUserCommand; +import com.bablsoft.accessflow.core.api.DirectoryPage; +import com.bablsoft.accessflow.core.api.EmailAlreadyExistsException; +import com.bablsoft.accessflow.core.api.ExternalUserDirectoryService; +import com.bablsoft.accessflow.core.api.UpdateExternalUserCommand; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.api.UserView; +import com.bablsoft.accessflow.scim.api.ScimConfigService; +import com.bablsoft.accessflow.scim.api.ScimConfigView; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.protocol.ScimEmail; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidFilterException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidPathException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidValueException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimName; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import com.bablsoft.accessflow.scim.internal.protocol.ScimResourceNotFoundException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUniquenessException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUserResource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScimUserOrchestratorTest { + + private static final String BASE = "https://af.example.com/scim/v2"; + + @Mock ExternalUserDirectoryService directory; + @Mock ScimConfigService configService; + + ScimUserOrchestrator orchestrator; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final UUID orgId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + private final ScimPrincipal principal = + new ScimPrincipal(orgId, UUID.randomUUID(), "okta-prod"); + + @BeforeEach + void setUp() { + orchestrator = new ScimUserOrchestrator(directory, configService); + lenient().when(configService.get(orgId)).thenReturn(defaultConfig()); + } + + @Test + void createMapsUserNameToEmailAndDefaultRole() { + when(directory.createExternal(any())).thenAnswer(inv -> { + CreateExternalUserCommand cmd = inv.getArgument(0); + return userView(cmd.email(), cmd.displayName(), cmd.scimExternalId(), true); + }); + + var created = orchestrator.create(principal, oktaUser("jane@example.com", "Jane Doe", + "00u1abcd", true), BASE); + + var captor = ArgumentCaptor.forClass(CreateExternalUserCommand.class); + verify(directory).createExternal(captor.capture()); + assertThat(captor.getValue().email()).isEqualTo("jane@example.com"); + assertThat(captor.getValue().displayName()).isEqualTo("Jane Doe"); + assertThat(captor.getValue().scimExternalId()).isEqualTo("00u1abcd"); + assertThat(captor.getValue().defaultRole()).isEqualTo(UserRoleType.ANALYST); + assertThat(created.userName()).isEqualTo("jane@example.com"); + assertThat(created.meta().location()).startsWith(BASE + "/Users/"); + assertThat(created.schemas()) + .containsExactly("urn:ietf:params:scim:schemas:core:2.0:User"); + } + + @Test + void createWithEmailsPrimaryMappingReadsPrimaryEmail() { + when(configService.get(orgId)).thenReturn(config("emails.primary", "displayName")); + when(directory.createExternal(any())).thenAnswer(inv -> { + CreateExternalUserCommand cmd = inv.getArgument(0); + return userView(cmd.email(), cmd.displayName(), null, true); + }); + + var resource = new ScimUserResource(null, null, null, "jdoe", "Jane", null, + List.of(new ScimEmail("secondary@example.com", "home", false), + new ScimEmail("primary@example.com", "work", true)), + true, null); + orchestrator.create(principal, resource, BASE); + + var captor = ArgumentCaptor.forClass(CreateExternalUserCommand.class); + verify(directory).createExternal(captor.capture()); + assertThat(captor.getValue().email()).isEqualTo("primary@example.com"); + } + + @Test + void createWithoutEmailSourceIsInvalidValue() { + var resource = new ScimUserResource(null, null, null, null, "Jane", null, null, true, null); + + assertThatThrownBy(() -> orchestrator.create(principal, resource, BASE)) + .isInstanceOf(ScimInvalidValueException.class); + } + + @Test + void createDuplicateEmailBecomesUniqueness() { + when(directory.createExternal(any())) + .thenThrow(new EmailAlreadyExistsException("jane@example.com")); + + assertThatThrownBy(() -> orchestrator.create(principal, + oktaUser("jane@example.com", "Jane", null, true), BASE)) + .isInstanceOf(ScimUniquenessException.class); + } + + @Test + void listWithoutFilterPagesTheDirectory() { + when(directory.list(orgId, 0, 100)).thenReturn( + new DirectoryPage<>(List.of(userView("a@x.io", "A", null, true)), 5)); + + var response = orchestrator.list(principal, null, 1, 100, BASE); + + assertThat(response.totalResults()).isEqualTo(5); + assertThat(response.startIndex()).isEqualTo(1); + assertThat(response.resources()).hasSize(1); + assertThat(response.schemas()) + .containsExactly("urn:ietf:params:scim:api:messages:2.0:ListResponse"); + } + + @Test + void listWithUserNameFilterLooksUpByEmail() { + when(directory.findByEmail(orgId, "jane@example.com")) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + + var response = orchestrator.list(principal, "userName eq \"jane@example.com\"", 1, 100, + BASE); + + assertThat(response.totalResults()).isEqualTo(1); + assertThat(response.resources().get(0).userName()).isEqualTo("jane@example.com"); + } + + @Test + void listWithUnmatchedFilterReturnsEmpty() { + when(directory.findByExternalId(orgId, "nope")).thenReturn(Optional.empty()); + + var response = orchestrator.list(principal, "externalId eq \"nope\"", 1, 100, BASE); + + assertThat(response.totalResults()).isZero(); + assertThat(response.resources()).isEmpty(); + } + + @Test + void listWithUnsupportedFilterAttributeIsInvalidFilter() { + assertThatThrownBy(() -> orchestrator.list(principal, "title eq \"boss\"", 1, 100, BASE)) + .isInstanceOf(ScimInvalidFilterException.class); + } + + @Test + void getUnknownUserIs404() { + when(directory.findById(orgId, userId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> orchestrator.get(principal, userId, BASE)) + .isInstanceOf(ScimResourceNotFoundException.class); + } + + @Test + void replaceUpdatesOnlyScimOwnedAttributes() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("old@example.com", "Old", null, true))); + when(directory.updateExternal(eq(orgId), eq(userId), any())) + .thenAnswer(inv -> userView("new@example.com", "New", "ext-9", true)); + + var result = orchestrator.replace(principal, userId, + oktaUser("new@example.com", "New", "ext-9", true), BASE); + + var captor = ArgumentCaptor.forClass(UpdateExternalUserCommand.class); + verify(directory).updateExternal(eq(orgId), eq(userId), captor.capture()); + assertThat(captor.getValue().email()).isEqualTo("new@example.com"); + assertThat(captor.getValue().active()).isTrue(); + assertThat(result.deactivated()).isFalse(); + } + + @Test + void oktaShapedPatchDeactivates() { + // Okta: {"schemas":[PatchOp],"Operations":[{"op":"replace","value":{"active":false}}]} + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + when(directory.updateExternal(eq(orgId), eq(userId), any())) + .thenAnswer(inv -> userView("jane@example.com", "Jane", null, false)); + var patch = patchRequest(""" + {"Operations":[{"op":"replace","value":{"active":false}}]} + """); + + var result = orchestrator.patch(principal, userId, patch, BASE); + + var captor = ArgumentCaptor.forClass(UpdateExternalUserCommand.class); + verify(directory).updateExternal(eq(orgId), eq(userId), captor.capture()); + assertThat(captor.getValue().active()).isFalse(); + assertThat(result.deactivated()).isTrue(); + } + + @Test + void entraShapedPatchWithStringBooleanAndPathDeactivates() { + // Entra: {"Operations":[{"op":"Replace","path":"active","value":"False"}]} + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + when(directory.updateExternal(eq(orgId), eq(userId), any())) + .thenAnswer(inv -> userView("jane@example.com", "Jane", null, false)); + var patch = patchRequest(""" + {"Operations":[{"op":"Replace","path":"active","value":"False"}]} + """); + + var result = orchestrator.patch(principal, userId, patch, BASE); + + assertThat(result.deactivated()).isTrue(); + } + + @Test + void patchIgnoresUnknownAttributesLikePassword() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + when(directory.updateExternal(eq(orgId), eq(userId), any())) + .thenAnswer(inv -> userView("jane@example.com", "Renamed", null, true)); + var patch = patchRequest(""" + {"Operations":[{"op":"replace","value": + {"password":"hunter2","displayName":"Renamed"}}]} + """); + + orchestrator.patch(principal, userId, patch, BASE); + + var captor = ArgumentCaptor.forClass(UpdateExternalUserCommand.class); + verify(directory).updateExternal(eq(orgId), eq(userId), captor.capture()); + assertThat(captor.getValue().displayName()).isEqualTo("Renamed"); + assertThat(captor.getValue().email()).isNull(); + assertThat(captor.getValue().active()).isNull(); + } + + @Test + void patchRemoveOpIsInvalidPath() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + var patch = patchRequest(""" + {"Operations":[{"op":"remove","path":"displayName"}]} + """); + + assertThatThrownBy(() -> orchestrator.patch(principal, userId, patch, BASE)) + .isInstanceOf(ScimInvalidPathException.class); + } + + @Test + void patchWithNonBooleanActiveIsInvalidValue() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + var patch = patchRequest(""" + {"Operations":[{"op":"replace","path":"active","value":"maybe"}]} + """); + + assertThatThrownBy(() -> orchestrator.patch(principal, userId, patch, BASE)) + .isInstanceOf(ScimInvalidValueException.class); + } + + @Test + void deleteDeactivatesOnceAndIsIdempotent() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + + assertThat(orchestrator.delete(principal, userId)).isTrue(); + verify(directory).updateExternal(eq(orgId), eq(userId), any()); + + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, false))); + assertThat(orchestrator.delete(principal, userId)).isFalse(); + } + + @Test + void responseNeverCarriesPasswordData() { + var resource = ScimUserOrchestrator.toResource( + userView("jane@example.com", "Jane", "ext-1", true), defaultConfig(), BASE); + + var json = objectMapper.writeValueAsString(resource); + assertThat(json).doesNotContainIgnoringCase("password"); + assertThat(json).contains("\"userName\":\"jane@example.com\""); + assertThat(json).contains("\"externalId\":\"ext-1\""); + } + + private ScimPatchRequest patchRequest(String json) { + return objectMapper.readValue(json, ScimPatchRequest.class); + } + + private static ScimUserResource oktaUser(String userName, String displayName, + String externalId, Boolean active) { + return new ScimUserResource( + List.of("urn:ietf:params:scim:schemas:core:2.0:User"), + null, externalId, userName, displayName, + new ScimName(displayName, "Jane", "Doe"), + List.of(new ScimEmail(userName, "work", true)), + active, null); + } + + private UserView userView(String email, String displayName, String externalId, + boolean active) { + return new UserView(userId, email, displayName, UserRoleType.ANALYST, null, "ANALYST", + orgId, active, AuthProviderType.SCIM, null, null, null, false, false, + Instant.parse("2026-08-01T00:00:00Z"), externalId, + Instant.parse("2026-08-02T00:00:00Z")); + } + + private static ScimConfigView defaultConfig() { + return config("userName", "displayName"); + } + + private static ScimConfigView config(String attrEmail, String attrDisplayName) { + return new ScimConfigView(null, null, true, attrEmail, attrDisplayName, + UserRoleType.ANALYST, null, null); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParserTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParserTest.java new file mode 100644 index 00000000..bb1f69b4 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParserTest.java @@ -0,0 +1,55 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ScimFilterParserTest { + + @Test + void parsesOktaShapedUserNameFilter() { + var filter = ScimFilterParser.parse("userName eq \"jane@example.com\""); + + assertThat(filter.attribute()).isEqualTo("username"); + assertThat(filter.value()).isEqualTo("jane@example.com"); + } + + @Test + void parsesUrnQualifiedAttribute() { + var filter = ScimFilterParser.parse( + "urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x@y.z\""); + + assertThat(filter.attribute()).isEqualTo("username"); + } + + @Test + void unescapesQuotedValues() { + var filter = ScimFilterParser.parse("displayName eq \"The \\\"A\\\" Team\""); + + assertThat(filter.value()).isEqualTo("The \"A\" Team"); + } + + @Test + void nullOrBlankMeansNoFilter() { + assertThat(ScimFilterParser.parse(null)).isNull(); + assertThat(ScimFilterParser.parse(" ")).isNull(); + } + + @Test + void rejectsNonEqExpressions() { + assertThatThrownBy(() -> ScimFilterParser.parse("userName co \"jane\"")) + .isInstanceOf(ScimInvalidFilterException.class); + assertThatThrownBy(() -> ScimFilterParser.parse( + "userName eq \"a\" and active eq true")) + .isInstanceOf(ScimInvalidFilterException.class); + assertThatThrownBy(() -> ScimFilterParser.parse("active pr")) + .isInstanceOf(ScimInvalidFilterException.class); + } + + @Test + void eqIsCaseInsensitive() { + assertThat(ScimFilterParser.parse("externalId EQ \"ext-1\"").attribute()) + .isEqualTo("externalid"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminControllerIntegrationTest.java new file mode 100644 index 00000000..94ec1c7a --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminControllerIntegrationTest.java @@ -0,0 +1,241 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.TestcontainersConfig; +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.api.UserView; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimConfigRepository; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimTokenRepository; +import com.bablsoft.accessflow.security.internal.jwt.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.web.context.WebApplicationContext; + +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateCrtKey; +import java.util.Base64; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +@SpringBootTest +@ImportTestcontainers(TestcontainersConfig.class) +class ScimAdminControllerIntegrationTest { + + @Autowired WebApplicationContext context; + @Autowired OrganizationRepository organizationRepository; + @Autowired UserRepository userRepository; + @Autowired ScimConfigRepository scimConfigRepository; + @Autowired ScimTokenRepository scimTokenRepository; + @Autowired JwtService jwtService; + + private MockMvcTester mvc; + private OrganizationEntity org; + private String adminToken; + private String analystToken; + + @DynamicPropertySource + static void env(DynamicPropertyRegistry registry) throws Exception { + var kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + var kp = kpg.generateKeyPair(); + var pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}) + .encodeToString(((RSAPrivateCrtKey) kp.getPrivate()).getEncoded()) + + "\n-----END PRIVATE KEY-----"; + registry.add("accessflow.jwt.private-key", () -> pem); + registry.add("accessflow.encryption-key", () -> + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + registry.add("accessflow.audit.hmac-key", () -> + "abababababababababababababababababababababababababababababababab"); + } + + @BeforeEach + void setUp() { + mvc = MockMvcTester.from(context, builder -> builder.apply(springSecurity()).build()); + scimTokenRepository.deleteAll(); + scimConfigRepository.deleteAll(); + userRepository.deleteAll(); + organizationRepository.deleteAll(); + + org = new OrganizationEntity(); + org.setId(UUID.randomUUID()); + org.setName("Primary"); + org.setSlug("primary-" + UUID.randomUUID()); + organizationRepository.save(org); + + adminToken = generateToken(saveUser("admin@example.com", UserRoleType.ADMIN)); + analystToken = generateToken(saveUser("analyst@example.com", UserRoleType.ANALYST)); + } + + @Test + void getReturnsDefaultsBeforeFirstUpdate() { + var result = mvc.get().uri("/api/v1/admin/scim-config") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.enabled").asBoolean().isFalse(); + assertThat(result).bodyJson().extractingPath("$.attr_email").asString() + .isEqualTo("userName"); + assertThat(result).bodyJson().extractingPath("$.attr_display_name").asString() + .isEqualTo("displayName"); + assertThat(result).bodyJson().extractingPath("$.default_role").asString() + .isEqualTo("ANALYST"); + } + + @Test + void putUpsertsTheSingletonRow() { + var result = mvc.put().uri("/api/v1/admin/scim-config") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"enabled":true,"attr_email":"emails.primary", + "attr_display_name":"name.formatted","default_role":"READONLY"} + """) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.enabled").asBoolean().isTrue(); + var stored = scimConfigRepository.findByOrganizationId(org.getId()).orElseThrow(); + assertThat(stored.isEnabled()).isTrue(); + assertThat(stored.getAttrEmail()).isEqualTo("emails.primary"); + assertThat(stored.getDefaultRole()).isEqualTo(UserRoleType.READONLY); + } + + @Test + void putRejectsUnknownMappingValue() { + var result = mvc.put().uri("/api/v1/admin/scim-config") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"attr_email\":\"nickName\"}") + .exchange(); + + assertThat(result).hasStatus(400); + assertThat(scimConfigRepository.findByOrganizationId(org.getId())).isEmpty(); + } + + @Test + void tokenLifecycleShowsRawValueExactlyOnce() throws Exception { + var created = mvc.post().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"okta-prod\"}") + .exchange(); + + assertThat(created).hasStatus(201); + assertThat(created).bodyJson().extractingPath("$.raw_token").asString() + .startsWith("af_scim_"); + assertThat(created).bodyJson().extractingPath("$.token.name").asString() + .isEqualTo("okta-prod"); + + var list = mvc.get().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + assertThat(list).hasStatus(200); + assertThat(list.getResponse().getContentAsString()).doesNotContain("raw_token"); + assertThat(list).bodyJson().extractingPath("$[0].token_prefix").asString() + .startsWith("af_scim_"); + + // The stored row carries only the SHA-256 hash. + var stored = scimTokenRepository + .findAllByOrganizationIdOrderByCreatedAtDesc(org.getId()).get(0); + assertThat(stored.getTokenHash()).hasSize(64).doesNotStartWith("af_scim_"); + } + + @Test + void duplicateTokenNameReturns409() { + mvc.post().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"dup\"}") + .exchange(); + + var second = mvc.post().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"dup\"}") + .exchange(); + + assertThat(second).hasStatus(409); + assertThat(second).bodyJson().extractingPath("$.error").asString() + .isEqualTo("SCIM_TOKEN_NAME_CONFLICT"); + } + + @Test + void revokeReturns204AndUnknownTokenIs404() { + mvc.post().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"to-revoke\"}") + .exchange(); + var tokenId = scimTokenRepository + .findAllByOrganizationIdOrderByCreatedAtDesc(org.getId()).get(0).getId(); + + var revoked = mvc.delete().uri("/api/v1/admin/scim/tokens/" + tokenId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + assertThat(revoked).hasStatus(204); + assertThat(scimTokenRepository.findById(tokenId).orElseThrow().getRevokedAt()).isNotNull(); + + var missing = mvc.delete().uri("/api/v1/admin/scim/tokens/" + UUID.randomUUID()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + assertThat(missing).hasStatus(404); + assertThat(missing).bodyJson().extractingPath("$.error").asString() + .isEqualTo("SCIM_TOKEN_NOT_FOUND"); + } + + @Test + void analystForbiddenEverywhere() { + assertThat(mvc.get().uri("/api/v1/admin/scim-config") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken) + .exchange()).hasStatus(403); + assertThat(mvc.get().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken) + .exchange()).hasStatus(403); + } + + private UserEntity saveUser(String email, UserRoleType role) { + var user = new UserEntity(); + user.setId(UUID.randomUUID()); + user.setEmail(email); + user.setDisplayName(role.name()); + user.setPasswordHash("hashed"); + user.setRole(role); + user.setAuthProvider(AuthProviderType.LOCAL); + user.setActive(true); + user.setOrganization(org); + return userRepository.save(user); + } + + private String generateToken(UserEntity entity) { + var view = new UserView( + entity.getId(), + entity.getEmail(), + entity.getDisplayName(), + entity.getRole(), + entity.getOrganization().getId(), + entity.isActive(), + entity.getAuthProvider(), + entity.getPasswordHash(), + entity.getLastLoginAt(), + entity.getPreferredLanguage(), + entity.isTotpEnabled(), + entity.getCreatedAt()); + return jwtService.generateAccessToken(view); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminWebModelsTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminWebModelsTest.java new file mode 100644 index 00000000..2ded29b8 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminWebModelsTest.java @@ -0,0 +1,55 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.scim.api.IssuedScimToken; +import com.bablsoft.accessflow.scim.api.ScimConfigView; +import com.bablsoft.accessflow.scim.api.ScimTokenView; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimAdminWebModelsTest { + + @Test + void scimConfigResponseMapsAllFields() { + var view = new ScimConfigView(UUID.randomUUID(), UUID.randomUUID(), true, + "emails.primary", "name.formatted", UserRoleType.READONLY, + Instant.parse("2026-08-01T00:00:00Z"), Instant.parse("2026-08-02T00:00:00Z")); + + var response = ScimConfigResponse.from(view); + + assertThat(response.id()).isEqualTo(view.id()); + assertThat(response.enabled()).isTrue(); + assertThat(response.attrEmail()).isEqualTo("emails.primary"); + assertThat(response.attrDisplayName()).isEqualTo("name.formatted"); + assertThat(response.defaultRole()).isEqualTo(UserRoleType.READONLY); + } + + @Test + void updateRequestBuildsCommand() { + var request = new UpdateScimConfigRequest(true, "userName", "displayName", + UserRoleType.ANALYST); + + var command = request.toCommand(); + + assertThat(command.enabled()).isTrue(); + assertThat(command.attrEmail()).isEqualTo("userName"); + assertThat(command.attrDisplayName()).isEqualTo("displayName"); + assertThat(command.defaultRole()).isEqualTo(UserRoleType.ANALYST); + } + + @Test + void createdTokenResponseCarriesRawTokenOnce() { + var view = new ScimTokenView(UUID.randomUUID(), "okta", "af_scim_AbCd", + Instant.now(), null, null); + + var response = CreatedScimTokenResponse.from(new IssuedScimToken(view, "af_scim_raw")); + + assertThat(response.rawToken()).isEqualTo("af_scim_raw"); + assertThat(response.token().tokenPrefix()).isEqualTo("af_scim_AbCd"); + assertThat(response.token().revokedAt()).isNull(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriterTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriterTest.java new file mode 100644 index 00000000..3ac90548 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriterTest.java @@ -0,0 +1,63 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditEntry; +import com.bablsoft.accessflow.audit.api.AuditLogService; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ScimAuditWriterTest { + + @Mock AuditLogService auditLogService; + + private final ScimPrincipal principal = + new ScimPrincipal(UUID.randomUUID(), UUID.randomUUID(), "okta-prod"); + private final RequestAuditContext auditContext = + new RequestAuditContext("10.0.0.1", "Okta-Provisioning"); + + @Test + void recordsNullActorRowWithTokenMetadata() { + var writer = new ScimAuditWriter(auditLogService); + var resourceId = UUID.randomUUID(); + + writer.record(AuditAction.SCIM_USER_PROVISIONED, AuditResourceType.USER, resourceId, + principal, Map.of("email", "jane@example.com"), auditContext); + + var captor = ArgumentCaptor.forClass(AuditEntry.class); + verify(auditLogService).record(captor.capture()); + var entry = captor.getValue(); + assertThat(entry.actorId()).isNull(); + assertThat(entry.organizationId()).isEqualTo(principal.organizationId()); + assertThat(entry.metadata()) + .containsEntry("email", "jane@example.com") + .containsEntry("scim_token_id", principal.tokenId().toString()) + .containsEntry("scim_token_name", "okta-prod"); + assertThat(entry.ipAddress()).isEqualTo("10.0.0.1"); + } + + @Test + void auditFailuresAreSwallowed() { + var writer = new ScimAuditWriter(auditLogService); + doThrow(new IllegalStateException("chain broken")).when(auditLogService).record(any()); + + assertThatCode(() -> writer.record(AuditAction.SCIM_USER_UPDATED, AuditResourceType.USER, + UUID.randomUUID(), principal, Map.of(), auditContext)) + .doesNotThrowAnyException(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPointTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPointTest.java new file mode 100644 index 00000000..984ebaf8 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPointTest.java @@ -0,0 +1,27 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import tools.jackson.databind.ObjectMapper; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimAuthenticationEntryPointTest { + + @Test + void writes401ScimErrorEnvelope() throws Exception { + var entryPoint = new ScimAuthenticationEntryPoint(new ObjectMapper()); + var response = new MockHttpServletResponse(); + + entryPoint.commence(new MockHttpServletRequest(), response, + new InsufficientAuthenticationException("nope")); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentType()).isEqualTo("application/scim+json"); + assertThat(response.getContentAsString()) + .contains("urn:ietf:params:scim:api:messages:2.0:Error") + .contains("\"status\":\"401\""); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationTokenTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationTokenTest.java new file mode 100644 index 00000000..0fc50f06 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationTokenTest.java @@ -0,0 +1,24 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimAuthenticationTokenTest { + + @Test + void carriesPrincipalAndOnlyTheScimAuthority() { + var principal = new ScimPrincipal(UUID.randomUUID(), UUID.randomUUID(), "okta"); + var token = new ScimAuthenticationToken(principal); + + assertThat(token.isAuthenticated()).isTrue(); + assertThat(token.getPrincipal()).isEqualTo(principal); + assertThat(token.getCredentials()).isNull(); + assertThat(token.getAuthorities()).extracting(GrantedAuthority::getAuthority) + .containsExactly("SCIM"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryControllerTest.java new file mode 100644 index 00000000..bf6235ff --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryControllerTest.java @@ -0,0 +1,62 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimDiscoveryControllerTest { + + private final ScimDiscoveryController controller = new ScimDiscoveryController(); + + @BeforeEach + void setUp() { + var request = new MockHttpServletRequest("GET", "/scim/v2/ServiceProviderConfig"); + request.setServerName("af.example.com"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void serviceProviderConfigAdvertisesPatchButNoBulkSortEtag() { + var config = controller.serviceProviderConfig(); + + assertThat(config.get("schemas")).isEqualTo( + List.of("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig")); + assertThat(((Map) config.get("patch")).get("supported")).isEqualTo(true); + assertThat(((Map) config.get("bulk")).get("supported")).isEqualTo(false); + assertThat(((Map) config.get("sort")).get("supported")).isEqualTo(false); + assertThat(((Map) config.get("etag")).get("supported")).isEqualTo(false); + assertThat(((Map) config.get("changePassword")).get("supported")).isEqualTo(false); + assertThat(((Map) config.get("filter")).get("maxResults")).isEqualTo(200); + } + + @Test + void resourceTypesDescribeUsersAndGroups() { + var types = controller.resourceTypes(); + + assertThat(types).hasSize(2); + assertThat(types.get(0).get("endpoint")).isEqualTo("/Users"); + assertThat(types.get(1).get("endpoint")).isEqualTo("/Groups"); + } + + @Test + void schemasListUserAndGroup() { + var schemas = controller.schemas(); + + assertThat(schemas).extracting(m -> m.get("id")).containsExactly( + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:core:2.0:Group"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimEndpointsIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimEndpointsIntegrationTest.java new file mode 100644 index 00000000..910fc140 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimEndpointsIntegrationTest.java @@ -0,0 +1,319 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.TestcontainersConfig; +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.UserGroupMembershipSourceType; +import com.bablsoft.accessflow.core.api.UserGroupService; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import com.bablsoft.accessflow.scim.api.ScimTokenService; +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimConfigEntity; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimConfigRepository; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimTokenRepository; +import com.bablsoft.accessflow.security.internal.token.RefreshTokenStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.web.context.WebApplicationContext; + +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateCrtKey; +import java.time.Duration; +import java.util.Base64; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * Full-stack SCIM 2.0 protocol tests (#621): bearer-token chain, Okta/Entra-shaped payloads, + * deactivation fan-out, group membership provenance. + */ +@SpringBootTest +@ImportTestcontainers(TestcontainersConfig.class) +class ScimEndpointsIntegrationTest { + + @Autowired WebApplicationContext context; + @Autowired OrganizationRepository organizationRepository; + @Autowired UserRepository userRepository; + @Autowired ScimConfigRepository scimConfigRepository; + @Autowired ScimTokenRepository scimTokenRepository; + @Autowired ScimTokenService scimTokenService; + @Autowired UserGroupService userGroupService; + @Autowired RefreshTokenStore refreshTokenStore; + + private MockMvcTester mvc; + private OrganizationEntity org; + private String rawToken; + + @DynamicPropertySource + static void env(DynamicPropertyRegistry registry) throws Exception { + var kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + var kp = kpg.generateKeyPair(); + var pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}) + .encodeToString(((RSAPrivateCrtKey) kp.getPrivate()).getEncoded()) + + "\n-----END PRIVATE KEY-----"; + registry.add("accessflow.jwt.private-key", () -> pem); + registry.add("accessflow.encryption-key", () -> + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + registry.add("accessflow.audit.hmac-key", () -> + "abababababababababababababababababababababababababababababababab"); + } + + @BeforeEach + void setUp() { + mvc = MockMvcTester.from(context, builder -> builder.apply(springSecurity()).build()); + scimTokenRepository.deleteAll(); + scimConfigRepository.deleteAll(); + userRepository.deleteAll(); + organizationRepository.deleteAll(); + + org = new OrganizationEntity(); + org.setId(UUID.randomUUID()); + org.setName("Primary"); + org.setSlug("primary-" + UUID.randomUUID()); + organizationRepository.save(org); + + var config = new ScimConfigEntity(); + config.setId(UUID.randomUUID()); + config.setOrganizationId(org.getId()); + config.setEnabled(true); + scimConfigRepository.save(config); + + rawToken = scimTokenService.create(org.getId(), "it-token", null).rawToken(); + } + + @Test + void missingTokenReturns401ScimEnvelope() { + var result = mvc.get().uri("/scim/v2/Users").exchange(); + + assertThat(result).hasStatus(401); + assertThat(result).bodyJson().extractingPath("$.schemas[0]").asString() + .isEqualTo("urn:ietf:params:scim:api:messages:2.0:Error"); + assertThat(result).bodyJson().extractingPath("$.status").asString().isEqualTo("401"); + } + + @Test + void revokedTokenReturns401() { + var tokenId = scimTokenRepository.findAllByOrganizationIdOrderByCreatedAtDesc(org.getId()) + .get(0).getId(); + scimTokenService.revoke(org.getId(), tokenId); + + var result = scimGet("/scim/v2/Users"); + + assertThat(result).hasStatus(401); + } + + @Test + void disabledConfigReturns401() { + var config = scimConfigRepository.findByOrganizationId(org.getId()).orElseThrow(); + config.setEnabled(false); + scimConfigRepository.save(config); + + var result = scimGet("/scim/v2/ServiceProviderConfig"); + + assertThat(result).hasStatus(401); + } + + @Test + void discoveryEndpointsAnswerWithScimMediaType() { + var result = scimGet("/scim/v2/ServiceProviderConfig"); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.patch.supported").asBoolean().isTrue(); + assertThat(scimGet("/scim/v2/ResourceTypes")).hasStatus(200); + assertThat(scimGet("/scim/v2/Schemas")).hasStatus(200); + } + + @Test + void oktaShapedCreateProvisionsScimUser() throws Exception { + var result = scimPost("/scim/v2/Users", """ + {"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName":"jane@example.com", + "name":{"givenName":"Jane","familyName":"Doe","formatted":"Jane Doe"}, + "displayName":"Jane Doe", + "emails":[{"primary":true,"value":"jane@example.com","type":"work"}], + "password":"should-be-ignored", + "externalId":"00u1abcd", + "active":true} + """); + + assertThat(result).hasStatus(201); + assertThat(result).bodyJson().extractingPath("$.userName").asString() + .isEqualTo("jane@example.com"); + assertThat(result).bodyJson().extractingPath("$.externalId").asString() + .isEqualTo("00u1abcd"); + assertThat(result.getResponse().getContentAsString()).doesNotContainIgnoringCase("password"); + + var stored = userRepository.findByEmail("jane@example.com").orElseThrow(); + assertThat(stored.getAuthProvider()).isEqualTo(AuthProviderType.SCIM); + assertThat(stored.getPasswordHash()).isNull(); + assertThat(stored.getScimExternalId()).isEqualTo("00u1abcd"); + assertThat(stored.getRole()).isEqualTo(UserRoleType.ANALYST); + } + + @Test + void duplicateEmailReturns409Uniqueness() { + seedScimUser("dup@example.com"); + + var result = scimPost("/scim/v2/Users", + "{\"userName\":\"dup@example.com\",\"active\":true}"); + + assertThat(result).hasStatus(409); + assertThat(result).bodyJson().extractingPath("$.scimType").asString() + .isEqualTo("uniqueness"); + } + + @Test + void filterByUserNameFindsTheUser() { + seedScimUser("findme@example.com"); + + var result = mvc.get().uri("/scim/v2/Users") + .param("filter", "userName eq \"findme@example.com\"") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.totalResults").asNumber().isEqualTo(1); + assertThat(result).bodyJson().extractingPath("$.Resources[0].userName").asString() + .isEqualTo("findme@example.com"); + } + + @Test + void unsupportedFilterReturnsInvalidFilterEnvelope() { + var result = mvc.get().uri("/scim/v2/Users") + .param("filter", "title co \"boss\"") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + + assertThat(result).hasStatus(400); + assertThat(result).bodyJson().extractingPath("$.scimType").asString() + .isEqualTo("invalidFilter"); + } + + @Test + void entraShapedPatchDeactivatesAndRevokesRefreshTokens() { + var user = seedScimUser("leaver@example.com"); + refreshTokenStore.store("rt-leaver", user.getId().toString(), 3600); + + var result = mvc.patch().uri("/scim/v2/Users/" + user.getId()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .contentType(MediaType.parseMediaType("application/scim+json")) + .content(""" + {"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations":[{"op":"Replace","path":"active","value":"False"}]} + """) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.active").asBoolean().isFalse(); + assertThat(userRepository.findById(user.getId()).orElseThrow().isActive()).isFalse(); + await().atMost(Duration.ofSeconds(5)).untilAsserted( + () -> assertThat(refreshTokenStore.isRevoked("rt-leaver")).isTrue()); + } + + @Test + void deleteDeactivatesIdempotently() { + var user = seedScimUser("gone@example.com"); + + assertThat(scimDelete("/scim/v2/Users/" + user.getId())).hasStatus(204); + assertThat(userRepository.findById(user.getId()).orElseThrow().isActive()).isFalse(); + assertThat(scimDelete("/scim/v2/Users/" + user.getId())).hasStatus(204); + assertThat(scimDelete("/scim/v2/Users/" + UUID.randomUUID())).hasStatus(404); + } + + @Test + void groupLifecycleKeepsManualMembershipsIntact() { + var scimUser = seedScimUser("member@example.com"); + var manualUser = seedScimUser("manual@example.com"); + + // Create the group over SCIM with one member. + var created = scimPost("/scim/v2/Groups", """ + {"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName":"Engineers","externalId":"grp-1", + "members":[{"value":"%s"}]} + """.formatted(scimUser.getId())); + assertThat(created).hasStatus(201); + var groupId = userGroupService.listAll(org.getId()).stream() + .filter(g -> "Engineers".equals(g.name())) + .findFirst().orElseThrow().id(); + + // An admin adds a MANUAL member out-of-band. + userGroupService.addMember(groupId, manualUser.getId(), org.getId(), + UserGroupMembershipSourceType.MANUAL); + + // SCIM replaces its member set with empty — the MANUAL row must survive. + var patch = mvc.patch().uri("/scim/v2/Groups/" + groupId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"Operations":[{"op":"replace","path":"members","value":[]}]} + """) + .exchange(); + assertThat(patch).hasStatus(200); + + var members = userGroupService.listMembers(groupId, org.getId()); + assertThat(members).hasSize(1); + assertThat(members.get(0).userId()).isEqualTo(manualUser.getId()); + assertThat(members.get(0).source()).isEqualTo(UserGroupMembershipSourceType.MANUAL); + } + + @Test + void groupFilterByDisplayName() { + scimPost("/scim/v2/Groups", "{\"displayName\":\"Platform\"}"); + + var result = mvc.get().uri("/scim/v2/Groups") + .param("filter", "displayName eq \"platform\"") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.totalResults").asNumber().isEqualTo(1); + } + + private UserEntity seedScimUser(String email) { + var user = new UserEntity(); + user.setId(UUID.randomUUID()); + user.setEmail(email); + user.setDisplayName(email); + user.setAuthProvider(AuthProviderType.SCIM); + user.setRole(UserRoleType.ANALYST); + user.setActive(true); + user.setOrganization(org); + return userRepository.save(user); + } + + private org.springframework.test.web.servlet.assertj.MvcTestResult scimGet(String uri) { + return mvc.get().uri(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + } + + private org.springframework.test.web.servlet.assertj.MvcTestResult scimPost(String uri, + String body) { + return mvc.post().uri(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .contentType(MediaType.parseMediaType("application/scim+json")) + .content(body) + .exchange(); + } + + private org.springframework.test.web.servlet.assertj.MvcTestResult scimDelete(String uri) { + return mvc.delete().uri(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandlerTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandlerTest.java new file mode 100644 index 00000000..fb69026a --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandlerTest.java @@ -0,0 +1,51 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.core.api.QuotaExceededException; +import com.bablsoft.accessflow.core.api.QuotaType; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidFilterException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimResourceNotFoundException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUniquenessException; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimErrorHandlerTest { + + private final ScimErrorHandler handler = new ScimErrorHandler(); + + @Test + void protocolExceptionsMapToTheirStatusAndScimType() { + var notFound = handler.handleProtocol( + new ScimResourceNotFoundException("User", "abc")); + assertThat(notFound.getStatusCode().value()).isEqualTo(404); + assertThat(notFound.getBody().scimType()).isNull(); + + var conflict = handler.handleProtocol(new ScimUniquenessException("dup")); + assertThat(conflict.getStatusCode().value()).isEqualTo(409); + assertThat(conflict.getBody().scimType()).isEqualTo("uniqueness"); + + var badFilter = handler.handleProtocol(new ScimInvalidFilterException("bad")); + assertThat(badFilter.getStatusCode().value()).isEqualTo(400); + assertThat(badFilter.getBody().scimType()).isEqualTo("invalidFilter"); + assertThat(badFilter.getBody().schemas()) + .containsExactly("urn:ietf:params:scim:api:messages:2.0:Error"); + } + + @Test + void quotaMapsTo403() { + var response = handler.handleQuota( + new QuotaExceededException(QuotaType.USER, UUID.randomUUID(), 5, 5)); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + } + + @Test + void unexpectedFailuresBecomeOpaque500() { + var response = handler.handleUnexpected(new IllegalStateException("secret detail")); + + assertThat(response.getStatusCode().value()).isEqualTo(500); + assertThat(response.getBody().detail()).isEqualTo("Internal error"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilterTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilterTest.java new file mode 100644 index 00000000..dd389902 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilterTest.java @@ -0,0 +1,115 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.core.api.OrganizationLookupService; +import com.bablsoft.accessflow.scim.api.ScimConfigService; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.api.ScimTokenService; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +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.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScimTokenAuthenticationFilterTest { + + @Mock ScimTokenService tokenService; + @Mock ScimConfigService configService; + @Mock OrganizationLookupService organizationLookupService; + @Mock FilterChain filterChain; + + ScimTokenAuthenticationFilter filter; + + private final UUID orgId = UUID.randomUUID(); + private final ScimPrincipal principal = new ScimPrincipal(orgId, UUID.randomUUID(), "okta"); + + @BeforeEach + void setUp() { + filter = new ScimTokenAuthenticationFilter(tokenService, configService, + organizationLookupService); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void validTokenWithEnabledConfigAuthenticates() throws Exception { + when(tokenService.authenticate("af_scim_x")).thenReturn(Optional.of(principal)); + when(configService.isEnabled(orgId)).thenReturn(true); + when(organizationLookupService.isDisabled(orgId)).thenReturn(false); + + filter.doFilterInternal(request("Bearer af_scim_x"), new MockHttpServletResponse(), + filterChain); + + var authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isInstanceOf(ScimAuthenticationToken.class); + assertThat(authentication.getPrincipal()).isEqualTo(principal); + verify(filterChain).doFilter(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void disabledConfigLeavesContextEmpty() throws Exception { + when(tokenService.authenticate("af_scim_x")).thenReturn(Optional.of(principal)); + when(configService.isEnabled(orgId)).thenReturn(false); + + filter.doFilterInternal(request("Bearer af_scim_x"), new MockHttpServletResponse(), + filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void disabledOrganizationLeavesContextEmpty() throws Exception { + when(tokenService.authenticate("af_scim_x")).thenReturn(Optional.of(principal)); + when(configService.isEnabled(orgId)).thenReturn(true); + when(organizationLookupService.isDisabled(orgId)).thenReturn(true); + + filter.doFilterInternal(request("Bearer af_scim_x"), new MockHttpServletResponse(), + filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void unknownTokenLeavesContextEmpty() throws Exception { + when(tokenService.authenticate("af_scim_x")).thenReturn(Optional.empty()); + + filter.doFilterInternal(request("Bearer af_scim_x"), new MockHttpServletResponse(), + filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void missingOrNonBearerHeaderIsIgnored() throws Exception { + filter.doFilterInternal(request(null), new MockHttpServletResponse(), filterChain); + filter.doFilterInternal(request("ApiKey af_x"), new MockHttpServletResponse(), + filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + private static MockHttpServletRequest request(String authorization) { + var request = new MockHttpServletRequest("GET", "/scim/v2/Users"); + if (authorization != null) { + request.addHeader("Authorization", authorization); + } + return request; + } +} From afb57ff44e398fb1cbb0d9030a0aaf439161bc8f Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 13 Aug 2026 15:34:58 +0400 Subject: [PATCH 05/12] feat(AF-621): SCIM admin page and operator guide /admin/scim (SSO_CONFIGURE): enabled switch, attribute-mapping selects, default role, copyable /scim/v2 base URL, and bearer-token management with a show-once modal. AuthProvider union + labels gain SCIM. Website auth chapter gets the cfg-scim operator guide (Okta + Entra ID setup, troubleshooting) with the docs.ts <-> app.js anchor contract and sitemap/JSON-LD freshness bumped. All seven locales translated. --- frontend/src/App.tsx | 11 + frontend/src/api/admin.test.ts | 34 ++ frontend/src/api/admin.ts | 43 ++ frontend/src/components/common/Sidebar.tsx | 2 + frontend/src/config/docs.ts | 1 + frontend/src/locales/de.json | 41 +- frontend/src/locales/en.json | 41 +- frontend/src/locales/es.json | 41 +- frontend/src/locales/fr.json | 41 +- frontend/src/locales/hy.json | 41 +- frontend/src/locales/ru.json | 41 +- frontend/src/locales/zh-CN.json | 41 +- .../src/pages/admin/ScimConfigPage.test.tsx | 179 ++++++++ frontend/src/pages/admin/ScimConfigPage.tsx | 405 ++++++++++++++++++ frontend/src/types/api.ts | 41 +- website/app.js | 1 + website/docs/configuration/auth/index.html | 82 +++- website/sitemap.xml | 2 +- 18 files changed, 1077 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/admin/ScimConfigPage.test.tsx create mode 100644 frontend/src/pages/admin/ScimConfigPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2cd7762..ac0f48ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { LoginPage } from '@/pages/auth/LoginPage'; import { SetupPage } from '@/pages/auth/SetupPage'; const OAuthCallbackPage = lazy(() => import('@/pages/auth/OAuthCallbackPage')); const SamlCallbackPage = lazy(() => import('@/pages/auth/SamlCallbackPage')); +const ScimConfigPage = lazy(() => import('@/pages/admin/ScimConfigPage')); const AcceptInvitePage = lazy(() => import('@/pages/auth/AcceptInvitePage')); const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage')); const ResetPasswordPage = lazy(() => import('@/pages/auth/ResetPasswordPage')); @@ -589,6 +590,16 @@ export function App() { } /> + + + + + + } + /> { expect(put).toHaveBeenCalledWith('/api/v1/admin/saml-config', { active: true }); }); + // ── SCIM config + tokens (#621) ─────────────────────────────────────────── + it('getScimConfig GETs /admin/scim-config', async () => { + get.mockResolvedValueOnce({ data: { enabled: false } }); + await adminApi.getScimConfig(); + expect(get).toHaveBeenCalledWith('/api/v1/admin/scim-config'); + }); + + it('updateScimConfig PUTs the body', async () => { + put.mockResolvedValueOnce({ data: { enabled: true } }); + await adminApi.updateScimConfig({ enabled: true, attr_email: 'userName' }); + expect(put).toHaveBeenCalledWith('/api/v1/admin/scim-config', { + enabled: true, + attr_email: 'userName', + }); + }); + + it('listScimTokens GETs /admin/scim/tokens', async () => { + get.mockResolvedValueOnce({ data: [] }); + await adminApi.listScimTokens(); + expect(get).toHaveBeenCalledWith('/api/v1/admin/scim/tokens'); + }); + + it('createScimToken POSTs the name', async () => { + post.mockResolvedValueOnce({ data: { raw_token: 'af_scim_x' } }); + await adminApi.createScimToken({ name: 'okta-prod' }); + expect(post).toHaveBeenCalledWith('/api/v1/admin/scim/tokens', { name: 'okta-prod' }); + }); + + it('revokeScimToken DELETEs by id', async () => { + del.mockResolvedValueOnce({}); + await adminApi.revokeScimToken('token-1'); + expect(del).toHaveBeenCalledWith('/api/v1/admin/scim/tokens/token-1'); + }); + // ── Langfuse config ─────────────────────────────────────────────────────── it('getLangfuseConfig GETs /admin/langfuse-config', async () => { get.mockResolvedValueOnce({ data: { enabled: false } }); diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 9d83d4d8..17df87c4 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -21,6 +21,11 @@ import type { OAuth2Config, OAuth2Provider, SamlConfig, + ScimConfig, + ScimToken, + CreateScimTokenInput, + CreatedScimToken, + UpdateScimConfigInput, SetupProgress, TestAiConfigResult, TestNotificationChannelInput, @@ -48,6 +53,8 @@ const CHANNELS_BASE = '/api/v1/admin/notification-channels'; const AI_CONFIGS_BASE = '/api/v1/admin/ai-configs'; const AI_ANALYSES_BASE = '/api/v1/admin/ai-analyses'; const SAML_CONFIG_BASE = '/api/v1/admin/saml-config'; +const SCIM_CONFIG_BASE = '/api/v1/admin/scim-config'; +const SCIM_TOKENS_BASE = '/api/v1/admin/scim/tokens'; const LANGFUSE_CONFIG_BASE = '/api/v1/admin/langfuse-config'; const OAUTH2_CONFIG_BASE = '/api/v1/admin/oauth2-config'; const SETUP_PROGRESS_BASE = '/api/v1/admin/setup-progress'; @@ -96,6 +103,16 @@ export const samlConfigKeys = { current: () => ['samlConfig', 'current'] as const, }; +export const scimConfigKeys = { + all: ['scimConfig'] as const, + current: () => ['scimConfig', 'current'] as const, +}; + +export const scimTokenKeys = { + all: ['scimTokens'] as const, + list: () => ['scimTokens', 'list'] as const, +}; + export const langfuseConfigKeys = { all: ['langfuseConfig'] as const, current: () => ['langfuseConfig', 'current'] as const, @@ -354,6 +371,32 @@ export async function updateSamlConfig(input: UpdateSamlConfigInput): Promise { + const { data } = await apiClient.get(SCIM_CONFIG_BASE); + return data; +} + +export async function updateScimConfig(input: UpdateScimConfigInput): Promise { + const { data } = await apiClient.put(SCIM_CONFIG_BASE, input); + return data; +} + +export async function listScimTokens(): Promise { + const { data } = await apiClient.get(SCIM_TOKENS_BASE); + return data; +} + +export async function createScimToken(input: CreateScimTokenInput): Promise { + const { data } = await apiClient.post(SCIM_TOKENS_BASE, input); + return data; +} + +export async function revokeScimToken(id: string): Promise { + await apiClient.delete(`${SCIM_TOKENS_BASE}/${id}`); +} + // ── Langfuse config ────────────────────────────────────────────────────────── export async function getLangfuseConfig(): Promise { diff --git a/frontend/src/components/common/Sidebar.tsx b/frontend/src/components/common/Sidebar.tsx index 500a2124..b5a05a74 100644 --- a/frontend/src/components/common/Sidebar.tsx +++ b/frontend/src/components/common/Sidebar.tsx @@ -20,6 +20,7 @@ import { SlackOutlined, LeftOutlined, RightOutlined, + CloudSyncOutlined, CloseOutlined, BarChartOutlined, DashboardOutlined, @@ -129,6 +130,7 @@ export function Sidebar({ { id: 'auditor', to: '/admin/auditor', label: t('nav.auditor'), icon: , permissions: ['COMPLIANCE_REPORT_VIEW'] }, { id: 'saml', to: '/admin/saml', label: t('nav.saml'), icon: , permissions: ['SSO_CONFIGURE'] }, { id: 'oauth2', to: '/admin/oauth2', label: t('nav.oauth2'), icon: , permissions: ['SSO_CONFIGURE'] }, + { id: 'scim', to: '/admin/scim', label: t('nav.scim'), icon: , permissions: ['SSO_CONFIGURE'] }, { id: 'slack', to: '/admin/slack', label: t('nav.slack'), icon: , permissions: ['NOTIFICATION_CHANNEL_MANAGE'] }, ], }, diff --git a/frontend/src/config/docs.ts b/frontend/src/config/docs.ts index 79fb6b50..71fd87bc 100644 --- a/frontend/src/config/docs.ts +++ b/frontend/src/config/docs.ts @@ -39,6 +39,7 @@ export const DOCS_ANCHOR_PAGES = { 'cfg-langfuse': 'configuration/ai/', 'cfg-oauth': 'configuration/auth/', 'cfg-saml': 'configuration/auth/', + 'cfg-scim': 'configuration/auth/', 'cfg-notification-channels': 'configuration/notifications/', 'cfg-slack': 'configuration/notifications/', 'cfg-smtp': 'configuration/notifications/', diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 57ff9dc8..16928e6f 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -54,6 +54,7 @@ "ai_analyses": "KI-Analysen", "notifications": "Benachrichtigungen", "saml": "SAML / SSO", + "scim": "SCIM-Provisionierung", "oauth2": "OAuth-Anbieter", "languages": "Sprachen", "custom_drivers": "Benutzerdefinierte JDBC-Treiber", @@ -1837,6 +1838,43 @@ "save_button": "Speichern", "error_metadata_url_invalid": "Bitte eine gültige URL eingeben (https://…)" }, + "scim": { + "title": "SCIM-Provisionierung", + "subtitle": "Lassen Sie Ihren Identity Provider Benutzer automatisch anlegen, aktualisieren und deaktivieren sowie Gruppen synchronisieren.", + "load_error": "SCIM-Konfiguration konnte nicht geladen werden", + "save_success": "SCIM-Konfiguration gespeichert", + "save_button": "Speichern", + "section_endpoint": "SCIM-Endpunkt", + "label_base_url": "Basis-URL (im IdP konfigurieren)", + "label_enabled": "Aktiviert", + "section_mapping": "Attributzuordnung", + "label_attr_email": "Quellattribut für E-Mail", + "label_attr_display_name": "Quellattribut für Anzeigename", + "label_default_role": "Standardrolle für provisionierte Benutzer", + "section_tokens": "Bearer-Token", + "tokens_description": "Langlebige Token, mit denen sich Ihr IdP authentifiziert. Der Rohwert wird nur einmal angezeigt.", + "tokens_empty": "Noch keine SCIM-Token", + "token_create": "Token erstellen", + "token_create_title": "SCIM-Token erstellen", + "token_name_label": "Token-Name", + "token_name_placeholder": "z. B. okta-prod", + "token_name_required": "Token-Name ist erforderlich", + "token_name_size": "Token-Name darf höchstens 100 Zeichen lang sein", + "token_issued_title": "SCIM-Token erstellt", + "token_copy_once_warning": "Kopieren Sie dieses Token jetzt — es wird nicht erneut angezeigt.", + "token_raw_label": "Bearer-Token", + "token_revoked": "Token widerrufen", + "token_revoke": "Widerrufen", + "token_revoke_confirm": "Token „{{name}}“ widerrufen? Ihr IdP verliert sofort den Zugriff.", + "token_revoke_aria": "Token {{name}} widerrufen", + "token_column_name": "Name", + "token_column_prefix": "Präfix", + "token_column_created_at": "Erstellt", + "token_column_last_used_at": "Zuletzt verwendet", + "token_column_status": "Status", + "token_status_active": "Aktiv", + "token_status_revoked": "Widerrufen" + }, "languages": { "title": "Sprachen", "subtitle": "Lege fest, welche Sprachen Benutzer wählen können, die Standardsprache für neue Konten und die Sprache, in der die KI antwortet.", @@ -2423,7 +2461,8 @@ "auth_provider": { "LOCAL": "Lokal", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index f4bd1c69..df0725a5 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -57,6 +57,7 @@ "ai_analyses": "AI analyses", "notifications": "Notifications", "saml": "SAML / SSO", + "scim": "SCIM Provisioning", "oauth2": "OAuth providers", "languages": "Languages", "custom_drivers": "Custom JDBC drivers", @@ -1894,6 +1895,43 @@ "save_button": "Save", "error_metadata_url_invalid": "Enter a valid URL (https://…)" }, + "scim": { + "title": "SCIM Provisioning", + "subtitle": "Let your identity provider create, update, and deactivate users and sync groups automatically.", + "load_error": "Failed to load SCIM configuration", + "save_success": "SCIM configuration saved", + "save_button": "Save", + "section_endpoint": "SCIM endpoint", + "label_base_url": "Base URL (configure this in your IdP)", + "label_enabled": "Enabled", + "section_mapping": "Attribute mapping", + "label_attr_email": "Email source attribute", + "label_attr_display_name": "Display-name source attribute", + "label_default_role": "Default role for provisioned users", + "section_tokens": "Bearer tokens", + "tokens_description": "Long-lived tokens your IdP authenticates with. The raw value is shown only once.", + "tokens_empty": "No SCIM tokens yet", + "token_create": "Create token", + "token_create_title": "Create SCIM token", + "token_name_label": "Token name", + "token_name_placeholder": "e.g. okta-prod", + "token_name_required": "Token name is required", + "token_name_size": "Token name must be at most 100 characters", + "token_issued_title": "SCIM token created", + "token_copy_once_warning": "Copy this token now — it will not be shown again.", + "token_raw_label": "Bearer token", + "token_revoked": "Token revoked", + "token_revoke": "Revoke", + "token_revoke_confirm": "Revoke token “{{name}}”? Your IdP will immediately lose access.", + "token_revoke_aria": "Revoke token {{name}}", + "token_column_name": "Name", + "token_column_prefix": "Prefix", + "token_column_created_at": "Created", + "token_column_last_used_at": "Last used", + "token_column_status": "Status", + "token_status_active": "Active", + "token_status_revoked": "Revoked" + }, "languages": { "title": "Languages", "subtitle": "Pick which languages users can choose, the default for new accounts, and the language the AI analyzer responds in.", @@ -2447,7 +2485,8 @@ "auth_provider": { "LOCAL": "Local", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index ae2affbf..c93615da 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -54,6 +54,7 @@ "ai_analyses": "Análisis de IA", "notifications": "Notificaciones", "saml": "SAML / SSO", + "scim": "Aprovisionamiento SCIM", "oauth2": "Proveedores de OAuth", "languages": "Idiomas", "custom_drivers": "Controladores JDBC personalizados", @@ -1837,6 +1838,43 @@ "save_button": "Guardar", "error_metadata_url_invalid": "Introduzca una URL válida (https://…)" }, + "scim": { + "title": "Aprovisionamiento SCIM", + "subtitle": "Permita que su proveedor de identidad cree, actualice y desactive usuarios y sincronice grupos automáticamente.", + "load_error": "No se pudo cargar la configuración SCIM", + "save_success": "Configuración SCIM guardada", + "save_button": "Guardar", + "section_endpoint": "Endpoint SCIM", + "label_base_url": "URL base (configúrela en su IdP)", + "label_enabled": "Habilitado", + "section_mapping": "Asignación de atributos", + "label_attr_email": "Atributo de origen del correo", + "label_attr_display_name": "Atributo de origen del nombre para mostrar", + "label_default_role": "Rol predeterminado para usuarios aprovisionados", + "section_tokens": "Tokens Bearer", + "tokens_description": "Tokens de larga duración con los que se autentica su IdP. El valor solo se muestra una vez.", + "tokens_empty": "Aún no hay tokens SCIM", + "token_create": "Crear token", + "token_create_title": "Crear token SCIM", + "token_name_label": "Nombre del token", + "token_name_placeholder": "p. ej. okta-prod", + "token_name_required": "El nombre del token es obligatorio", + "token_name_size": "El nombre del token debe tener como máximo 100 caracteres", + "token_issued_title": "Token SCIM creado", + "token_copy_once_warning": "Copie este token ahora — no se volverá a mostrar.", + "token_raw_label": "Token Bearer", + "token_revoked": "Token revocado", + "token_revoke": "Revocar", + "token_revoke_confirm": "¿Revocar el token «{{name}}»? Su IdP perderá el acceso inmediatamente.", + "token_revoke_aria": "Revocar token {{name}}", + "token_column_name": "Nombre", + "token_column_prefix": "Prefijo", + "token_column_created_at": "Creado", + "token_column_last_used_at": "Último uso", + "token_column_status": "Estado", + "token_status_active": "Activo", + "token_status_revoked": "Revocado" + }, "languages": { "title": "Idiomas", "subtitle": "Elige qué idiomas pueden seleccionar los usuarios, el predeterminado para nuevas cuentas y el idioma en el que responde la IA.", @@ -2423,7 +2461,8 @@ "auth_provider": { "LOCAL": "Local", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index bf9136f7..374e989d 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -54,6 +54,7 @@ "ai_analyses": "Analyses IA", "notifications": "Notifications", "saml": "SAML / SSO", + "scim": "Provisionnement SCIM", "oauth2": "Fournisseurs OAuth", "languages": "Langues", "custom_drivers": "Pilotes JDBC personnalisés", @@ -1837,6 +1838,43 @@ "save_button": "Enregistrer", "error_metadata_url_invalid": "Saisissez une URL valide (https://…)" }, + "scim": { + "title": "Provisionnement SCIM", + "subtitle": "Laissez votre fournisseur d'identité créer, mettre à jour et désactiver les utilisateurs et synchroniser les groupes automatiquement.", + "load_error": "Échec du chargement de la configuration SCIM", + "save_success": "Configuration SCIM enregistrée", + "save_button": "Enregistrer", + "section_endpoint": "Point de terminaison SCIM", + "label_base_url": "URL de base (à configurer dans votre IdP)", + "label_enabled": "Activé", + "section_mapping": "Mappage des attributs", + "label_attr_email": "Attribut source de l'e-mail", + "label_attr_display_name": "Attribut source du nom d'affichage", + "label_default_role": "Rôle par défaut des utilisateurs provisionnés", + "section_tokens": "Jetons Bearer", + "tokens_description": "Jetons de longue durée avec lesquels votre IdP s'authentifie. La valeur brute n'est affichée qu'une seule fois.", + "tokens_empty": "Aucun jeton SCIM pour l'instant", + "token_create": "Créer un jeton", + "token_create_title": "Créer un jeton SCIM", + "token_name_label": "Nom du jeton", + "token_name_placeholder": "p. ex. okta-prod", + "token_name_required": "Le nom du jeton est obligatoire", + "token_name_size": "Le nom du jeton ne doit pas dépasser 100 caractères", + "token_issued_title": "Jeton SCIM créé", + "token_copy_once_warning": "Copiez ce jeton maintenant — il ne sera plus affiché.", + "token_raw_label": "Jeton Bearer", + "token_revoked": "Jeton révoqué", + "token_revoke": "Révoquer", + "token_revoke_confirm": "Révoquer le jeton « {{name}} » ? Votre IdP perdra immédiatement l'accès.", + "token_revoke_aria": "Révoquer le jeton {{name}}", + "token_column_name": "Nom", + "token_column_prefix": "Préfixe", + "token_column_created_at": "Créé", + "token_column_last_used_at": "Dernière utilisation", + "token_column_status": "Statut", + "token_status_active": "Actif", + "token_status_revoked": "Révoqué" + }, "languages": { "title": "Langues", "subtitle": "Choisis quelles langues les utilisateurs peuvent sélectionner, la langue par défaut pour les nouveaux comptes et la langue dans laquelle l'IA répond.", @@ -2423,7 +2461,8 @@ "auth_provider": { "LOCAL": "Local", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json index 7f77853f..e1c1b8d2 100644 --- a/frontend/src/locales/hy.json +++ b/frontend/src/locales/hy.json @@ -54,6 +54,7 @@ "ai_analyses": "AI վերլուծություններ", "notifications": "Ծանուցումներ", "saml": "SAML / SSO", + "scim": "SCIM տրամադրում", "oauth2": "OAuth մատակարարներ", "languages": "Լեզուներ", "custom_drivers": "Հատուկ JDBC դրայվերներ", @@ -1837,6 +1838,43 @@ "save_button": "Պահպանել", "error_metadata_url_invalid": "Մուտքագրեք վավեր URL (https://…)" }, + "scim": { + "title": "SCIM տրամադրում", + "subtitle": "Թույլ տվեք ձեր ինքնության մատակարարին ավտոմատ ստեղծել, թարմացնել և ապաակտիվացնել օգտատերերին ու համաժամեցնել խմբերը:", + "load_error": "Չհաջողվեց բեռնել SCIM կարգավորումը", + "save_success": "SCIM կարգավորումը պահպանվեց", + "save_button": "Պահպանել", + "section_endpoint": "SCIM վերջնակետ", + "label_base_url": "Հիմնական URL (կարգավորեք ձեր IdP-ում)", + "label_enabled": "Միացված է", + "section_mapping": "Ատրիբուտների համապատասխանեցում", + "label_attr_email": "Էլ. փոստի աղբյուր ատրիբուտ", + "label_attr_display_name": "Ցուցադրվող անվան աղբյուր ատրիբուտ", + "label_default_role": "Տրամադրված օգտատերերի լռելյայն դերը", + "section_tokens": "Bearer թոքեններ", + "tokens_description": "Երկարակյաց թոքեններ, որոնցով նույնականանում է ձեր IdP-ն։ Արժեքը ցուցադրվում է միայն մեկ անգամ:", + "tokens_empty": "Դեռ SCIM թոքեններ չկան", + "token_create": "Ստեղծել թոքեն", + "token_create_title": "Ստեղծել SCIM թոքեն", + "token_name_label": "Թոքենի անուն", + "token_name_placeholder": "օր.՝ okta-prod", + "token_name_required": "Թոքենի անունը պարտադիր է", + "token_name_size": "Թոքենի անունը պետք է լինի առավելագույնը 100 նիշ", + "token_issued_title": "SCIM թոքենը ստեղծվեց", + "token_copy_once_warning": "Պատճենեք այս թոքենը հիմա — այն այլևս չի ցուցադրվի:", + "token_raw_label": "Bearer թոքեն", + "token_revoked": "Թոքենը չեղարկվեց", + "token_revoke": "Չեղարկել", + "token_revoke_confirm": "Չեղարկե՞լ «{{name}}» թոքենը։ Ձեր IdP-ն անմիջապես կկորցնի մուտքը:", + "token_revoke_aria": "Չեղարկել {{name}} թոքենը", + "token_column_name": "Անուն", + "token_column_prefix": "Նախածանց", + "token_column_created_at": "Ստեղծվել է", + "token_column_last_used_at": "Վերջին օգտագործումը", + "token_column_status": "Կարգավիճակ", + "token_status_active": "Ակտիվ", + "token_status_revoked": "Չեղարկված" + }, "languages": { "title": "Լեզուներ", "subtitle": "Ընտրեք, թե որ լեզուները կարող են ընտրել օգտատերերը, կանխադրված լեզուն նոր հաշիվների համար և լեզուն, որով AI-ն պատասխանում է։", @@ -2423,7 +2461,8 @@ "auth_provider": { "LOCAL": "Տեղական", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 34652ad2..880b856f 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -54,6 +54,7 @@ "ai_analyses": "Анализы ИИ", "notifications": "Уведомления", "saml": "SAML / SSO", + "scim": "SCIM-провижининг", "oauth2": "Провайдеры OAuth", "languages": "Языки", "custom_drivers": "Пользовательские JDBC-драйверы", @@ -1837,6 +1838,43 @@ "save_button": "Сохранить", "error_metadata_url_invalid": "Введите корректный URL (https://…)" }, + "scim": { + "title": "SCIM-провижининг", + "subtitle": "Позвольте вашему провайдеру идентификации автоматически создавать, обновлять и деактивировать пользователей и синхронизировать группы.", + "load_error": "Не удалось загрузить конфигурацию SCIM", + "save_success": "Конфигурация SCIM сохранена", + "save_button": "Сохранить", + "section_endpoint": "Конечная точка SCIM", + "label_base_url": "Базовый URL (настройте в вашем IdP)", + "label_enabled": "Включено", + "section_mapping": "Сопоставление атрибутов", + "label_attr_email": "Атрибут-источник email", + "label_attr_display_name": "Атрибут-источник отображаемого имени", + "label_default_role": "Роль по умолчанию для созданных пользователей", + "section_tokens": "Bearer-токены", + "tokens_description": "Долгоживущие токены, которыми аутентифицируется ваш IdP. Значение показывается только один раз.", + "tokens_empty": "SCIM-токенов пока нет", + "token_create": "Создать токен", + "token_create_title": "Создать SCIM-токен", + "token_name_label": "Имя токена", + "token_name_placeholder": "напр. okta-prod", + "token_name_required": "Имя токена обязательно", + "token_name_size": "Имя токена должно содержать не более 100 символов", + "token_issued_title": "SCIM-токен создан", + "token_copy_once_warning": "Скопируйте этот токен сейчас — он больше не будет показан.", + "token_raw_label": "Bearer-токен", + "token_revoked": "Токен отозван", + "token_revoke": "Отозвать", + "token_revoke_confirm": "Отозвать токен «{{name}}»? Ваш IdP немедленно потеряет доступ.", + "token_revoke_aria": "Отозвать токен {{name}}", + "token_column_name": "Имя", + "token_column_prefix": "Префикс", + "token_column_created_at": "Создан", + "token_column_last_used_at": "Последнее использование", + "token_column_status": "Статус", + "token_status_active": "Активен", + "token_status_revoked": "Отозван" + }, "languages": { "title": "Языки", "subtitle": "Выберите языки для пользователей, язык по умолчанию для новых учётных записей и язык, на котором отвечает ИИ.", @@ -2423,7 +2461,8 @@ "auth_provider": { "LOCAL": "Локальный", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 0aab656f..573d2cfc 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -54,6 +54,7 @@ "ai_analyses": "AI 分析", "notifications": "通知", "saml": "SAML / SSO", + "scim": "SCIM 预配", "oauth2": "OAuth 服务商", "languages": "语言", "custom_drivers": "自定义 JDBC 驱动", @@ -1837,6 +1838,43 @@ "save_button": "保存", "error_metadata_url_invalid": "请输入有效的 URL(https://…)" }, + "scim": { + "title": "SCIM 预配", + "subtitle": "让您的身份提供商自动创建、更新和停用用户并同步组。", + "load_error": "无法加载 SCIM 配置", + "save_success": "SCIM 配置已保存", + "save_button": "保存", + "section_endpoint": "SCIM 端点", + "label_base_url": "基础 URL(在您的 IdP 中配置)", + "label_enabled": "已启用", + "section_mapping": "属性映射", + "label_attr_email": "邮箱来源属性", + "label_attr_display_name": "显示名称来源属性", + "label_default_role": "预配用户的默认角色", + "section_tokens": "Bearer 令牌", + "tokens_description": "您的 IdP 用于身份验证的长期令牌。原始值仅显示一次。", + "tokens_empty": "暂无 SCIM 令牌", + "token_create": "创建令牌", + "token_create_title": "创建 SCIM 令牌", + "token_name_label": "令牌名称", + "token_name_placeholder": "例如 okta-prod", + "token_name_required": "令牌名称为必填项", + "token_name_size": "令牌名称最多 100 个字符", + "token_issued_title": "SCIM 令牌已创建", + "token_copy_once_warning": "请立即复制此令牌 — 它不会再次显示。", + "token_raw_label": "Bearer 令牌", + "token_revoked": "令牌已吊销", + "token_revoke": "吊销", + "token_revoke_confirm": "吊销令牌“{{name}}”?您的 IdP 将立即失去访问权限。", + "token_revoke_aria": "吊销令牌 {{name}}", + "token_column_name": "名称", + "token_column_prefix": "前缀", + "token_column_created_at": "创建时间", + "token_column_last_used_at": "最后使用", + "token_column_status": "状态", + "token_status_active": "有效", + "token_status_revoked": "已吊销" + }, "languages": { "title": "语言", "subtitle": "选择用户可以使用的语言、新账户的默认语言以及 AI 响应所用的语言。", @@ -2423,7 +2461,8 @@ "auth_provider": { "LOCAL": "本地", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/pages/admin/ScimConfigPage.test.tsx b/frontend/src/pages/admin/ScimConfigPage.test.tsx new file mode 100644 index 00000000..1173d758 --- /dev/null +++ b/frontend/src/pages/admin/ScimConfigPage.test.tsx @@ -0,0 +1,179 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { App } from 'antd'; +import type { ReactNode } from 'react'; +import '@/i18n'; +import type { ScimConfig, ScimToken } from '@/types/api'; + +const { + getScimConfigMock, + updateScimConfigMock, + listScimTokensMock, + createScimTokenMock, + revokeScimTokenMock, +} = vi.hoisted(() => ({ + getScimConfigMock: vi.fn(), + updateScimConfigMock: vi.fn(), + listScimTokensMock: vi.fn(), + createScimTokenMock: vi.fn(), + revokeScimTokenMock: vi.fn(), +})); + +vi.mock('@/api/admin', async () => { + const actual = await vi.importActual('@/api/admin'); + return { + ...actual, + getScimConfig: getScimConfigMock, + updateScimConfig: updateScimConfigMock, + listScimTokens: listScimTokensMock, + createScimToken: createScimTokenMock, + revokeScimToken: revokeScimTokenMock, + }; +}); + +const { ScimConfigPage } = await import('./ScimConfigPage'); + +function config(partial: Partial = {}): ScimConfig { + return { + id: 'cfg-1', + organization_id: 'org-1', + enabled: false, + attr_email: 'userName', + attr_display_name: 'displayName', + default_role: 'ANALYST', + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', + ...partial, + }; +} + +function token(partial: Partial = {}): ScimToken { + return { + id: 'tok-1', + name: 'okta-prod', + token_prefix: 'af_scim_AbCd', + created_at: '2026-08-01T00:00:00Z', + last_used_at: null, + revoked_at: null, + ...partial, + }; +} + +function wrap(node: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + + {node} + + + ); +} + +describe('ScimConfigPage', () => { + beforeEach(() => { + getScimConfigMock.mockReset(); + updateScimConfigMock.mockReset(); + listScimTokensMock.mockReset(); + createScimTokenMock.mockReset(); + revokeScimTokenMock.mockReset(); + getScimConfigMock.mockResolvedValue(config()); + listScimTokensMock.mockResolvedValue([]); + }); + + it('renders the config form with values from the server', async () => { + getScimConfigMock.mockResolvedValue(config({ enabled: true, default_role: 'READONLY' })); + + render(wrap()); + + expect(await screen.findByText('SCIM Provisioning')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('switch')).toBeChecked()); + expect(screen.getByText(`${window.location.origin}/scim/v2`)).toBeInTheDocument(); + }); + + it('saves the configuration', async () => { + updateScimConfigMock.mockResolvedValue(config({ enabled: true })); + + render(wrap()); + await screen.findByText('SCIM Provisioning'); + + fireEvent.click(screen.getByRole('switch')); + fireEvent.click(screen.getByRole('button', { name: /Save/ })); + + await waitFor(() => + expect(updateScimConfigMock).toHaveBeenCalledWith({ + enabled: true, + attr_email: 'userName', + attr_display_name: 'displayName', + default_role: 'ANALYST', + }), + ); + }); + + it('lists tokens with prefix and status', async () => { + listScimTokensMock.mockResolvedValue([ + token(), + token({ + id: 'tok-2', + name: 'old', + token_prefix: 'af_scim_ZzZz', + revoked_at: '2026-08-02T00:00:00Z', + }), + ]); + + render(wrap()); + + expect(await screen.findByText('okta-prod')).toBeInTheDocument(); + expect(screen.getByText('af_scim_AbCd…')).toBeInTheDocument(); + expect(screen.getByText('Revoked')).toBeInTheDocument(); + }); + + it('creating a token shows the raw value once', async () => { + createScimTokenMock.mockResolvedValue({ + token: token(), + raw_token: 'af_scim_raw_value_shown_once', + }); + + render(wrap()); + await screen.findByText('SCIM Provisioning'); + + fireEvent.click(screen.getByRole('button', { name: 'Create token' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.change(within(dialog).getByLabelText('Token name'), { + target: { value: 'okta-prod' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Create token' })); + // mutationFn receives (variables, context) — assert on the first argument only. + await waitFor(() => + expect(createScimTokenMock.mock.calls[0]?.[0]).toEqual({ name: 'okta-prod' }), + ); + + expect(await screen.findByText('af_scim_raw_value_shown_once')).toBeInTheDocument(); + expect( + screen.getByText('Copy this token now — it will not be shown again.'), + ).toBeInTheDocument(); + }); + + it('revokes a token after confirmation', async () => { + listScimTokensMock.mockResolvedValue([token()]); + revokeScimTokenMock.mockResolvedValue(undefined); + + render(wrap()); + await screen.findByText('okta-prod'); + + fireEvent.click(screen.getByRole('button', { name: /Revoke token okta-prod/ })); + fireEvent.click(await screen.findByRole('button', { name: 'Revoke' })); + + await waitFor(() => expect(revokeScimTokenMock).toHaveBeenCalledWith('tok-1')); + }); + + it('shows the load-error state', async () => { + getScimConfigMock.mockRejectedValue(new Error('boom')); + + render(wrap()); + + expect(await screen.findByText('Failed to load SCIM configuration')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/admin/ScimConfigPage.tsx b/frontend/src/pages/admin/ScimConfigPage.tsx new file mode 100644 index 00000000..cfcccefe --- /dev/null +++ b/frontend/src/pages/admin/ScimConfigPage.tsx @@ -0,0 +1,405 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + App, + Button, + Empty, + Form, + Input, + Modal, + Popconfirm, + Select, + Skeleton, + Space, + Switch, + Table, + Typography, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { CheckOutlined } from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { PageHeader } from '@/components/common/PageHeader'; +import { EmptyState } from '@/components/common/EmptyState'; +import { + createScimToken, + getScimConfig, + listScimTokens, + revokeScimToken, + scimConfigKeys, + scimTokenKeys, + updateScimConfig, +} from '@/api/admin'; +import { adminErrorMessage } from '@/utils/apiErrors'; +import { enumOptions, roleLabel } from '@/utils/enumLabels'; +import { showApiError } from '@/utils/showApiError'; +import type { + CreatedScimToken, + Role, + ScimAttrDisplayName, + ScimAttrEmail, + ScimToken, + UpdateScimConfigInput, +} from '@/types/api'; + +const ROLE_VALUES: readonly Role[] = ['ADMIN', 'REVIEWER', 'ANALYST', 'READONLY'] as const; +const ATTR_EMAIL_VALUES: readonly ScimAttrEmail[] = ['userName', 'emails.primary'] as const; +const ATTR_DISPLAY_NAME_VALUES: readonly ScimAttrDisplayName[] = [ + 'displayName', + 'name.formatted', + 'userName', +] as const; + +interface ScimFormValues { + enabled: boolean; + attr_email: ScimAttrEmail; + attr_display_name: ScimAttrDisplayName; + default_role: Role; +} + +interface CreateTokenFormValues { + name: string; +} + +export function ScimConfigPage() { + const { t, i18n } = useTranslation(); + const { message } = App.useApp(); + const queryClient = useQueryClient(); + const [form] = Form.useForm(); + const [tokenForm] = Form.useForm(); + const [createOpen, setCreateOpen] = useState(false); + const [issuedToken, setIssuedToken] = useState(null); + + const cfgQuery = useQuery({ + queryKey: scimConfigKeys.current(), + queryFn: getScimConfig, + }); + + const tokensQuery = useQuery({ + queryKey: scimTokenKeys.list(), + queryFn: listScimTokens, + }); + + useEffect(() => { + if (cfgQuery.data) { + form.setFieldsValue({ + enabled: cfgQuery.data.enabled, + attr_email: cfgQuery.data.attr_email, + attr_display_name: cfgQuery.data.attr_display_name, + default_role: cfgQuery.data.default_role, + }); + } + }, [cfgQuery.data, form]); + + const saveMutation = useMutation({ + mutationFn: (payload: UpdateScimConfigInput) => updateScimConfig(payload), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: scimConfigKeys.all }); + message.success(t('admin.scim.save_success')); + }, + onError: (err) => showApiError(message, err, adminErrorMessage), + }); + + const createTokenMutation = useMutation({ + mutationFn: createScimToken, + onSuccess: (response) => { + setIssuedToken(response); + setCreateOpen(false); + tokenForm.resetFields(); + void queryClient.invalidateQueries({ queryKey: scimTokenKeys.all }); + }, + onError: (err) => showApiError(message, err, adminErrorMessage), + }); + + const revokeTokenMutation = useMutation({ + mutationFn: (id: string) => revokeScimToken(id), + onSuccess: () => { + message.success(t('admin.scim.token_revoked')); + void queryClient.invalidateQueries({ queryKey: scimTokenKeys.all }); + }, + onError: (err) => showApiError(message, err, adminErrorMessage), + }); + + const onFinish = (values: ScimFormValues) => { + saveMutation.mutate({ + enabled: values.enabled, + attr_email: values.attr_email, + attr_display_name: values.attr_display_name, + default_role: values.default_role, + }); + }; + + const dateFormatter = new Intl.DateTimeFormat(i18n.language, { + dateStyle: 'medium', + timeStyle: 'short', + }); + const fmtDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—'); + + const baseUrl = `${window.location.origin}/scim/v2`; + + const tokenColumns: ColumnsType = [ + { + title: t('admin.scim.token_column_name'), + dataIndex: 'name', + key: 'name', + render: (name: string) => {name}, + }, + { + title: t('admin.scim.token_column_prefix'), + dataIndex: 'token_prefix', + key: 'token_prefix', + render: (prefix: string) => {prefix}…, + }, + { + title: t('admin.scim.token_column_created_at'), + dataIndex: 'created_at', + key: 'created_at', + render: fmtDate, + }, + { + title: t('admin.scim.token_column_last_used_at'), + dataIndex: 'last_used_at', + key: 'last_used_at', + render: fmtDate, + }, + { + title: t('admin.scim.token_column_status'), + key: 'status', + render: (_, token) => + token.revoked_at ? t('admin.scim.token_status_revoked') : t('admin.scim.token_status_active'), + }, + { + title: '', + key: 'actions', + width: 120, + render: (_, token) => + token.revoked_at ? null : ( + revokeTokenMutation.mutate(token.id)} + > + + + ), + }, + ]; + + if (cfgQuery.isLoading) { + return ( +

+ +
+ ); + } + if (cfgQuery.isError) { + return ( + + ); + } + + return ( +
+ +
+ form={form} layout="vertical" onFinish={onFinish}> +
+ + + {baseUrl} + + + + + +
+ +
+ + + ({ value, label: value }))} + /> + + + + + + + + setIssuedToken(null)} + footer={[ + , + ]} + destroyOnHidden + > + {issuedToken && ( + + + {t('admin.scim.token_name_label')} + {issuedToken.token.name} + {t('admin.scim.token_raw_label')} + + {issuedToken.raw_token} + + + )} + +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function Grid({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +export default ScimConfigPage; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index cc011271..d135f1aa 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,5 +1,5 @@ export type Role = 'READONLY' | 'ANALYST' | 'REVIEWER' | 'ADMIN' | 'AUDITOR'; -export type AuthProvider = 'LOCAL' | 'SAML' | 'OAUTH2'; +export type AuthProvider = 'LOCAL' | 'SAML' | 'OAUTH2' | 'SCIM'; export type OAuth2Provider = | 'GOOGLE' | 'GITHUB' @@ -458,6 +458,45 @@ export interface UpdateSamlConfigInput { active?: boolean; } +export type ScimAttrEmail = 'userName' | 'emails.primary'; +export type ScimAttrDisplayName = 'displayName' | 'name.formatted' | 'userName'; + +export interface ScimConfig { + id: string | null; + organization_id: string; + enabled: boolean; + attr_email: ScimAttrEmail; + attr_display_name: ScimAttrDisplayName; + default_role: Role; + created_at: string | null; + updated_at: string | null; +} + +export interface UpdateScimConfigInput { + enabled?: boolean; + attr_email?: ScimAttrEmail; + attr_display_name?: ScimAttrDisplayName; + default_role?: Role; +} + +export interface ScimToken { + id: string; + name: string; + token_prefix: string; + created_at: string; + last_used_at: string | null; + revoked_at: string | null; +} + +export interface CreateScimTokenInput { + name: string; +} + +export interface CreatedScimToken { + token: ScimToken; + raw_token: string; +} + export interface LangfuseConfig { id: string | null; organization_id: string; diff --git a/website/app.js b/website/app.js index 8819b99d..e87d79cd 100644 --- a/website/app.js +++ b/website/app.js @@ -206,6 +206,7 @@ 'compliance-reports': '/docs/configuration/audit-compliance/', 'cfg-oauth': '/docs/configuration/auth/', 'cfg-saml': '/docs/configuration/auth/', + 'cfg-scim': '/docs/configuration/auth/', 'cfg-api-connectors': '/docs/configuration/connectors/', 'cfg-connectors': '/docs/configuration/connectors/', 'cfg-data-classifications': '/docs/configuration/datasources/', diff --git a/website/docs/configuration/auth/index.html b/website/docs/configuration/auth/index.html index b2759704..0f0cb676 100644 --- a/website/docs/configuration/auth/index.html +++ b/website/docs/configuration/auth/index.html @@ -62,7 +62,7 @@ "inLanguage": "en", "articleSection": "Configuration", "datePublished": "2026-04-01", - "dateModified": "2026-08-03", + "dateModified": "2026-08-13", "url": "https://accessflow.bablsoft.com/docs/configuration/auth/", "mainEntityOfPage": "https://accessflow.bablsoft.com/docs/configuration/auth/", "image": "https://accessflow.bablsoft.com/og-image.png", @@ -216,6 +216,7 @@

On this page

OAuth 2.0 / OIDC SAML 2.0 SSO + SCIM 2.0 provisioning Notifications Audit & compliance End-user workflows @@ -229,7 +230,7 @@
Documentation

Authentication & SSO.

-

Last updated

+

Last updated

@@ -334,6 +335,83 @@

SAML 2.0 SSO

ACCESSFLOW_SAML_SP_SIGNING_CERT_PEM.

+ +
+ +
+

SCIM 2.0 provisioning

+

+ What it is. SSO answers "can this person sign in?" — SCIM answers "does + this person still have an account at all?". With SCIM enabled, your identity provider + (Okta, Microsoft Entra ID, Keycloak, OneLogin) creates, updates, and deactivates + AccessFlow users and syncs group memberships automatically, so joiner/mover/leaver flows + are driven from the IdP. Deactivating a user over SCIM disables login, revokes all their + refresh tokens, and immediately revokes their active just-in-time access grants. +

+

+ Configure it. Open /admin/scim: +

+
    +
  1. Copy the base URL shown at the top of the page + (https://<your-host>/scim/v2) — you will paste it into the IdP.
  2. +
  3. Attribute mapping. Pick which SCIM attribute the user's email is read + from (userName, the default, or emails.primary) and which one + carries the display name (displayName, name.formatted, or + userName). Choose the default role provisioned users receive.
  4. +
  5. Create a bearer token. Give it a name per IdP + (e.g. okta-prod) and copy the value — it is shown exactly once; only a + SHA-256 hash is stored. Rotate by creating a second token, switching the IdP over, then + revoking the old one.
  6. +
  7. Flip Enabled on and save.
  8. +
+

Okta setup.

+
    +
  1. In Okta Admin, open your AccessFlow app integration (or add an app that + supports SCIM provisioning) and go to Provisioning → Integration.
  2. +
  3. Set SCIM connector base URL to https://<your-host>/scim/v2, + Unique identifier field for users to userName, and + Authentication Mode to HTTP Header with the bearer token you created.
  4. +
  5. Click Test Connector Configuration, then enable + Create Users, Update User Attributes, and Deactivate Users + under Provisioning → To App. Use Push Groups to sync groups.
  6. +
+

Microsoft Entra ID setup.

+
    +
  1. In the Entra admin center, open your enterprise application and go to + Provisioning → New configuration.
  2. +
  3. Set Tenant URL to https://<your-host>/scim/v2 and + Secret token to the bearer token, then Test connection.
  4. +
  5. Review the default attribute mappings (Entra sends userName as the UPN — + keep the email mapping at userName, or map emails.primary if + you provision the mail attribute), assign users/groups in scope, and turn provisioning + On. Entra syncs on a ~40-minute cycle.
  6. +
+

+ Keycloak (via a SCIM extension) and OneLogin follow the same shape: base URL + bearer + token. AccessFlow supports the SCIM 2.0 core /Users and /Groups + resources with eq filtering and PATCH; bulk operations, sorting, and ETags + are not supported (the IdPs above do not need them). +

+

+ Worth knowing. SCIM-provisioned users have no password — they sign in + through your SSO. SCIM never writes roles, platform-admin flags, or TOTP settings. + Group memberships pushed over SCIM are tracked separately from memberships an admin + added manually and from SSO-login group mapping, so the three sources never overwrite + each other. Deleting a group at the IdP deletes it in AccessFlow — including any + group-based grants attached to it. Every SCIM mutation lands in the audit log + (SCIM_USER_PROVISIONED, SCIM_USER_DEACTIVATED, + SCIM_GROUP_SYNCED, …) with the token identity in the metadata. +

+

Troubleshooting.

+
    +
  • 401 from the IdP's connection test — the token was revoked, SCIM is not + enabled on /admin/scim, or the organization is disabled.
  • +
  • 409 on user create — a user with that email already exists (emails are + globally unique). The IdP links to the existing user via its + userName eq lookup instead.
  • +
  • 403 on user create — the organization's user quota is exhausted.
  • +
+
diff --git a/website/sitemap.xml b/website/sitemap.xml index 1791abec..bdd77e8e 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -50,7 +50,7 @@ https://accessflow.bablsoft.com/docs/configuration/auth/ - 2026-08-03 + 2026-08-13 weekly 0.7 From 06e3fbc1447f4a3c55190650d6b0fc95525bd092 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 13 Aug 2026 15:37:02 +0400 Subject: [PATCH 06/12] test(AF-621): e2e spec for SCIM config, tokens, and provisioning Drives /admin/scim (enable + save, show-once token modal, revoke) and exercises the SCIM protocol against the backend origin with the issued bearer token: Okta-shaped create + userName filter, Entra-shaped PATCH deactivate, and the 401 SCIM error envelope after revocation. --- e2e/tests/admin-scim-config.spec.ts | 283 ++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 e2e/tests/admin-scim-config.spec.ts diff --git a/e2e/tests/admin-scim-config.spec.ts b/e2e/tests/admin-scim-config.spec.ts new file mode 100644 index 00000000..a1ea2af2 --- /dev/null +++ b/e2e/tests/admin-scim-config.spec.ts @@ -0,0 +1,283 @@ +import { + test, + expect, + type APIRequestContext, + type Page, +} from '@playwright/test'; +import { loginViaApi } from '../helpers/datasources'; + +const ADMIN_EMAIL = 'e2e@accessflow.test'; +const ADMIN_PASSWORD = 'E2ePassword!123'; + +const DEFAULT_API_BASE = 'http://localhost:8080'; + +// #621 — the standard e2e stack seeds no scim_config row, so we start from +// "SCIM disabled / no tokens". The spec drives the /admin/scim page for +// config + token management, then exercises the SCIM protocol surface itself +// with the issued bearer token against the backend origin (page.request with +// a relative URL would hit the SPA, not the backend). + +// Unique per run so re-running against a warm stack never collides. +const RUN_ID = Date.now(); +const TOKEN_NAME = `e2e-${RUN_ID}`; +const SCIM_USER_EMAIL = `scim-e2e-${RUN_ID}@accessflow.test`; + +function apiBase(): string { + return process.env.E2E_API_BASE ?? DEFAULT_API_BASE; +} + +async function loginViaUi(page: Page, email: string, password: string): Promise { + await page.goto('/login'); + await page.locator('#login-email').fill(email); + await page.locator('#login-password').fill(password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/dashboard', { timeout: 15_000 }); +} + +// Gate before driving the form: wait for the GET that ScimConfigPage issues on +// mount so the Form.useForm setFieldsValue effect has populated the inputs. +// Mirrors waitForSamlConfigLoaded in admin-saml-config.spec.ts. +async function waitForScimConfigLoaded(page: Page): Promise { + await page.waitForResponse( + (r) => + r.request().method() === 'GET' && + /\/api\/v1\/admin\/scim-config$/.test(r.url()) && + r.status() < 500, + { timeout: 15_000 }, + ); +} + +async function resetScimConfig( + request: APIRequestContext, + accessToken: string, +): Promise { + const res = await request.put(`${apiBase()}/api/v1/admin/scim-config`, { + headers: { Authorization: `Bearer ${accessToken}` }, + data: { enabled: false }, + }); + if (!res.ok()) { + // eslint-disable-next-line no-console + console.warn(`SCIM config reset returned ${res.status()}: ${await res.text()}`); + } +} + +// #621 covers the /admin/scim surface plus the SCIM protocol end-to-end: +// 1. Initial state — SCIM disabled, defaults rendered, base URL visible. +// 2. Enable + save → toast; PUT round-trips through the real backend. +// 3. Create a bearer token → show-once modal; the raw value authenticates +// against /scim/v2/ServiceProviderConfig. +// 4. Provision an Okta-shaped user over SCIM → 201; the user appears in the +// admin users page. +// 5. Entra-shaped PATCH active=false → the user shows as inactive. +// 6. Revoke the token in the UI → the SCIM call now returns 401 in the SCIM +// error envelope. +// +// describe.serial because each scenario depends on state established by the +// previous one; afterAll disables the config so adjacent specs are unaffected. +test.describe.serial('/admin/scim — config, tokens, and provisioning (#621)', () => { + let adminAccessToken = ''; + let rawScimToken = ''; + let scimUserId = ''; + + test.beforeAll(async ({ request }) => { + adminAccessToken = await loginViaApi(request, ADMIN_EMAIL, ADMIN_PASSWORD); + }); + + test.afterAll(async ({ request }) => { + if (adminAccessToken) { + await resetScimConfig(request, adminAccessToken); + } + }); + + test('1) initial load → defaults rendered, SCIM disabled', async ({ page }) => { + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/scim'); + await waitForScimConfigLoaded(page); + + await expect(page.getByRole('heading', { name: 'SCIM Provisioning' })).toBeVisible(); + await expect(page.getByText('SCIM endpoint', { exact: true })).toBeVisible(); + await expect(page.getByText('Attribute mapping', { exact: true })).toBeVisible(); + await expect(page.getByText('Bearer tokens', { exact: true })).toBeVisible(); + await expect(page.getByText(/\/scim\/v2$/).first()).toBeVisible(); + // The Enabled switch reflects the unseeded "disabled" default. + await expect(page.getByRole('switch')).not.toBeChecked(); + }); + + test('2) enable + save → toast', async ({ page }) => { + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/scim'); + await waitForScimConfigLoaded(page); + + await page.getByRole('switch').click(); + await expect(page.getByRole('switch')).toBeChecked(); + + const saveResponsePromise = page.waitForResponse( + (r) => + r.request().method() === 'PUT' && + /\/api\/v1\/admin\/scim-config$/.test(r.url()), + { timeout: 15_000 }, + ); + await page.getByRole('button', { name: 'Save' }).click(); + const saveResponse = await saveResponsePromise; + expect(saveResponse.status()).toBe(200); + const body = (await saveResponse.json()) as { enabled?: boolean }; + expect(body.enabled).toBe(true); + + await expect( + page.getByText('SCIM configuration saved', { exact: true }), + ).toBeVisible({ timeout: 10_000 }); + }); + + test('3) create token → show-once modal; token authenticates against /scim/v2', async ({ + page, + request, + }) => { + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/scim'); + await waitForScimConfigLoaded(page); + + await page.getByRole('button', { name: 'Create token' }).click(); + const createDialog = page.getByRole('dialog').filter({ hasText: 'Create SCIM token' }); + await createDialog.getByLabel('Token name').fill(TOKEN_NAME); + + const createResponsePromise = page.waitForResponse( + (r) => + r.request().method() === 'POST' && + /\/api\/v1\/admin\/scim\/tokens$/.test(r.url()), + { timeout: 15_000 }, + ); + await createDialog.getByRole('button', { name: 'Create token' }).click(); + const createResponse = await createResponsePromise; + expect(createResponse.status()).toBe(201); + const created = (await createResponse.json()) as { raw_token?: string }; + expect(created.raw_token).toMatch(/^af_scim_/); + rawScimToken = created.raw_token ?? ''; + + // The show-once modal displays the same raw value and the copy warning. + const issuedDialog = page.getByRole('dialog').filter({ hasText: 'SCIM token created' }); + await expect(issuedDialog.getByText(rawScimToken)).toBeVisible(); + await expect( + issuedDialog.getByText('Copy this token now — it will not be shown again.'), + ).toBeVisible(); + await issuedDialog.getByRole('button', { name: 'Close' }).click(); + + // The token row lists only the prefix, never the raw value. + await expect(page.getByText(TOKEN_NAME, { exact: true })).toBeVisible(); + + // The raw token authenticates against the SCIM discovery endpoint. + const spConfig = await request.get(`${apiBase()}/scim/v2/ServiceProviderConfig`, { + headers: { Authorization: `Bearer ${rawScimToken}` }, + }); + expect(spConfig.status()).toBe(200); + const spBody = (await spConfig.json()) as { patch?: { supported?: boolean } }; + expect(spBody.patch?.supported).toBe(true); + }); + + test('4) provision an Okta-shaped user over SCIM → appears in admin users', async ({ + page, + request, + }) => { + const createRes = await request.post(`${apiBase()}/scim/v2/Users`, { + headers: { + Authorization: `Bearer ${rawScimToken}`, + 'Content-Type': 'application/scim+json', + }, + data: { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: SCIM_USER_EMAIL, + name: { givenName: 'Scim', familyName: 'User', formatted: 'Scim User' }, + displayName: 'Scim User', + emails: [{ primary: true, value: SCIM_USER_EMAIL, type: 'work' }], + externalId: `okta-${RUN_ID}`, + active: true, + }, + }); + expect(createRes.status()).toBe(201); + const createdUser = (await createRes.json()) as { + id: string; + userName?: string; + active?: boolean; + }; + expect(createdUser.userName).toBe(SCIM_USER_EMAIL); + expect(createdUser.active).toBe(true); + scimUserId = createdUser.id; + + // A userName eq filter (the Okta dedupe probe) finds the user. + const filterRes = await request.get( + `${apiBase()}/scim/v2/Users?filter=${encodeURIComponent( + `userName eq "${SCIM_USER_EMAIL}"`, + )}`, + { headers: { Authorization: `Bearer ${rawScimToken}` } }, + ); + expect(filterRes.status()).toBe(200); + const filterBody = (await filterRes.json()) as { totalResults?: number }; + expect(filterBody.totalResults).toBe(1); + + // The provisioned user shows up in the admin users page. + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/users'); + await expect(page.getByText(SCIM_USER_EMAIL)).toBeVisible({ timeout: 15_000 }); + }); + + test('5) Entra-shaped PATCH active=false → user deactivated', async ({ request }) => { + const patchRes = await request.patch(`${apiBase()}/scim/v2/Users/${scimUserId}`, { + headers: { + Authorization: `Bearer ${rawScimToken}`, + 'Content-Type': 'application/scim+json', + }, + data: { + schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], + Operations: [{ op: 'Replace', path: 'active', value: 'False' }], + }, + }); + expect(patchRes.status()).toBe(200); + const patched = (await patchRes.json()) as { active?: boolean }; + expect(patched.active).toBe(false); + + // The admin API confirms the flag flipped (backend state, not UI cache). + const adminUsers = await request.get( + `${apiBase()}/api/v1/admin/users?size=100`, + { headers: { Authorization: `Bearer ${adminAccessToken}` } }, + ); + expect(adminUsers.ok()).toBe(true); + const adminBody = (await adminUsers.json()) as { + content: Array<{ email: string; active: boolean }>; + }; + const row = adminBody.content.find((u) => u.email === SCIM_USER_EMAIL); + expect(row).toBeDefined(); + expect(row?.active).toBe(false); + }); + + test('6) revoke token in UI → SCIM call returns 401 envelope', async ({ + page, + request, + }) => { + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/scim'); + await waitForScimConfigLoaded(page); + + await page.getByRole('button', { name: `Revoke token ${TOKEN_NAME}` }).click(); + const revokeResponsePromise = page.waitForResponse( + (r) => + r.request().method() === 'DELETE' && + /\/api\/v1\/admin\/scim\/tokens\//.test(r.url()), + { timeout: 15_000 }, + ); + // Popconfirm ok button. + await page.getByRole('button', { name: 'Revoke', exact: true }).click(); + const revokeResponse = await revokeResponsePromise; + expect(revokeResponse.status()).toBe(204); + + await expect(page.getByText('Token revoked', { exact: true })).toBeVisible({ + timeout: 10_000, + }); + + const scimRes = await request.get(`${apiBase()}/scim/v2/Users`, { + headers: { Authorization: `Bearer ${rawScimToken}` }, + }); + expect(scimRes.status()).toBe(401); + const errorBody = (await scimRes.json()) as { schemas?: string[]; status?: string }; + expect(errorBody.schemas).toContain('urn:ietf:params:scim:api:messages:2.0:Error'); + expect(errorBody.status).toBe('401'); + }); +}); From 57ab412eba8d2a96b5be5a381ae4daeba6e3bf91 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 13 Aug 2026 15:42:44 +0400 Subject: [PATCH 07/12] docs(AF-621): document SCIM provisioning across design docs and website 07-security: SCIM section (token model, filter chain, write boundary, deactivation fan-out, failure modes, audit). 03-data-model: scim_config + scim_tokens tables, users/user_groups SCIM columns, membership source and audit values. 05-backend: scim module + UserDeactivatedEvent flow. 02/06/12, CLAUDE.md module tree, README, website homepage tiles + source map, freshness markers. --- CLAUDE.md | 5 +++- README.md | 7 ++--- docs/02-architecture.md | 2 +- docs/03-data-model.md | 60 ++++++++++++++++++++++++++++++++++++----- docs/05-backend.md | 45 +++++++++++++++++++++++++++++++ docs/06-frontend.md | 2 ++ docs/07-security.md | 57 +++++++++++++++++++++++++++++++++++++++ docs/12-roadmap.md | 1 + website/README.md | 1 + website/index.html | 12 ++++----- website/sitemap.xml | 2 +- 11 files changed, 176 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index df03cdef..f07addeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Each plugin has its own version line, pinned by URL + SHA-256 in `connectors/ Touching an engine? Read `.claude/patterns/engine-plugin.md` first, and > `.claude/patterns/engine-fanout.md` before adding any `core.api` enum value. -AccessFlow ships as a single open-source product under Apache 2.0. Authentication uses JWT (RS256) with optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab; a generic `OIDC` provider type covers other IdPs — Keycloak, Auth0, Okta, Authentik, Zitadel — with admin-editable endpoint URLs persisted on the `oauth2_config` row). +AccessFlow ships as a single open-source product under Apache 2.0. Authentication uses JWT (RS256) with optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab; a generic `OIDC` provider type covers other IdPs — Keycloak, Auth0, Okta, Authentik, Zitadel — with admin-editable endpoint URLs persisted on the `oauth2_config` row). User and group lifecycle can be IdP-driven over SCIM 2.0 (`scim` module, `/scim/v2` with per-org bearer tokens — #621). **Full design docs:** `docs/` — read them before implementing any feature. The authoritative references are: - `docs/02-architecture.md` — system architecture and request flow @@ -145,6 +145,9 @@ com.bablsoft.accessflow/ ├── discovery/ # Automated sensitive-data discovery (AF-623): DiscoveryScanJob samples column data via the engine sampling path, regex+checksum detectors (email, PAN+Luhn, SSN, IBAN, phone) + optional fail-safe AI pass propose classification tags an admin confirms (AF-447 derivation) or dismisses │ ├── api/ │ └── internal/ # config, persistence, detect (pure detectors), scheduled, web +├── scim/ # SCIM 2.0 provisioning server (#621): /scim/v2 Users+Groups behind a per-org bearer-token filter chain (@Order(0), SCIM error envelope), attribute-mapping config, show-once tokens; deactivation fans out via core.events.UserDeactivatedEvent (security revokes sessions, access revokes JIT grants) +│ ├── api/ +│ └── internal/ # config (own SecurityFilterChain), persistence, protocol (wire records, filter/patch parsing), web (scim + admin controllers) └── mcp/ # Spring AI stateless MCP server — @Tool callbacks for AI agents ├── api/ └── internal/ diff --git a/README.md b/README.md index 7aa62843..8d427b0d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Design Docs

-AccessFlow sits as a full query proxy in front of your databases — the relational engines PostgreSQL, MySQL, MariaDB, Oracle, and Microsoft SQL Server are supported out of the box via a declarative **connector catalog** (additional engines such as ClickHouse install with one click), any other JDBC-compatible engine can be added by uploading its driver JAR, the NoSQL document engines **MongoDB** and **Couchbase** (SQL++), the NoSQL key-value engine **Redis**, the NoSQL wide-column engines **Apache Cassandra** (CQL) and **ScyllaDB** (CQL-compatible), the NoSQL search engines **Elasticsearch** and **OpenSearch**, the NoSQL key-value engine **Amazon DynamoDB** (PartiQL), the NoSQL graph engine **Neo4j** (Cypher over Bolt), and the cloud data warehouses **Snowflake**, **Google BigQuery** (GoogleSQL), and **Databricks SQL** install the same way through on-demand native engine plugins. The catalog separates the **SQL** (relational) family, the cloud **data-warehouse** family, and the **NoSQL** umbrella of native engine-managed connectors. Every query a user submits — SQL, a MongoDB shell / JSON command, a Couchbase SQL++ statement, a Redis command, a Cassandra/ScyllaDB CQL statement, an Elasticsearch/OpenSearch query, a DynamoDB PartiQL statement, a Neo4j Cypher statement, or a Snowflake / BigQuery / Databricks warehouse SQL statement — is parsed, classified, optionally analyzed by AI, and routed through a configurable human-approval workflow before it ever reaches live data. The same governance extends beyond databases: outbound **REST, SOAP, GraphQL, and gRPC** calls against registered API connectors run through that identical pipeline — AI risk scoring, attribute-based routing, multi-stage approval — with response masking and immutable, downloadable response snapshots. Every request, decision, and execution is captured in a tamper-evident metadata audit log. Authentication is JWT (RS256) with optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab). AccessFlow ships as a single open-source product under Apache 2.0 and is designed to run entirely inside your own infrastructure. +AccessFlow sits as a full query proxy in front of your databases — the relational engines PostgreSQL, MySQL, MariaDB, Oracle, and Microsoft SQL Server are supported out of the box via a declarative **connector catalog** (additional engines such as ClickHouse install with one click), any other JDBC-compatible engine can be added by uploading its driver JAR, the NoSQL document engines **MongoDB** and **Couchbase** (SQL++), the NoSQL key-value engine **Redis**, the NoSQL wide-column engines **Apache Cassandra** (CQL) and **ScyllaDB** (CQL-compatible), the NoSQL search engines **Elasticsearch** and **OpenSearch**, the NoSQL key-value engine **Amazon DynamoDB** (PartiQL), the NoSQL graph engine **Neo4j** (Cypher over Bolt), and the cloud data warehouses **Snowflake**, **Google BigQuery** (GoogleSQL), and **Databricks SQL** install the same way through on-demand native engine plugins. The catalog separates the **SQL** (relational) family, the cloud **data-warehouse** family, and the **NoSQL** umbrella of native engine-managed connectors. Every query a user submits — SQL, a MongoDB shell / JSON command, a Couchbase SQL++ statement, a Redis command, a Cassandra/ScyllaDB CQL statement, an Elasticsearch/OpenSearch query, a DynamoDB PartiQL statement, a Neo4j Cypher statement, or a Snowflake / BigQuery / Databricks warehouse SQL statement — is parsed, classified, optionally analyzed by AI, and routed through a configurable human-approval workflow before it ever reaches live data. The same governance extends beyond databases: outbound **REST, SOAP, GraphQL, and gRPC** calls against registered API connectors run through that identical pipeline — AI risk scoring, attribute-based routing, multi-stage approval — with response masking and immutable, downloadable response snapshots. Every request, decision, and execution is captured in a tamper-evident metadata audit log. Authentication is JWT (RS256) with optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab), and SCIM 2.0 provisioning lets the identity provider drive user & group lifecycle end to end. AccessFlow ships as a single open-source product under Apache 2.0 and is designed to run entirely inside your own infrastructure. --- @@ -104,7 +104,7 @@ A glance at the day-to-day flows engineers and approvers actually use. - **Notifications** — Email (SMTP), Slack, Discord, Telegram, Microsoft Teams, PagerDuty, and HMAC-signed outbound webhooks with retry policy. **ServiceNow & Jira ticketing**: auto-create an incident/issue when a query is rejected, escalated, or times out awaiting review, with linked tickets on the query detail page and signed inbound webhooks syncing ticket status back — a ticket resolution can even approve/reject the pending query (bi-directional sync). - **Slack approve/reject** — a configured Slack app adds **Approve** / **Reject** buttons to review-request messages; the decision runs through the same self-approval and RBAC guards as the REST API (HMAC-verified Interactive Components). - **Mobile approvals (PWA) with one-tap push** — install AccessFlow as a Progressive Web App with an offline-capable review queue, and get **Web Push** notifications when a query needs your approval. Approve or reject in one tap — the decision only commits after a **step-up re-verification** (password, or TOTP when 2FA is on), and the self-approval guard is enforced server-side on every channel. -- **Identity & SSO** — JWT access tokens (15 min) + HttpOnly refresh cookies, optional SAML 2.0 SSO, OAuth 2.0 / OIDC sign-in with built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab plus a generic `OIDC` provider for other IdPs (Keycloak, Auth0, Okta, Authentik, Zitadel), password reset and user-invitation flows. +- **Identity & SSO** — JWT access tokens (15 min) + HttpOnly refresh cookies, optional SAML 2.0 SSO, OAuth 2.0 / OIDC sign-in with built-in templates for Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, and self-managed GitLab plus a generic `OIDC` provider for other IdPs (Keycloak, Auth0, Okta, Authentik, Zitadel), password reset and user-invitation flows, and SCIM 2.0 provisioning so the IdP drives user & group lifecycle (create, update, deactivate, group sync — Okta / Entra ID / Keycloak / OneLogin). - **Custom roles (RBAC)** — compose org-scoped roles from a fixed catalog of functional permissions (submit SELECT/DML/DDL, review queries / access requests / API calls, manage datasources, view the audit log, …) via a permission-matrix UI, and assign them alongside the five immutable built-in roles — e.g. a reviewer who can approve queries but not manage users. Enforcement is permission-based end to end (JWT permission claims → `PERM_*` authorities → UI gating), and role-targeted policies (masking reveals, row security, routing, approver rules) match custom roles by name. - **Multi-tenant organization management** — a single deployment hosts one or more fully-isolated organizations (every entity is scoped by org, always derived from the JWT). A super-admin (`platform_admin`) manages tenants across the cluster — create, edit, disable / enable — with **per-org quotas** (`max_datasources`, `max_users`, `max_queries_per_day`; a breach returns `409 QUOTA_EXCEEDED`) and a disabled-org kill-switch that blocks login and requests immediately. - **MCP server** — built-in Spring AI MCP server exposes a stateless tool surface so external AI agents can submit queries through the same review pipeline, and also discover schemas, validate SQL without executing it, read masking- and row-security-aware sample data, monitor their queries, and review their own audit trail. @@ -155,7 +155,7 @@ For the full request flow, technology stack table, and component-level diagrams, | Client state | Zustand 5 | | Cache & locks | Redis 8 (JWT refresh-token revocation, ShedLock locks for `@Scheduled` jobs) | | AI backends | OpenAI, Anthropic, Ollama, any OpenAI-compatible endpoint, Hugging Face (Inference Providers router or local TGI) (admin-configurable per organization) | -| Auth | JWT RS256 + optional SAML 2.0 SSO and OAuth 2.0 / OIDC (Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, self-managed GitLab built in) | +| Auth | JWT RS256 + optional SAML 2.0 SSO and OAuth 2.0 / OIDC (Google, GitHub, GitHub Enterprise Server, Microsoft, GitLab, self-managed GitLab built in); SCIM 2.0 provisioning for IdP-driven user & group lifecycle | | Observability | Micrometer Tracing + OpenTelemetry (OTLP export), Prometheus metrics (Actuator), pre-built Grafana dashboards, structured JSON logging | | Deploy | Docker Compose, Helm 3 | | Infrastructure as Code | Official Terraform / OpenTofu provider (Go, terraform-plugin-framework) + reusable GitHub Actions and a GitLab CI template | @@ -258,6 +258,7 @@ accessflow/ │ │ ├── access/ # JIT time-bound access requests + grant-expiry job │ │ ├── ai/ # Spring AI adapters (OpenAI / Anthropic / Ollama / Hugging Face) │ │ ├── security/ # JWT, Spring Security filters, SAML 2.0 SSO +│ │ ├── scim/ # SCIM 2.0 provisioning server (IdP-driven user/group lifecycle) │ │ ├── notifications/ # Email / Slack / Webhook / Discord / Telegram / MS Teams / PagerDuty / ServiceNow / Jira dispatchers │ │ ├── audit/ # INSERT-only, HMAC-chained audit log │ │ ├── compliance/ # Compliance reports + signed PDF/CSV exports (AF-459) diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 6808b14e..f955dcae 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -57,7 +57,7 @@ AccessFlow is composed of six primary subsystems — Proxy Engine, Workflow, AI | Frontend Framework | React 18, Vite 5, TypeScript | | UI Component Library | Ant Design 5.x | | SQL Editor | CodeMirror 6 with SQL language plugin | -| Auth | JWT (RS256) with refresh token rotation + optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, Microsoft, and GitLab; additional providers via DB-driven `oauth2_config` rows — see [07-security.md](07-security.md)) | +| Auth | JWT (RS256) with refresh token rotation + optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, Microsoft, and GitLab; additional providers via DB-driven `oauth2_config` rows — see [07-security.md](07-security.md)). User/group lifecycle can be IdP-driven over SCIM 2.0 (`/scim/v2`, per-org bearer tokens, #621). | | Containerization | Docker, Docker Compose 2.x | | Kubernetes | Helm 3 chart with ConfigMap/Secret templating | | Cache / Locks | Redis (JWT refresh-token revocation, ShedLock distributed locks for `@Scheduled` jobs) | diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 7c56cf46..b1b2581a 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -34,7 +34,8 @@ The `disabled` / `max_*` columns are added by `V87__org_isolation_quotas_platfor ## users -Platform users. Can be created locally or auto-provisioned via SAML. +Platform users. Can be created locally, auto-provisioned via SAML/OIDC login, or provisioned by +an identity provider over SCIM 2.0 (#621). | Column | Type / Notes | |--------|-------------| @@ -43,7 +44,7 @@ Platform users. Can be created locally or auto-provisioned via SAML. | `email` | VARCHAR(255) UNIQUE NOT NULL | | `display_name` | VARCHAR(255) | | `password_hash` | VARCHAR — null if SSO-only user | -| `auth_provider` | ENUM: `LOCAL` \| `SAML` \| `OAUTH2` | +| `auth_provider` | ENUM: `LOCAL` \| `SAML` \| `OAUTH2` \| `SCIM` (`SCIM` added in V141, #621 — IdP-provisioned, no password, signs in via SSO) | | `saml_subject` | VARCHAR — SAML NameID, nullable | | `role` | ENUM: `ADMIN` \| `REVIEWER` \| `ANALYST` \| `READONLY` \| `AUDITOR` (`AUDITOR` added in V91 — dedicated read-only compliance role, AF-459). **Nullable since V114 (AF-522)** — populated (and kept in sync) for users on a system role, NULL for users on a custom role; `role_id` is the source of truth. | | `role_id` | FK → `roles`, nullable (AF-522, V114) — the user's assigned role (system or custom). Backfilled from the legacy `role` enum for pre-existing rows. Indexed (`idx_users_role_id`). | @@ -55,7 +56,9 @@ Platform users. Can be created locally or auto-provisioned via SAML. | `totp_enabled` | BOOLEAN NOT NULL DEFAULT false — flipped to true only after the user confirms enrolment with a valid code | | `totp_backup_codes_encrypted` | TEXT — AES-256-GCM ciphertext of a JSON array of bcrypt hashes (one per single-use recovery code). Codes are removed from the array as they're consumed. Null when 2FA is not enabled. | | `attributes` | JSONB NOT NULL DEFAULT `'{}'` (AF-380) — admin-editable per-user attribute map, resolvable in row-security predicates as `:user.`. Set via the user admin API; **not** synced from the IdP. Added by `V61__add_users_attributes.sql`. | +| `scim_external_id` | VARCHAR(255), nullable (#621, V139) — the IdP-side SCIM `externalId`. Partial unique index `uq_users_org_scim_external_id ON (organization_id, scim_external_id) WHERE scim_external_id IS NOT NULL`. | | `created_at` | TIMESTAMPTZ | +| `updated_at` | TIMESTAMPTZ NOT NULL (#621, V139; backfilled from `created_at`) — maintained by JPA `@PreUpdate`, feeds SCIM `meta.lastModified`. | --- @@ -577,7 +580,7 @@ Records the outcome of routing a single query request (AF-379, Flyway `V59__crea ## user_groups -Named, organisation-scoped collections of users. Groups are used as the indirection layer for reviewer assignment (see `datasource_reviewers`) and may be auto-synced from OAuth2 / SAML IdP claims. +Named, organisation-scoped collections of users. Groups are used as the indirection layer for reviewer assignment (see `datasource_reviewers`), may be auto-synced from OAuth2 / SAML IdP claims, and can be created and member-synced by an identity provider over SCIM 2.0 (#621). | Column | Type / Notes | |--------|-------------| @@ -585,6 +588,7 @@ Named, organisation-scoped collections of users. Groups are used as the indirect | `organization_id` | FK → `organizations` | | `name` | VARCHAR(128) NOT NULL — unique per `(organization_id, lower(name))` | | `description` | VARCHAR(512) NULL | +| `scim_external_id` | VARCHAR(255), nullable (#621, V139) — the IdP-side SCIM `externalId`; partial unique index per org, mirroring `users.scim_external_id` | | `version` | BIGINT NOT NULL DEFAULT 0 — optimistic locking | | `created_at`, `updated_at` | TIMESTAMPTZ | @@ -596,10 +600,10 @@ Composite-key join table that bundles users into groups. |--------|-------------| | `user_id` | FK → `users` ON DELETE CASCADE — part of PK | | `group_id` | FK → `user_groups` ON DELETE CASCADE — part of PK | -| `source` | ENUM: `MANUAL` \| `IDP` — `IDP` rows are owned by the OAuth2 / SAML login sync flow; `MANUAL` rows are owned by admins via the API | +| `source` | ENUM: `MANUAL` \| `IDP` \| `SCIM` — `IDP` rows are owned by the OAuth2 / SAML login sync flow; `MANUAL` rows are owned by admins via the API; `SCIM` rows (V140, #621) are owned by the IdP's SCIM provisioning engine | | `joined_at` | TIMESTAMPTZ | -The SSO group-sync flow replaces only `source = 'IDP'` rows per user on each login, leaving `source = 'MANUAL'` rows untouched. +The SSO group-sync flow replaces only `source = 'IDP'` rows per user on each login; SCIM member operations touch only `source = 'SCIM'` rows. Each of the three sources is blind to the other two — first source wins on membership, so no path can wipe or duplicate another's rows. --- @@ -1428,7 +1432,9 @@ Bootstrap reuses the existing `*_CREATED` / `*_UPDATED` actions for `DATASOURCE` ### Audit Resource Types -`resource_type` is the snake_case form of one of the values in `AuditResourceType`: `query_request`, `datasource`, `user`, `api_key`, `permission`, `review_plan`, `notification_channel`, `ai_config`, `custom_jdbc_driver`, `system_smtp`, `user_invitation`, `organization`, `oauth2_config`, `saml_config`, `langfuse_config`, `audit_log`, `slack_app_config`, `access_grant_request`, `routing_policy`, `query_comment`, `break_glass_event`, `request_group`. +`resource_type` is the snake_case form of one of the values in `AuditResourceType`: `query_request`, `datasource`, `user`, `api_key`, `permission`, `review_plan`, `notification_channel`, `ai_config`, `custom_jdbc_driver`, `system_smtp`, `user_invitation`, `organization`, `oauth2_config`, `saml_config`, `langfuse_config`, `audit_log`, `slack_app_config`, `access_grant_request`, `routing_policy`, `query_comment`, `break_glass_event`, `request_group`, `scim_config`, `scim_token`. + +SCIM-driven mutations (#621) audit as `SCIM_USER_PROVISIONED` / `SCIM_USER_UPDATED` / `SCIM_USER_DEACTIVATED` / `SCIM_GROUP_SYNCED` / `SCIM_GROUP_DELETED` with `actor_id = NULL` (the actor is the IdP's provisioning engine) and `metadata.scim_token_id` / `metadata.scim_token_name` carrying the token identity; admin-side changes audit as `SCIM_CONFIG_UPDATED` / `SCIM_TOKEN_CREATED` / `SCIM_TOKEN_REVOKED` with the caller as actor. --- @@ -1751,6 +1757,48 @@ Tracing and prompt fetch are **best-effort and non-blocking** — a Langfuse out --- +## scim_config + +Per-organization SCIM 2.0 provisioning settings (#621, V137) — a singleton row per org, owned by +the `scim` module. `attr_email` / `attr_display_name` name the SCIM attribute the corresponding +user field is read from (attribute mapping); `default_role` is the system role assigned to +SCIM-provisioned users, mirroring `saml_config.default_role`. Every `/scim/v2/**` request +re-checks `enabled` — flipping it off cuts the IdP over immediately. + +| Column | Type / Notes | +|--------|-------------| +| `id` | UUID PK | +| `organization_id` | FK → `organizations` ON DELETE CASCADE, UNIQUE (one row per org) | +| `enabled` | BOOLEAN NOT NULL DEFAULT FALSE — master switch, checked per SCIM request | +| `attr_email` | VARCHAR(255) NOT NULL DEFAULT `'userName'` — ∈ {`userName`, `emails.primary`} (app-enforced; HTTP 400 `SCIM_INVALID_MAPPING` otherwise) | +| `attr_display_name` | VARCHAR(255) NOT NULL DEFAULT `'displayName'` — ∈ {`displayName`, `name.formatted`, `userName`} | +| `default_role` | ENUM `user_role_type` NOT NULL DEFAULT `'ANALYST'` — role given to SCIM-provisioned users; SCIM can never change a role afterwards | +| `version` | BIGINT — `@Version` optimistic lock | +| `created_at` / `updated_at` | TIMESTAMPTZ | + +## scim_tokens + +Long-lived SCIM bearer tokens (#621, V138) — one or more named tokens per org so an operator can +rotate without downtime. Shape mirrors `api_keys`: only the SHA-256 hex hash and a 12-char +display prefix are stored; the raw `af_scim_…` value is shown exactly once at creation. + +| Column | Type / Notes | +|--------|-------------| +| `id` | UUID PK | +| `organization_id` | FK → `organizations` ON DELETE CASCADE | +| `name` | VARCHAR(100) NOT NULL — UNIQUE per org (`scim_tokens_unique_name_per_org`); HTTP 409 `SCIM_TOKEN_NAME_CONFLICT` on duplicates | +| `token_prefix` | VARCHAR(16) NOT NULL — display prefix (`af_scim_XXXX`) | +| `token_hash` | VARCHAR(128) NOT NULL UNIQUE — SHA-256 hex; `@JsonIgnore`d, never serialized | +| `created_by` | FK → `users` ON DELETE SET NULL, nullable | +| `last_used_at` | TIMESTAMPTZ — best-effort bump on each successful authentication | +| `revoked_at` | TIMESTAMPTZ — set once, idempotent; revoked tokens never authenticate | +| `created_at` | TIMESTAMPTZ | + +Indexes: `(organization_id)`; partial `idx_scim_tokens_active_hash ON (token_hash) WHERE +revoked_at IS NULL` — authentication is a hash lookup on every SCIM request. + +--- + ## attestation_campaign A recurring access-recertification campaign (AF-384, Flyway V99). Owned by the `attestation` module. diff --git a/docs/05-backend.md b/docs/05-backend.md index ceacc351..f3a8fb4b 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -2489,6 +2489,51 @@ User-managed API keys live alongside the rest of authentication in the **`securi The full REST contract is in `docs/04-api-spec.md` → "API Keys". +## SCIM provisioning (scim module, #621) + +The **`scim/` module** (`com.bablsoft.accessflow.scim`) is the SCIM 2.0 service provider: IdPs +(Okta, Entra ID, Keycloak, OneLogin) create/update/deactivate users and sync groups over +`/scim/v2/Users` and `/scim/v2/Groups`. + +- **Own security chain.** `scim.internal.config.ScimSecurityConfiguration` contributes a + `SecurityFilterChain` bean (`@Order(0)`, `securityMatcher("/scim/v2/**")`) — Spring collects + filter-chain beans from any `@Configuration`, so the security module's + `SecurityConfiguration` is untouched. `ScimTokenAuthenticationFilter` resolves the per-org + bearer token (SHA-256 hash lookup on `scim_tokens`), re-checks `scim_config.enabled` and the + org-disabled kill-switch per request, and a dedicated entry point emits 401 in the SCIM + error envelope (never ProblemDetail; see `docs/07-security.md`). +- **Protocol layer.** Hand-rolled RFC 7644 pragmatic subset in `scim.internal.protocol`: + Jackson wire records (annotated `@JsonNaming(LowerCamelCase)` to override the app-wide + SNAKE_CASE strategy — SCIM mandates camelCase), an `eq`-only filter parser, and a PatchOp + applier that handles both Okta shapes (no-path value objects, real booleans) and Entra + shapes (`path: "active"` with string `"False"`, `members[value eq "…"]` removes). +- **Orchestration.** `ScimUserOrchestrator` maps the wire contract onto + `core.api.ExternalUserDirectoryService` — the system-actor user primitives (create with + quota + global-email uniqueness, partial update limited to SCIM-owned attributes, offset + paging for `startIndex`). `ScimGroupOrchestrator` maps onto `core.api.UserGroupService`'s + source-scoped member operations (`source=SCIM`). DELETE on a user deactivates — AccessFlow + never hard-deletes users. +- **Admin surface.** `/api/v1/admin/scim-config` + `/api/v1/admin/scim/tokens` + (`PERM_SSO_CONFIGURE`), show-once token issuance mirroring API keys. +- No scheduled jobs — SCIM is entirely IdP-push-driven. + +### User deactivation fan-out (UserDeactivatedEvent) + +Whenever a user's `is_active` transitions `true → false` — admin `PUT /admin/users/{id} +active=false`, admin `DELETE /admin/users/{id}`, or any SCIM deactivation path — +`core.events.UserDeactivatedEvent` is published (transition-only: deactivating an inactive user +publishes nothing). Consumers, both `@ApplicationModuleListener` (async, AFTER_COMMIT): + +- `security.internal.UserDeactivationListener` — revokes every refresh token + (`RefreshTokenStore.revokeAllForUser`); outstanding access tokens expire within + `ACCESSFLOW_JWT_ACCESS_TOKEN_EXPIRY` (default 15 min). +- `access.internal.UserDeactivationGrantRevoker` — revokes the user's `APPROVED` JIT grants + through the ordinary revocation path (system-attributed, idempotent, per-row failures + swallowed). + +Before #621, refresh-token revocation lived in `AdminUserController` and only fired on the +DELETE path; the event unifies all deactivation paths. + ## API Access Governance (apigov module, AF-500) The **`apigov/` module** governs outbound API calls (REST / SOAP / GraphQL / gRPC) with the same diff --git a/docs/06-frontend.md b/docs/06-frontend.md index 8ccc978c..5f20c1cb 100644 --- a/docs/06-frontend.md +++ b/docs/06-frontend.md @@ -151,6 +151,7 @@ accessflow-ui/ │ │ ├── AIConfigPage.tsx │ │ ├── NotificationsPage.tsx │ │ ├── SamlConfigPage.tsx # SAML 2.0 SSO configuration +│ │ ├── ScimConfigPage.tsx # SCIM 2.0 provisioning config + bearer tokens (#621) │ │ └── LangfuseConfigPage.tsx # Langfuse tracing + prompt management │ │ │ ├── store/ @@ -855,6 +856,7 @@ for deployment recipes (Docker Compose, Helm). /admin/languages → LanguagesConfigPage /admin/drivers → CustomDriversPage (admin-uploaded JDBC drivers) /admin/saml → SamlConfigPage +/admin/scim → ScimConfigPage (lazy; SCIM 2.0 provisioning — #621) /admin/oauth2 → OAuth2ConfigPage (lazy) /admin/slack → SlackConfigPage (lazy; Slack app config — AF-362) /admin/langfuse → LangfuseConfigPage (lazy; Langfuse tracing + prompt management — AF-333) diff --git a/docs/07-security.md b/docs/07-security.md index 0b192cc6..a995a7ed 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -170,6 +170,63 @@ API returns `"********"` whenever a secret is stored — the plaintext never lea `AiAnalyzerStrategyHolder`. Config changes take effect on the next authorize request — no application restart. +### SCIM 2.0 provisioning (#621) + +Identity providers (Okta, Microsoft Entra ID, Keycloak, OneLogin) drive user and group +lifecycle over `/scim/v2/Users` and `/scim/v2/Groups` — the joiner/mover/leaver follow-on to +SSO. Implemented by the standalone `scim` module. + +- **Authentication.** A long-lived per-organization bearer token, never a JWT. Format + `af_scim_<32-byte base64url>`; only the SHA-256 hex hash and a 12-char display prefix are + stored (`scim_tokens.token_hash`, `token_prefix`), the plaintext is shown **once** on + creation (same reasoning as API keys: 256 bits of entropy make the unsalted hash safe to + look up per request). Multiple named tokens per org allow zero-downtime rotation; revocation + sets `revoked_at` and takes effect on the next request. +- **Filter chain.** `/scim/v2/**` has its own `SecurityFilterChain` (`@Order(0)`, ahead of the + SAML/OAuth2/catch-all chains) with `ScimTokenAuthenticationFilter` and a dedicated entry + point that answers 401 in the SCIM error envelope + (`urn:ietf:params:scim:api:messages:2.0:Error`) — IdP provisioning engines do not parse + ProblemDetail. CSRF and CORS are disabled: SCIM is server-to-server, a browser never calls + it. The org is **derived from the token**, never from the request; every request re-checks + `scim_config.enabled` and the org-disabled kill-switch, exactly like the JWT and API-key + filters. The filter's `SCIM` authority never overlaps `PERM_*`/`ROLE_*`, so a SCIM token can + never reach a JWT-guarded endpoint. +- **Write boundary.** SCIM owns exactly: the mapped email, display name, `externalId`, + `active`, and group memberships. It can never write roles, `platform_admin`, passwords, TOTP + settings, or row-security attributes — there is no role-escalation surface. User responses + never contain password-shaped fields (the wire records have none). SCIM-provisioned users + carry `auth_provider = SCIM` and a NULL password hash: local login is impossible and they + sign in through the org's SAML/OIDC SSO (whose email-match provisioning accepts non-LOCAL + rows without tripping the local-account takeover guard). +- **Deactivation fan-out.** `active=false` (PATCH, PUT, or DELETE — AccessFlow never + hard-deletes users) flips `is_active` and publishes `core.events.UserDeactivatedEvent`; the + security module revokes all refresh tokens and the access module revokes the user's APPROVED + JIT grants through the ordinary revocation path (system-attributed). Outstanding access + tokens expire naturally within `ACCESSFLOW_JWT_ACCESS_TOKEN_EXPIRY` (default 15 minutes). + The same event unifies admin-UI deactivation, so all paths behave identically. +- **Group provenance.** SCIM-pushed memberships carry `source = SCIM` in + `user_group_memberships`, disjoint from admin `MANUAL` rows and SSO-login `IDP` rows — no + path can overwrite another's memberships. Deleting a group over SCIM cascades its + memberships **and** its group-based grants (`datasource_group_permissions`, + `api_connector_group_permissions`) — the correct semantics for "group removed at the IdP", + and audited with member counts. +- **Failure modes.** Unknown/revoked token, disabled config, disabled org → 401 (SCIM + envelope). Duplicate email/externalId/group name → 409 `scimType=uniqueness` (emails are + globally unique across orgs; the IdP then adopts the existing user via its `userName eq` + lookup). User quota exhausted (create or reactivation) → 403. Unsupported filter → 400 + `invalidFilter`; unsupported PatchOp path/op → 400 `invalidPath`/`invalidValue`. SCIM error + details are intentionally not localized — the consumer is a machine. +- **Audit.** Every SCIM mutation writes a synchronous audit row (`SCIM_USER_PROVISIONED`, + `SCIM_USER_UPDATED`, `SCIM_USER_DEACTIVATED`, `SCIM_GROUP_SYNCED`, `SCIM_GROUP_DELETED`) + with `actor_id = NULL` and the token identity (`scim_token_id`, `scim_token_name`) in the + metadata. Admin config/token changes audit as `SCIM_CONFIG_UPDATED` / `SCIM_TOKEN_CREATED` / + `SCIM_TOKEN_REVOKED` with the caller as actor. +- **Load-bearing regression check:** `e2e/tests/admin-scim-config.spec.ts` — config CRUD, + show-once token issue/revoke, Okta-shaped provisioning, Entra-shaped deactivation, and the + 401 envelope after revocation. + +Operator setup guide (Okta / Entra ID walkthroughs): `website/docs/configuration/auth/#cfg-scim`. + ### API key authentication Users may create personal API keys (under **Profile → API keys**) to authenticate the MCP diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index 516749f5..9b59cb98 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -69,6 +69,7 @@ - SAML 2.0 SP-initiated and IdP-initiated SSO - Auto-provisioning of users from SAML assertions - SAML attribute → role mapping +- SCIM 2.0 provisioning — IdP-driven user & group lifecycle (Okta / Entra ID / Keycloak, #621) - OAuth 2.0 / OIDC sign-in with built-in templates for Google, GitHub, Microsoft, and GitLab; additional providers configurable via DB-driven `oauth2_config` rows --- diff --git a/website/README.md b/website/README.md index 0d36f7d9..7360e3b0 100644 --- a/website/README.md +++ b/website/README.md @@ -39,6 +39,7 @@ the right. | [`backend/pom.xml`](../backend/pom.xml), [`frontend/package.json`](../frontend/package.json) | Architecture callouts, From-source toolchain versions in Install tab | | (no upstream — copy lives in the website) | System requirements panel sizing tiers (Evaluation / Production) | | [`docs/07-security.md`](../docs/07-security.md) | "Workforce-ready auth" feature tile | +| [`docs/07-security.md`](../docs/07-security.md) "SCIM 2.0 provisioning" | SCIM operator guide (`#cfg-scim`) in [`docs/configuration/auth/index.html`](docs/configuration/auth/index.html) + the SCIM blurb in the "Workforce-ready auth" feature tile | | [`docs/08-notifications.md`](../docs/08-notifications.md), [`docs/05-backend.md`](../docs/05-backend.md) "JIT time-bound access requests" + [`docs/07-security.md`](../docs/07-security.md) | "Configurable review workflows" feature tile (incl. JIT access-request blurb) | | [`docs/08-notifications.md`](../docs/08-notifications.md) "ServiceNow" / "Jira" / "Ticketing inbound webhooks & bi-directional sync" (AF-453), [`docs/03-data-model.md`](../docs/03-data-model.md) `query_tickets`, [`docs/04-api-spec.md`](../docs/04-api-spec.md) "Ticketing Integration Endpoints", [`docs/09-deployment.md`](../docs/09-deployment.md) `ACCESSFLOW_NOTIFICATIONS_TICKETING_SIGNATURE_TOLERANCE` | ServiceNow & Jira mentions in the "Configurable review workflows" tile blurb/tags + "Notify on every channel" panel + integrations docs card + "ServiceNow & Jira ticketing" item in the Planned roadmap group (homepage) + ServiceNow / Jira bullets, bi-directional-sync step, and encrypted-fields list under "Notification channels" in [`docs/index.html`](docs/index.html) | | [`docs/05-backend.md`](../docs/05-backend.md) "JIT time-bound access requests", [`docs/07-security.md`](../docs/07-security.md) JIT section, [`docs/09-deployment.md`](../docs/09-deployment.md) `ACCESSFLOW_ACCESS_*` env vars | "Just-in-time (JIT) access requests" subsection (`#cfg-access-requests`) + RBAC rows under "User roles & RBAC" in [`docs/index.html`](docs/index.html) | diff --git a/website/index.html b/website/index.html index e6f95026..a9e5da50 100644 --- a/website/index.html +++ b/website/index.html @@ -65,7 +65,7 @@ "operatingSystem": "Linux, macOS, Windows (Docker)", "softwareVersion": "2.1.1", "datePublished": "2026-04-01", - "dateModified": "2026-08-12", + "dateModified": "2026-08-13", "license": "https://www.apache.org/licenses/LICENSE-2.0", "image": "https://accessflow.bablsoft.com/og-image.png", "isAccessibleForFree": true, @@ -77,7 +77,7 @@ "Full query proxy for SQL, NoSQL and cloud data-warehouse engines", "AI query risk analysis with configurable providers", "Configurable multi-stage review and approval workflows", - "Workforce-ready auth — JWT, SAML 2.0, OAuth 2.0 / OIDC, TOTP", + "Workforce-ready auth — JWT, SAML 2.0, OAuth 2.0 / OIDC, SCIM 2.0 provisioning, TOTP", "Tamper-evident audit log and signed compliance reports", "Personalized dashboard and weekly digest", "Cloud-native deploy via Docker Compose and Helm", @@ -125,7 +125,7 @@ "primaryImageOfPage": "https://accessflow.bablsoft.com/og-image.png", "inLanguage": "en", "datePublished": "2026-04-01", - "dateModified": "2026-08-12" + "dateModified": "2026-08-13" } ] } @@ -397,8 +397,8 @@

Configurable review workflows

Workforce-ready auth

-

Sign in with the accounts your company already uses — SAML 2.0 SSO and OAuth 2.0 / OIDC, with built-in templates for Google, GitHub, Microsoft, GitLab, and a generic provider for any other IdP (Keycloak, Auth0, Okta, …). Roles and group memberships sync automatically from your identity provider on every login, with optional TOTP two-factor. One deployment hosts multiple fully-isolated organizations, and a super-admin manages tenants across the cluster — with per-org quotas and a kill-switch that disables an org instantly.

- JWT · SAML · OAuth · IdP group sync · TOTP · Multi-tenant orgs · Per-org quotas +

Sign in with the accounts your company already uses — SAML 2.0 SSO and OAuth 2.0 / OIDC, with built-in templates for Google, GitHub, Microsoft, GitLab, and a generic provider for any other IdP (Keycloak, Auth0, Okta, …). Roles and group memberships sync automatically from your identity provider on every login, with optional TOTP two-factor. With SCIM 2.0 provisioning, the IdP drives the whole user lifecycle: joiners are created, movers re-grouped, and leavers deactivated automatically — sessions and standing access grants revoked the moment someone is offboarded in Okta or Entra ID. One deployment hosts multiple fully-isolated organizations, and a super-admin manages tenants across the cluster — with per-org quotas and a kill-switch that disables an org instantly.

+ JWT · SAML · OAuth · SCIM provisioning · IdP group sync · TOTP · Multi-tenant orgs · Per-org quotas
@@ -1315,7 +1315,7 @@

Review & access

Auth & audit

    -
  • JWT · SAML · OAuth/OIDC · TOTP · RBAC with custom roles
  • +
  • JWT · SAML · OAuth/OIDC · SCIM provisioning · TOTP · RBAC with custom roles
  • HMAC-chained audit log + CSV export
diff --git a/website/sitemap.xml b/website/sitemap.xml index bdd77e8e..a40959d8 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -2,7 +2,7 @@ https://accessflow.bablsoft.com/ - 2026-08-12 + 2026-08-13 weekly 1.0 From 13a39f8c38e9703cd689bab7ded7539ef360ddc6 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Thu, 13 Aug 2026 15:51:05 +0400 Subject: [PATCH 08/12] fix(AF-621): build the IdP-facing SCIM base URL from the API base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings: window.location.origin points at the SPA, which proxies nothing — the copyable base URL now uses apiBaseUrl() (same trap OAuth2ConfigPage documents). Also: UsersPage provider filter and the enumLabels exhaustiveness test gain SCIM, the token list renders a real error state instead of 'no tokens yet', and the e2e cleanup rationale is documented. --- e2e/tests/admin-scim-config.spec.ts | 3 +++ frontend/src/locales/de.json | 1 + frontend/src/locales/en.json | 1 + frontend/src/locales/es.json | 1 + frontend/src/locales/fr.json | 1 + frontend/src/locales/hy.json | 1 + frontend/src/locales/ru.json | 1 + frontend/src/locales/zh-CN.json | 1 + frontend/src/pages/admin/ScimConfigPage.test.tsx | 4 +++- frontend/src/pages/admin/ScimConfigPage.tsx | 15 ++++++++++++++- frontend/src/pages/admin/UsersPage.tsx | 7 ++++++- frontend/src/utils/__tests__/enumLabels.test.ts | 2 +- 12 files changed, 34 insertions(+), 4 deletions(-) diff --git a/e2e/tests/admin-scim-config.spec.ts b/e2e/tests/admin-scim-config.spec.ts index a1ea2af2..e988dd6e 100644 --- a/e2e/tests/admin-scim-config.spec.ts +++ b/e2e/tests/admin-scim-config.spec.ts @@ -87,6 +87,9 @@ test.describe.serial('/admin/scim — config, tokens, and provisioning (#621)', if (adminAccessToken) { await resetScimConfig(request, adminAccessToken); } + // The provisioned user is deliberately left behind (deactivated by test 5): + // AccessFlow never hard-deletes users, and the per-run unique email prevents + // collisions on warm-stack reruns. }); test('1) initial load → defaults rendered, SCIM disabled', async ({ page }) => { diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 16928e6f..d591973a 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -1854,6 +1854,7 @@ "section_tokens": "Bearer-Token", "tokens_description": "Langlebige Token, mit denen sich Ihr IdP authentifiziert. Der Rohwert wird nur einmal angezeigt.", "tokens_empty": "Noch keine SCIM-Token", + "tokens_load_error": "SCIM-Token konnten nicht geladen werden", "token_create": "Token erstellen", "token_create_title": "SCIM-Token erstellen", "token_name_label": "Token-Name", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index df0725a5..b5d19a3d 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1911,6 +1911,7 @@ "section_tokens": "Bearer tokens", "tokens_description": "Long-lived tokens your IdP authenticates with. The raw value is shown only once.", "tokens_empty": "No SCIM tokens yet", + "tokens_load_error": "Failed to load SCIM tokens", "token_create": "Create token", "token_create_title": "Create SCIM token", "token_name_label": "Token name", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index c93615da..b00dfed0 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -1854,6 +1854,7 @@ "section_tokens": "Tokens Bearer", "tokens_description": "Tokens de larga duración con los que se autentica su IdP. El valor solo se muestra una vez.", "tokens_empty": "Aún no hay tokens SCIM", + "tokens_load_error": "No se pudieron cargar los tokens SCIM", "token_create": "Crear token", "token_create_title": "Crear token SCIM", "token_name_label": "Nombre del token", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 374e989d..081a2d63 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -1854,6 +1854,7 @@ "section_tokens": "Jetons Bearer", "tokens_description": "Jetons de longue durée avec lesquels votre IdP s'authentifie. La valeur brute n'est affichée qu'une seule fois.", "tokens_empty": "Aucun jeton SCIM pour l'instant", + "tokens_load_error": "Échec du chargement des jetons SCIM", "token_create": "Créer un jeton", "token_create_title": "Créer un jeton SCIM", "token_name_label": "Nom du jeton", diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json index e1c1b8d2..c54bc5cf 100644 --- a/frontend/src/locales/hy.json +++ b/frontend/src/locales/hy.json @@ -1854,6 +1854,7 @@ "section_tokens": "Bearer թոքեններ", "tokens_description": "Երկարակյաց թոքեններ, որոնցով նույնականանում է ձեր IdP-ն։ Արժեքը ցուցադրվում է միայն մեկ անգամ:", "tokens_empty": "Դեռ SCIM թոքեններ չկան", + "tokens_load_error": "Չհաջողվեց բեռնել SCIM թոքենները", "token_create": "Ստեղծել թոքեն", "token_create_title": "Ստեղծել SCIM թոքեն", "token_name_label": "Թոքենի անուն", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 880b856f..14f17a9f 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -1854,6 +1854,7 @@ "section_tokens": "Bearer-токены", "tokens_description": "Долгоживущие токены, которыми аутентифицируется ваш IdP. Значение показывается только один раз.", "tokens_empty": "SCIM-токенов пока нет", + "tokens_load_error": "Не удалось загрузить SCIM-токены", "token_create": "Создать токен", "token_create_title": "Создать SCIM-токен", "token_name_label": "Имя токена", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 573d2cfc..7e7c247b 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -1854,6 +1854,7 @@ "section_tokens": "Bearer 令牌", "tokens_description": "您的 IdP 用于身份验证的长期令牌。原始值仅显示一次。", "tokens_empty": "暂无 SCIM 令牌", + "tokens_load_error": "无法加载 SCIM 令牌", "token_create": "创建令牌", "token_create_title": "创建 SCIM 令牌", "token_name_label": "令牌名称", diff --git a/frontend/src/pages/admin/ScimConfigPage.test.tsx b/frontend/src/pages/admin/ScimConfigPage.test.tsx index 1173d758..d96c694d 100644 --- a/frontend/src/pages/admin/ScimConfigPage.test.tsx +++ b/frontend/src/pages/admin/ScimConfigPage.test.tsx @@ -90,7 +90,9 @@ describe('ScimConfigPage', () => { expect(await screen.findByText('SCIM Provisioning')).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole('switch')).toBeChecked()); - expect(screen.getByText(`${window.location.origin}/scim/v2`)).toBeInTheDocument(); + // The IdP-facing base URL is built from the API base, never the SPA origin. + expect(screen.getByText(/\/scim\/v2$/)).toBeInTheDocument(); + expect(screen.queryByText(`${window.location.origin}/scim/v2`)).not.toBeInTheDocument(); }); it('saves the configuration', async () => { diff --git a/frontend/src/pages/admin/ScimConfigPage.tsx b/frontend/src/pages/admin/ScimConfigPage.tsx index cfcccefe..884989c1 100644 --- a/frontend/src/pages/admin/ScimConfigPage.tsx +++ b/frontend/src/pages/admin/ScimConfigPage.tsx @@ -30,6 +30,7 @@ import { scimTokenKeys, updateScimConfig, } from '@/api/admin'; +import { apiBaseUrl } from '@/api/client'; import { adminErrorMessage } from '@/utils/apiErrors'; import { enumOptions, roleLabel } from '@/utils/enumLabels'; import { showApiError } from '@/utils/showApiError'; @@ -135,7 +136,10 @@ export function ScimConfigPage() { }); const fmtDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—'); - const baseUrl = `${window.location.origin}/scim/v2`; + // The IdP calls the BACKEND, not the frontend — always the API base URL here, never + // window.location.origin (wrong host → silent fall-through to the SPA; see the same + // warning in OAuth2ConfigPage.callbackUrlFor). + const baseUrl = `${apiBaseUrl().replace(/\/+$/, '')}/scim/v2`; const tokenColumns: ColumnsType = [ { @@ -245,6 +249,8 @@ export function ScimConfigPage() { label={t('admin.scim.label_attr_email')} rules={[{ required: true }]} > + {/* Options are literal SCIM wire attribute paths (RFC 7643), not + translatable enum labels — rendered verbatim on purpose. */}