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/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/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/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/events/UserDeactivatedEvent.java b/backend/src/main/java/com/bablsoft/accessflow/core/events/UserDeactivatedEvent.java
new file mode 100644
index 00000000..092a9e74
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/events/UserDeactivatedEvent.java
@@ -0,0 +1,15 @@
+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.
+ *
+ *
Refresh-token revocation happens synchronously at the publishing service (a failed
+ * revocation must surface to the caller); listeners own the remaining side-effects — 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/DefaultExternalUserDirectoryService.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryService.java
new file mode 100644
index 00000000..5baa95ae
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryService.java
@@ -0,0 +1,170 @@
+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.SessionRevocationService;
+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.time.Instant;
+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;
+ private final SessionRevocationService sessionRevocationService;
+
+ @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());
+ }
+ // The @PreUpdate bump only runs at flush — stamp updatedAt now so the returned view
+ // (and the SCIM meta.lastModified built from it) reflects this write.
+ entity.setUpdatedAt(Instant.now());
+ 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);
+ // Refresh tokens are revoked synchronously (a failed revocation must surface); the
+ // event fans the remaining side-effects (JIT-grant revocation) out to listeners.
+ sessionRevocationService.revokeAllSessions(entity.getId());
+ 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/UserAdminServiceImpl.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImpl.java
index a8aeacb6..7c507c9f 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
@@ -8,6 +8,7 @@
import com.bablsoft.accessflow.core.api.Permission;
import com.bablsoft.accessflow.core.api.QuotaService;
import com.bablsoft.accessflow.core.api.RoleNotFoundException;
+import com.bablsoft.accessflow.core.api.SessionRevocationService;
import com.bablsoft.accessflow.core.api.SystemRolePermissions;
import com.bablsoft.accessflow.core.api.UpdateUserCommand;
import com.bablsoft.accessflow.core.api.UserAdminService;
@@ -20,8 +21,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 +48,8 @@ class UserAdminServiceImpl implements UserAdminService {
private final RolePermissionRepository rolePermissionRepository;
private final QuotaService quotaService;
private final ObjectMapper objectMapper;
+ private final ApplicationEventPublisher eventPublisher;
+ private final SessionRevocationService sessionRevocationService;
@Override
@Transactional(readOnly = true)
@@ -99,7 +104,11 @@ 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()) {
+ onDeactivated(entity.getId(), organizationId);
+ }
}
if (command.displayName() != null) {
entity.setDisplayName(command.displayName());
@@ -124,7 +133,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);
+ onDeactivated(entity.getId(), organizationId);
+ }
return toView(entity);
}
@@ -151,6 +163,16 @@ public Map findByIds(UUID organizationId, Collection ids)
LinkedHashMap::new));
}
+ /**
+ * Refresh tokens are revoked synchronously (a failed revocation must surface to the caller,
+ * and the in-memory event channel is fire-and-forget); the event fans the remaining
+ * side-effects (JIT-grant revocation) out to listeners.
+ */
+ private void onDeactivated(UUID userId, UUID organizationId) {
+ sessionRevocationService.revokeAllSessions(userId);
+ eventPublisher.publishEvent(new UserDeactivatedEvent(userId, organizationId));
+ }
+
/**
* Resolves the requested role onto the entity (AF-522). {@code roleId} wins over the legacy
* enum. {@code roleRef} always points at the role row; the legacy {@code role} enum column
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/UserRepository.java b/backend/src/main/java/com/bablsoft/accessflow/core/internal/persistence/repo/UserRepository.java
index c92d9249..57c57518 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,13 @@ public interface UserRepository extends JpaRepository {
long countByOrganization_IdAndActiveTrue(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/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..e75be955
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenService.java
@@ -0,0 +1,103 @@
+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());
+ }
+ }
+
+ /**
+ * Deliberately NOT {@code @Transactional}: with a managed entity the last-used bump would
+ * flush at commit, outside the try/catch, and a failure there would escape as an unhandled
+ * exception from the auth filter. Detached read + explicit save keeps the bump truly
+ * best-effort.
+ */
+ @Override
+ 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..87cc2dca
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestrator.java
@@ -0,0 +1,352 @@
+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 org.springframework.transaction.annotation.Transactional;
+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);
+ }
+
+ @Transactional
+ 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. */
+ @Transactional
+ 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);
+ }
+
+ @Transactional
+ 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. */
+ @Transactional
+ 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..c6c48c30
--- /dev/null
+++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestrator.java
@@ -0,0 +1,322 @@
+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.ScimFilterParser;
+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 org.springframework.transaction.annotation.Transactional;
+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 = 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)));
+ }
+
+ @Transactional
+ 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. */
+ @Transactional
+ 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);
+ }
+
+ @Transactional
+ 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). */
+ @Transactional
+ 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