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> resourceTypes() { + var base = baseUrl(); + return List.of( + Map.of( + "schemas", List.of(ScimSchemas.RESOURCE_TYPE), + "id", "User", + "name", "User", + "endpoint", "/Users", + "schema", ScimSchemas.USER, + "meta", Map.of("resourceType", "ResourceType", + "location", base + "/ResourceTypes/User")), + Map.of( + "schemas", List.of(ScimSchemas.RESOURCE_TYPE), + "id", "Group", + "name", "Group", + "endpoint", "/Groups", + "schema", ScimSchemas.GROUP, + "meta", Map.of("resourceType", "ResourceType", + "location", base + "/ResourceTypes/Group"))); + } + + @GetMapping("/Schemas") + @Operation(summary = "SCIM schema discovery (minimal descriptors)") + @ApiResponse(responseCode = "200", description = "Supported schemas") + List> schemas() { + return List.of( + Map.of( + "id", ScimSchemas.USER, + "name", "User", + "description", "AccessFlow user (SCIM-owned attributes: userName, " + + "displayName, name.formatted, emails, externalId, active)"), + Map.of( + "id", ScimSchemas.GROUP, + "name", "Group", + "description", "AccessFlow user group (displayName, externalId, members)")); + } + + private static String baseUrl() { + return ServletUriComponentsBuilder.fromCurrentContextPath().path("/scim/v2").toUriString(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandler.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandler.java new file mode 100644 index 00000000..b8c70a5e --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandler.java @@ -0,0 +1,64 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.core.api.QuotaExceededException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimError; +import com.bablsoft.accessflow.scim.internal.protocol.ScimProtocolException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +/** + * Renders every {@code /scim/v2/**} failure as the SCIM error envelope (#621) — never RFC 9457 + * ProblemDetail. Scoped by {@code assignableTypes}, so the rest of the application keeps its + * ProblemDetail contract. Detail strings are intentionally not localized (IdP consumer). + */ +@RestControllerAdvice(assignableTypes = { + ScimUserController.class, ScimGroupController.class, ScimDiscoveryController.class}) +@Order(Ordered.HIGHEST_PRECEDENCE) +@Slf4j +class ScimErrorHandler { + + @ExceptionHandler(ScimProtocolException.class) + ResponseEntity handleProtocol(ScimProtocolException ex) { + return ResponseEntity.status(ex.status()) + .contentType(ScimMediaTypes.SCIM_JSON) + .body(ScimError.of(ex.status(), ex.scimType(), ex.getMessage())); + } + + /** Malformed JSON from the IdP — RFC 7644 wants 400 invalidSyntax, never a 500. */ + @ExceptionHandler(HttpMessageNotReadableException.class) + ResponseEntity handleUnreadable(HttpMessageNotReadableException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .contentType(ScimMediaTypes.SCIM_JSON) + .body(ScimError.of(400, "invalidSyntax", "Malformed request body")); + } + + /** A non-UUID {id} path segment — the resource cannot exist. */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .contentType(ScimMediaTypes.SCIM_JSON) + .body(ScimError.of(400, "invalidValue", "Invalid resource id")); + } + + @ExceptionHandler(QuotaExceededException.class) + ResponseEntity handleQuota(QuotaExceededException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .contentType(ScimMediaTypes.SCIM_JSON) + .body(ScimError.of(403, null, "Organization user quota exceeded")); + } + + @ExceptionHandler(RuntimeException.class) + ResponseEntity handleUnexpected(RuntimeException ex) { + log.error("Unexpected error handling SCIM request", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .contentType(ScimMediaTypes.SCIM_JSON) + .body(ScimError.of(500, null, "Internal error")); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimGroupController.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimGroupController.java new file mode 100644 index 00000000..01151162 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimGroupController.java @@ -0,0 +1,144 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.ScimGroupOrchestrator; +import com.bablsoft.accessflow.scim.internal.protocol.ScimGroupResource; +import com.bablsoft.accessflow.scim.internal.protocol.ScimListResponse; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.Map; +import java.util.UUID; + +/** + * SCIM 2.0 Groups endpoint (#621). Member writes are tracked with {@code source=SCIM} and never + * touch MANUAL or SSO-IDP memberships. Deleting a group cascades its memberships and group-based + * grants — the correct semantics for "group removed at the IdP". + */ +@RestController +@RequestMapping(path = "/scim/v2/Groups", + produces = {ScimMediaTypes.SCIM_JSON_VALUE, "application/json"}) +@Tag(name = "SCIM Groups", description = "SCIM 2.0 group provisioning for identity providers") +@RequiredArgsConstructor +class ScimGroupController { + + private final ScimGroupOrchestrator orchestrator; + private final ScimAuditWriter auditWriter; + + @GetMapping + @Operation(summary = "List or filter groups (displayName/externalId eq)") + @ApiResponse(responseCode = "200", description = "SCIM ListResponse") + @ApiResponse(responseCode = "400", description = "Unsupported filter (invalidFilter)") + @ApiResponse(responseCode = "401", description = "Invalid or missing bearer token") + ScimListResponse list( + @RequestParam(required = false) String filter, + @RequestParam(defaultValue = "1") int startIndex, + @RequestParam(defaultValue = "100") int count, + Authentication authentication) { + return orchestrator.list(principal(authentication), filter, startIndex, count, baseUrl()); + } + + @PostMapping + @Operation(summary = "Create a group (optionally with members)") + @ApiResponse(responseCode = "201", description = "Group created") + @ApiResponse(responseCode = "409", description = "displayName or externalId already exists (uniqueness)") + ResponseEntity create(@RequestBody ScimGroupResource resource, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var created = orchestrator.create(principal, resource, baseUrl()); + auditGroupSynced(created, principal, auditContext); + return ResponseEntity.created(URI.create(created.meta().location())).body(created); + } + + @GetMapping("/{id}") + @Operation(summary = "Fetch a group with members") + @ApiResponse(responseCode = "200", description = "The group") + @ApiResponse(responseCode = "404", description = "Unknown group in this organization") + ScimGroupResource get(@PathVariable UUID id, Authentication authentication) { + return orchestrator.get(principal(authentication), id, baseUrl()); + } + + @PutMapping("/{id}") + @Operation(summary = "Replace displayName, externalId, and the SCIM-sourced member set") + @ApiResponse(responseCode = "200", description = "Updated group") + @ApiResponse(responseCode = "404", description = "Unknown group in this organization") + @ApiResponse(responseCode = "409", description = "displayName or externalId already exists (uniqueness)") + ScimGroupResource replace(@PathVariable UUID id, + @RequestBody ScimGroupResource resource, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var updated = orchestrator.replace(principal, id, resource, baseUrl()); + auditGroupSynced(updated, principal, auditContext); + return updated; + } + + @PatchMapping("/{id}") + @Operation(summary = "Apply a SCIM PatchOp (members add/remove/replace, displayName, externalId)") + @ApiResponse(responseCode = "200", description = "Updated group") + @ApiResponse(responseCode = "400", description = "Unsupported op/path (invalidPath, invalidValue)") + @ApiResponse(responseCode = "404", description = "Unknown group in this organization") + ScimGroupResource patch(@PathVariable UUID id, + @RequestBody ScimPatchRequest patch, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var updated = orchestrator.patch(principal, id, patch, baseUrl()); + auditGroupSynced(updated, principal, auditContext); + return updated; + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete a group (cascades memberships and group-based grants)") + @ApiResponse(responseCode = "204", description = "Group deleted") + @ApiResponse(responseCode = "404", description = "Unknown group in this organization") + ResponseEntity delete(@PathVariable UUID id, Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var deleted = orchestrator.delete(principal, id); + auditWriter.record(AuditAction.SCIM_GROUP_DELETED, AuditResourceType.USER_GROUP, id, + principal, + Map.of("name", String.valueOf(deleted.name()), + "member_count", deleted.memberCount()), + auditContext); + return ResponseEntity.noContent().build(); + } + + private void auditGroupSynced(ScimGroupResource group, ScimPrincipal principal, + RequestAuditContext auditContext) { + auditWriter.record(AuditAction.SCIM_GROUP_SYNCED, AuditResourceType.USER_GROUP, + UUID.fromString(group.id()), principal, + Map.of("name", String.valueOf(group.displayName()), + "member_count", group.members() == null ? 0 : group.members().size()), + auditContext); + } + + private static ScimPrincipal principal(Authentication authentication) { + return (ScimPrincipal) authentication.getPrincipal(); + } + + private static String baseUrl() { + return ServletUriComponentsBuilder.fromCurrentContextPath().path("/scim/v2").toUriString(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimMediaTypes.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimMediaTypes.java new file mode 100644 index 00000000..5520a807 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimMediaTypes.java @@ -0,0 +1,13 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import org.springframework.http.MediaType; + +/** The SCIM media type (RFC 7644 §3.1). Requests and responses accept plain JSON too. */ +public final class ScimMediaTypes { + + public static final String SCIM_JSON_VALUE = "application/scim+json"; + public static final MediaType SCIM_JSON = MediaType.parseMediaType(SCIM_JSON_VALUE); + + private ScimMediaTypes() { + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilter.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilter.java new file mode 100644 index 00000000..f3375a52 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilter.java @@ -0,0 +1,60 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.core.api.OrganizationLookupService; +import com.bablsoft.accessflow.scim.api.ScimConfigService; +import com.bablsoft.accessflow.scim.api.ScimTokenService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Authenticates {@code /scim/v2/**} requests by their per-org bearer token (#621). Enforced per + * request, exactly like the JWT and API-key filters: a revoked token, a disabled SCIM config, or + * a disabled organization all leave the security context empty, and + * {@link ScimAuthenticationEntryPoint} answers 401 in the SCIM error envelope. The org is derived + * from the token — never from the request. + */ +@Component +@RequiredArgsConstructor +public class ScimTokenAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final ScimTokenService tokenService; + private final ScimConfigService configService; + private final OrganizationLookupService organizationLookupService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (SecurityContextHolder.getContext().getAuthentication() == null) { + var rawToken = extractBearer(request); + if (rawToken != null) { + tokenService.authenticate(rawToken) + .filter(p -> configService.isEnabled(p.organizationId())) + .filter(p -> !organizationLookupService.isDisabled(p.organizationId())) + .ifPresent(p -> SecurityContextHolder.getContext() + .setAuthentication(new ScimAuthenticationToken(p))); + } + } + filterChain.doFilter(request, response); + } + + private String extractBearer(HttpServletRequest request) { + var authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { + return null; + } + var candidate = authorization.substring(BEARER_PREFIX.length()).trim(); + return candidate.isEmpty() ? null : candidate; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimUserController.java b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimUserController.java new file mode 100644 index 00000000..7eaeced1 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimUserController.java @@ -0,0 +1,147 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.ScimUserOrchestrator; +import com.bablsoft.accessflow.scim.internal.ScimUserWriteResult; +import com.bablsoft.accessflow.scim.internal.protocol.ScimListResponse; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUserResource; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.Map; +import java.util.UUID; + +/** + * SCIM 2.0 Users endpoint (#621). Paths are RFC 7644's PascalCase — a deliberate exception to the + * kebab-case rule; authentication is the per-org bearer token chain, not JWT. + */ +@RestController +@RequestMapping(path = "/scim/v2/Users", + produces = {ScimMediaTypes.SCIM_JSON_VALUE, "application/json"}) +@Tag(name = "SCIM Users", description = "SCIM 2.0 user provisioning for identity providers") +@RequiredArgsConstructor +class ScimUserController { + + private final ScimUserOrchestrator orchestrator; + private final ScimAuditWriter auditWriter; + + @GetMapping + @Operation(summary = "List or filter users (userName/externalId/emails eq)") + @ApiResponse(responseCode = "200", description = "SCIM ListResponse") + @ApiResponse(responseCode = "400", description = "Unsupported filter (invalidFilter)") + @ApiResponse(responseCode = "401", description = "Invalid or missing bearer token") + ScimListResponse list( + @RequestParam(required = false) String filter, + @RequestParam(defaultValue = "1") int startIndex, + @RequestParam(defaultValue = "100") int count, + Authentication authentication) { + return orchestrator.list(principal(authentication), filter, startIndex, count, baseUrl()); + } + + @PostMapping + @Operation(summary = "Provision a user") + @ApiResponse(responseCode = "201", description = "User created") + @ApiResponse(responseCode = "403", description = "Organization user quota exceeded") + @ApiResponse(responseCode = "409", description = "Email or externalId already exists (uniqueness)") + ResponseEntity create(@RequestBody ScimUserResource resource, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var created = orchestrator.create(principal, resource, baseUrl()); + auditWriter.record(AuditAction.SCIM_USER_PROVISIONED, AuditResourceType.USER, + UUID.fromString(created.id()), principal, + Map.of("email", created.userName(), + "external_id", String.valueOf(created.externalId())), + auditContext); + return ResponseEntity.created(URI.create(created.meta().location())).body(created); + } + + @GetMapping("/{id}") + @Operation(summary = "Fetch a user") + @ApiResponse(responseCode = "200", description = "The user") + @ApiResponse(responseCode = "404", description = "Unknown user in this organization") + ScimUserResource get(@PathVariable UUID id, Authentication authentication) { + return orchestrator.get(principal(authentication), id, baseUrl()); + } + + @PutMapping("/{id}") + @Operation(summary = "Replace the SCIM-owned attributes of a user") + @ApiResponse(responseCode = "200", description = "Updated user") + @ApiResponse(responseCode = "404", description = "Unknown user in this organization") + @ApiResponse(responseCode = "409", description = "Email or externalId already exists (uniqueness)") + ScimUserResource replace(@PathVariable UUID id, + @RequestBody ScimUserResource resource, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var result = orchestrator.replace(principal, id, resource, baseUrl()); + auditUserWrite(result, id, principal, auditContext); + return result.resource(); + } + + @PatchMapping("/{id}") + @Operation(summary = "Apply a SCIM PatchOp (active, displayName, externalId, mapped email)") + @ApiResponse(responseCode = "200", description = "Updated user") + @ApiResponse(responseCode = "400", description = "Unsupported op/path (invalidPath, invalidValue)") + @ApiResponse(responseCode = "404", description = "Unknown user in this organization") + ScimUserResource patch(@PathVariable UUID id, + @RequestBody ScimPatchRequest patch, + Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + var result = orchestrator.patch(principal, id, patch, baseUrl()); + auditUserWrite(result, id, principal, auditContext); + return result.resource(); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Deactivate a user (AccessFlow never hard-deletes)") + @ApiResponse(responseCode = "204", description = "User deactivated (idempotent)") + @ApiResponse(responseCode = "404", description = "Unknown user in this organization") + ResponseEntity delete(@PathVariable UUID id, Authentication authentication, + RequestAuditContext auditContext) { + var principal = principal(authentication); + if (orchestrator.delete(principal, id)) { + auditWriter.record(AuditAction.SCIM_USER_DEACTIVATED, AuditResourceType.USER, id, + principal, Map.of("via", "DELETE"), auditContext); + } + return ResponseEntity.noContent().build(); + } + + private void auditUserWrite(ScimUserWriteResult result, UUID id, ScimPrincipal principal, + RequestAuditContext auditContext) { + var action = result.deactivated() + ? AuditAction.SCIM_USER_DEACTIVATED + : AuditAction.SCIM_USER_UPDATED; + auditWriter.record(action, AuditResourceType.USER, id, principal, + Map.of("email", String.valueOf(result.resource().userName())), auditContext); + } + + private static ScimPrincipal principal(Authentication authentication) { + return (ScimPrincipal) authentication.getPrincipal(); + } + + private static String baseUrl() { + return ServletUriComponentsBuilder.fromCurrentContextPath().path("/scim/v2").toUriString(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/scim/package-info.java b/backend/src/main/java/com/bablsoft/accessflow/scim/package-info.java new file mode 100644 index 00000000..2464ea0b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/scim/package-info.java @@ -0,0 +1,4 @@ +@ApplicationModule(displayName = "SCIM Provisioning") +package com.bablsoft.accessflow.scim; + +import org.springframework.modulith.ApplicationModule; diff --git a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/AdminUserController.java b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/AdminUserController.java index 62b5f4d3..2f6b3cd3 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/AdminUserController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/security/internal/web/AdminUserController.java @@ -9,7 +9,6 @@ import com.bablsoft.accessflow.core.api.UpdateUserCommand; import com.bablsoft.accessflow.core.api.UserAdminService; import com.bablsoft.accessflow.security.api.JwtClaims; -import com.bablsoft.accessflow.security.internal.token.RefreshTokenStore; import com.bablsoft.accessflow.security.internal.web.model.AdminUserResponse; import com.bablsoft.accessflow.security.internal.web.model.CreateUserRequest; import com.bablsoft.accessflow.security.internal.web.model.UpdateUserRequest; @@ -51,7 +50,6 @@ class AdminUserController { private final UserAdminService userAdminService; private final PasswordEncoder passwordEncoder; - private final RefreshTokenStore refreshTokenStore; private final AuditLogService auditLogService; @GetMapping @@ -130,8 +128,8 @@ UserAttributesResponse getUserAttributes(@PathVariable UUID id, Authentication a ResponseEntity deactivateUser(@PathVariable UUID id, Authentication authentication, RequestAuditContext auditContext) { var caller = currentClaims(authentication); + // Refresh-token + JIT-grant revocation fan out from UserDeactivatedEvent listeners. userAdminService.deactivateUser(id, caller.organizationId(), caller.userId()); - refreshTokenStore.revokeAllForUser(id.toString()); recordAudit(AuditAction.USER_DEACTIVATED, id, caller, auditContext, Map.of()); return ResponseEntity.noContent().build(); diff --git a/backend/src/main/resources/db/migration/V137__create_scim_config.sql b/backend/src/main/resources/db/migration/V137__create_scim_config.sql new file mode 100644 index 00000000..87c5c0bb --- /dev/null +++ b/backend/src/main/resources/db/migration/V137__create_scim_config.sql @@ -0,0 +1,15 @@ +-- #621: per-organization SCIM 2.0 provisioning configuration (singleton row per org). +-- attr_email / attr_display_name name the SCIM attribute the corresponding user field is read +-- from (attribute mapping); default_role is the system role assigned to SCIM-provisioned users, +-- mirroring saml_config.default_role / oauth2_config.default_role. +CREATE TABLE scim_config ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + attr_email VARCHAR(255) NOT NULL DEFAULT 'userName', + attr_display_name VARCHAR(255) NOT NULL DEFAULT 'displayName', + default_role user_role_type NOT NULL DEFAULT 'ANALYST', + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/src/main/resources/db/migration/V138__create_scim_tokens.sql b/backend/src/main/resources/db/migration/V138__create_scim_tokens.sql new file mode 100644 index 00000000..d3de6a09 --- /dev/null +++ b/backend/src/main/resources/db/migration/V138__create_scim_tokens.sql @@ -0,0 +1,19 @@ +-- #621: long-lived SCIM bearer tokens (one or more named tokens per org, so an operator can +-- rotate without downtime). Only a SHA-256 hex hash is stored; the raw token is shown exactly +-- once at creation. Shape mirrors api_keys (V32). +CREATE TABLE scim_tokens ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + token_prefix VARCHAR(16) NOT NULL, + token_hash VARCHAR(128) NOT NULL UNIQUE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT scim_tokens_unique_name_per_org UNIQUE (organization_id, name) +); + +CREATE INDEX idx_scim_tokens_org ON scim_tokens (organization_id); +-- Authentication does a hash lookup on every SCIM request; revoked tokens drop out of the index. +CREATE INDEX idx_scim_tokens_active_hash ON scim_tokens (token_hash) WHERE revoked_at IS NULL; diff --git a/backend/src/main/resources/db/migration/V139__add_scim_columns.sql b/backend/src/main/resources/db/migration/V139__add_scim_columns.sql new file mode 100644 index 00000000..b194974e --- /dev/null +++ b/backend/src/main/resources/db/migration/V139__add_scim_columns.sql @@ -0,0 +1,13 @@ +-- #621: SCIM resource identity columns. +-- scim_external_id is the IdP-side identifier (SCIM externalId), unique per org when set. +-- users.updated_at feeds SCIM meta.lastModified (backfilled from created_at). +ALTER TABLE users ADD COLUMN scim_external_id VARCHAR(255); +CREATE UNIQUE INDEX uq_users_org_scim_external_id + ON users (organization_id, scim_external_id) WHERE scim_external_id IS NOT NULL; + +ALTER TABLE users ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP; +UPDATE users SET updated_at = created_at; + +ALTER TABLE user_groups ADD COLUMN scim_external_id VARCHAR(255); +CREATE UNIQUE INDEX uq_user_groups_org_scim_external_id + ON user_groups (organization_id, scim_external_id) WHERE scim_external_id IS NOT NULL; diff --git a/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql b/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql new file mode 100644 index 00000000..9040af70 --- /dev/null +++ b/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql @@ -0,0 +1,3 @@ +-- #621: SCIM-pushed group memberships get their own provenance so neither SSO-login IDP sync +-- (which replaces all IDP rows) nor admin MANUAL edits ever touch them. +ALTER TYPE user_group_membership_source ADD VALUE IF NOT EXISTS 'SCIM'; diff --git a/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql.conf b/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql.conf new file mode 100644 index 00000000..73bd53a1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V140__add_scim_membership_source.sql.conf @@ -0,0 +1 @@ +executeInTransaction=false diff --git a/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql b/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql new file mode 100644 index 00000000..cf8f560f --- /dev/null +++ b/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql @@ -0,0 +1,4 @@ +-- #621: SCIM-provisioned users carry their own auth provider. They have no password (local login +-- impossible) and sign in through the org's SAML/OIDC SSO, whose email match accepts non-LOCAL +-- rows without the local-account takeover guard firing. +ALTER TYPE auth_provider_type ADD VALUE IF NOT EXISTS 'SCIM'; diff --git a/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql.conf b/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql.conf new file mode 100644 index 00000000..73bd53a1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V141__add_scim_auth_provider.sql.conf @@ -0,0 +1 @@ +executeInTransaction=false diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index 523bee4d..12530b92 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -993,3 +993,12 @@ validation.discovery.scan_interval.range=Scan interval must be between 1 and 720 validation.discovery.finding_ids.required=At least one finding id is required validation.discovery.finding_ids.max=At most 100 findings can be decided per request validation.discovery.decision.required=Decision is required + +# SCIM provisioning (#621) +error.scim_token_not_found=SCIM token not found +error.scim_token_name_conflict=A SCIM token with this name already exists +error.scim_invalid_mapping=Invalid SCIM attribute mapping +validation.scim.attr_email=Email source must be userName or emails.primary +validation.scim.attr_display_name=Display-name source must be displayName, name.formatted, or userName +validation.scim.token_name_required=Token name is required +validation.scim.token_name_size=Token name must be at most 100 characters diff --git a/backend/src/main/resources/i18n/messages_de.properties b/backend/src/main/resources/i18n/messages_de.properties index 981d3669..439bc650 100644 --- a/backend/src/main/resources/i18n/messages_de.properties +++ b/backend/src/main/resources/i18n/messages_de.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=Das Scan-Intervall muss zwischen 1 und validation.discovery.finding_ids.required=Mindestens eine Finding-ID ist erforderlich validation.discovery.finding_ids.max=Pro Anfrage können höchstens 100 Findings entschieden werden validation.discovery.decision.required=Eine Entscheidung ist erforderlich + +# SCIM provisioning (#621) +error.scim_token_not_found=SCIM-Token nicht gefunden +error.scim_token_name_conflict=Ein SCIM-Token mit diesem Namen existiert bereits +error.scim_invalid_mapping=Ungültige SCIM-Attributzuordnung +validation.scim.attr_email=E-Mail-Quelle muss userName oder emails.primary sein +validation.scim.attr_display_name=Anzeigename-Quelle muss displayName, name.formatted oder userName sein +validation.scim.token_name_required=Token-Name ist erforderlich +validation.scim.token_name_size=Token-Name darf höchstens 100 Zeichen lang sein diff --git a/backend/src/main/resources/i18n/messages_es.properties b/backend/src/main/resources/i18n/messages_es.properties index cf0717ed..1e4883fb 100644 --- a/backend/src/main/resources/i18n/messages_es.properties +++ b/backend/src/main/resources/i18n/messages_es.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=El intervalo de escaneo debe estar entr validation.discovery.finding_ids.required=Se requiere al menos un id de hallazgo validation.discovery.finding_ids.max=Se pueden decidir como máximo 100 hallazgos por solicitud validation.discovery.decision.required=La decisión es obligatoria + +# SCIM provisioning (#621) +error.scim_token_not_found=Token SCIM no encontrado +error.scim_token_name_conflict=Ya existe un token SCIM con este nombre +error.scim_invalid_mapping=Asignación de atributos SCIM no válida +validation.scim.attr_email=La fuente del correo debe ser userName o emails.primary +validation.scim.attr_display_name=La fuente del nombre para mostrar debe ser displayName, name.formatted o userName +validation.scim.token_name_required=El nombre del token es obligatorio +validation.scim.token_name_size=El nombre del token debe tener como máximo 100 caracteres diff --git a/backend/src/main/resources/i18n/messages_fr.properties b/backend/src/main/resources/i18n/messages_fr.properties index 58cde7f7..e739e112 100644 --- a/backend/src/main/resources/i18n/messages_fr.properties +++ b/backend/src/main/resources/i18n/messages_fr.properties @@ -975,3 +975,12 @@ validation.discovery.scan_interval.range=L'intervalle d'analyse doit être compr validation.discovery.finding_ids.required=Au moins un identifiant de découverte est requis validation.discovery.finding_ids.max=Au plus 100 découvertes peuvent être décidées par requête validation.discovery.decision.required=La décision est obligatoire + +# SCIM provisioning (#621) +error.scim_token_not_found=Jeton SCIM introuvable +error.scim_token_name_conflict=Un jeton SCIM portant ce nom existe déjà +error.scim_invalid_mapping=Mappage d'attributs SCIM non valide +validation.scim.attr_email=La source de l'e-mail doit être userName ou emails.primary +validation.scim.attr_display_name=La source du nom d'affichage doit être displayName, name.formatted ou userName +validation.scim.token_name_required=Le nom du jeton est obligatoire +validation.scim.token_name_size=Le nom du jeton ne doit pas dépasser 100 caractères diff --git a/backend/src/main/resources/i18n/messages_hy.properties b/backend/src/main/resources/i18n/messages_hy.properties index 7f422234..125f6aee 100644 --- a/backend/src/main/resources/i18n/messages_hy.properties +++ b/backend/src/main/resources/i18n/messages_hy.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=Սկանավորման միջակայք validation.discovery.finding_ids.required=Անհրաժեշտ է առնվազն մեկ հայտնաբերման նույնացուցիչ validation.discovery.finding_ids.max=Մեկ հարցումով կարելի է որոշել առավելագույնը 100 հայտնաբերում validation.discovery.decision.required=Որոշումը պարտադիր է + +# SCIM provisioning (#621) +error.scim_token_not_found=SCIM թոքենը չի գտնվել +error.scim_token_name_conflict=Այս անունով SCIM թոքեն արդեն գոյություն ունի +error.scim_invalid_mapping=SCIM ատրիբուտների անվավեր համապատասխանեցում +validation.scim.attr_email=Էլ. փոստի աղբյուրը պետք է լինի userName կամ emails.primary +validation.scim.attr_display_name=Ցուցադրվող անվան աղբյուրը պետք է լինի displayName, name.formatted կամ userName +validation.scim.token_name_required=Թոքենի անունը պարտադիր է +validation.scim.token_name_size=Թոքենի անունը պետք է լինի առավելագույնը 100 նիշ diff --git a/backend/src/main/resources/i18n/messages_ru.properties b/backend/src/main/resources/i18n/messages_ru.properties index d47e1939..d80696fb 100644 --- a/backend/src/main/resources/i18n/messages_ru.properties +++ b/backend/src/main/resources/i18n/messages_ru.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=Интервал сканировани validation.discovery.finding_ids.required=Требуется хотя бы один идентификатор находки validation.discovery.finding_ids.max=За один запрос можно решить не более 100 находок validation.discovery.decision.required=Решение обязательно + +# SCIM provisioning (#621) +error.scim_token_not_found=SCIM-токен не найден +error.scim_token_name_conflict=SCIM-токен с таким именем уже существует +error.scim_invalid_mapping=Недопустимое сопоставление атрибутов SCIM +validation.scim.attr_email=Источник email должен быть userName или emails.primary +validation.scim.attr_display_name=Источник отображаемого имени должен быть displayName, name.formatted или userName +validation.scim.token_name_required=Имя токена обязательно +validation.scim.token_name_size=Имя токена должно содержать не более 100 символов diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index 4f5e8da8..ac43f501 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -972,3 +972,12 @@ validation.discovery.scan_interval.range=扫描间隔必须介于 1 到 720 小 validation.discovery.finding_ids.required=至少需要一个发现项 ID validation.discovery.finding_ids.max=每次请求最多可决定 100 个发现项 validation.discovery.decision.required=必须提供决定 + +# SCIM provisioning (#621) +error.scim_token_not_found=未找到 SCIM 令牌 +error.scim_token_name_conflict=已存在同名的 SCIM 令牌 +error.scim_invalid_mapping=SCIM 属性映射无效 +validation.scim.attr_email=邮箱来源必须为 userName 或 emails.primary +validation.scim.attr_display_name=显示名称来源必须为 displayName、name.formatted 或 userName +validation.scim.token_name_required=令牌名称为必填项 +validation.scim.token_name_size=令牌名称最多 100 个字符 diff --git a/backend/src/test/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevokerTest.java b/backend/src/test/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevokerTest.java new file mode 100644 index 00000000..cc53cb82 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/access/internal/UserDeactivationGrantRevokerTest.java @@ -0,0 +1,74 @@ +package com.bablsoft.accessflow.access.internal; + +import com.bablsoft.accessflow.access.api.AccessGrantStatus; +import com.bablsoft.accessflow.access.internal.persistence.repo.AccessGrantRequestRepository; +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserDeactivationGrantRevokerTest { + + @Mock AccessGrantRequestRepository requestRepository; + @Mock AccessGrantRequestStateService stateService; + + UserDeactivationGrantRevoker revoker; + + private final UUID userId = UUID.randomUUID(); + private final UUID orgId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + revoker = new UserDeactivationGrantRevoker(requestRepository, stateService); + } + + @Test + void revokesEveryApprovedGrantSystemAttributed() { + var grantA = UUID.randomUUID(); + var grantB = UUID.randomUUID(); + when(requestRepository.findIdsByRequesterIdAndStatus(userId, AccessGrantStatus.APPROVED)) + .thenReturn(List.of(grantA, grantB)); + + revoker.onUserDeactivated(new UserDeactivatedEvent(userId, orgId)); + + verify(stateService).revoke(eq(grantA), isNull()); + verify(stateService).revoke(eq(grantB), isNull()); + } + + @Test + void noGrantsMeansNoRevocations() { + when(requestRepository.findIdsByRequesterIdAndStatus(userId, AccessGrantStatus.APPROVED)) + .thenReturn(List.of()); + + revoker.onUserDeactivated(new UserDeactivatedEvent(userId, orgId)); + + verify(stateService, never()).revoke(any(), any()); + } + + @Test + void perGrantFailureDoesNotStopTheRest() { + var failing = UUID.randomUUID(); + var succeeding = UUID.randomUUID(); + when(requestRepository.findIdsByRequesterIdAndStatus(userId, AccessGrantStatus.APPROVED)) + .thenReturn(List.of(failing, succeeding)); + doThrow(new IllegalStateException("boom")).when(stateService).revoke(eq(failing), isNull()); + + revoker.onUserDeactivated(new UserDeactivatedEvent(userId, orgId)); + + verify(stateService).revoke(eq(succeeding), isNull()); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/api/UserGroupDtosTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/api/UserGroupDtosTest.java index 16e2bea9..7109d51b 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/api/UserGroupDtosTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/api/UserGroupDtosTest.java @@ -50,10 +50,11 @@ void userGroupMembershipViewExposesAllFields() { } @Test - void userGroupMembershipSourceTypeHasTwoValues() { + void userGroupMembershipSourceTypeHasThreeValues() { assertThat(UserGroupMembershipSourceType.values()) .containsExactly(UserGroupMembershipSourceType.MANUAL, - UserGroupMembershipSourceType.IDP); + UserGroupMembershipSourceType.IDP, + UserGroupMembershipSourceType.SCIM); assertThat(UserGroupMembershipSourceType.valueOf("MANUAL")) .isEqualTo(UserGroupMembershipSourceType.MANUAL); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryServiceTest.java new file mode 100644 index 00000000..0707bd31 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultExternalUserDirectoryServiceTest.java @@ -0,0 +1,250 @@ +package com.bablsoft.accessflow.core.internal; + +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.CreateExternalUserCommand; +import com.bablsoft.accessflow.core.api.EmailAlreadyExistsException; +import com.bablsoft.accessflow.core.api.ExternalIdAlreadyExistsException; +import com.bablsoft.accessflow.core.api.QuotaExceededException; +import com.bablsoft.accessflow.core.api.QuotaService; +import com.bablsoft.accessflow.core.api.QuotaType; +import com.bablsoft.accessflow.core.api.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.events.UserDeactivatedEvent; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.RoleRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultExternalUserDirectoryServiceTest { + + @Mock UserRepository userRepository; + @Mock OrganizationRepository organizationRepository; + @Mock RoleRepository roleRepository; + @Mock QuotaService quotaService; + @Mock ApplicationEventPublisher eventPublisher; + @Mock SessionRevocationService sessionRevocationService; + + DefaultExternalUserDirectoryService service; + + private final UUID orgId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new DefaultExternalUserDirectoryService(userRepository, organizationRepository, + roleRepository, quotaService, eventPublisher, sessionRevocationService); + lenient().when(roleRepository.findByNameAndSystemTrue(any())).thenReturn(Optional.empty()); + } + + @Test + void createExternalPersistsScimProviderWithoutPassword() { + var org = new OrganizationEntity(); + org.setId(orgId); + when(userRepository.existsByEmail("jane@example.com")).thenReturn(false); + when(organizationRepository.getReferenceById(orgId)).thenReturn(org); + when(userRepository.save(any(UserEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + + var view = service.createExternal(new CreateExternalUserCommand( + orgId, "Jane@Example.com ", "Jane", "ext-1", UserRoleType.ANALYST)); + + assertThat(view.email()).isEqualTo("jane@example.com"); + assertThat(view.authProvider()).isEqualTo(AuthProviderType.SCIM); + assertThat(view.passwordHash()).isNull(); + assertThat(view.scimExternalId()).isEqualTo("ext-1"); + assertThat(view.active()).isTrue(); + assertThat(view.role()).isEqualTo(UserRoleType.ANALYST); + verify(quotaService).checkUserQuota(orgId); + } + + @Test + void createExternalRejectsDuplicateEmail() { + when(userRepository.existsByEmail("dup@example.com")).thenReturn(true); + + assertThatThrownBy(() -> service.createExternal(new CreateExternalUserCommand( + orgId, "dup@example.com", "Dup", null, UserRoleType.ANALYST))) + .isInstanceOf(EmailAlreadyExistsException.class); + verify(userRepository, never()).save(any()); + } + + @Test + void createExternalRejectsDuplicateExternalId() { + when(userRepository.existsByEmail("new@example.com")).thenReturn(false); + when(userRepository.findByOrganization_IdAndScimExternalId(orgId, "ext-1")) + .thenReturn(Optional.of(user(UUID.randomUUID(), true))); + + assertThatThrownBy(() -> service.createExternal(new CreateExternalUserCommand( + orgId, "new@example.com", "New", "ext-1", UserRoleType.ANALYST))) + .isInstanceOf(ExternalIdAlreadyExistsException.class); + verify(userRepository, never()).save(any()); + } + + @Test + void createExternalEnforcesQuota() { + when(userRepository.existsByEmail("over@example.com")).thenReturn(false); + doThrow(new QuotaExceededException(QuotaType.USER, orgId, 5, 5)) + .when(quotaService).checkUserQuota(orgId); + + assertThatThrownBy(() -> service.createExternal(new CreateExternalUserCommand( + orgId, "over@example.com", "Over", null, UserRoleType.ANALYST))) + .isInstanceOf(QuotaExceededException.class); + verify(userRepository, never()).save(any()); + } + + @Test + void updateExternalDeactivationPublishesEventOnce() { + var entity = user(userId, true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + + service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, null, false)); + + assertThat(entity.isActive()).isFalse(); + verify(eventPublisher).publishEvent(new UserDeactivatedEvent(userId, orgId)); + verify(sessionRevocationService).revokeAllSessions(userId); + } + + @Test + void updateExternalDeactivatingInactiveUserIsIdempotent() { + var entity = user(userId, false); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + + service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, null, false)); + + verify(eventPublisher, never()).publishEvent(any()); + verify(sessionRevocationService, never()).revokeAllSessions(any()); + } + + @Test + void updateExternalReactivationChecksQuota() { + var entity = user(userId, false); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + doThrow(new QuotaExceededException(QuotaType.USER, orgId, 5, 5)) + .when(quotaService).checkUserQuota(orgId); + + assertThatThrownBy(() -> service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, null, true))) + .isInstanceOf(QuotaExceededException.class); + assertThat(entity.isActive()).isFalse(); + } + + @Test + void updateExternalRejectsEmailCollision() { + var entity = user(userId, true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + when(userRepository.existsByEmail("taken@example.com")).thenReturn(true); + + assertThatThrownBy(() -> service.updateExternal(orgId, userId, + new UpdateExternalUserCommand("taken@example.com", null, null, null))) + .isInstanceOf(EmailAlreadyExistsException.class); + } + + @Test + void updateExternalRejectsExternalIdCollision() { + var entity = user(userId, true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + when(userRepository.findByOrganization_IdAndScimExternalId(orgId, "ext-9")) + .thenReturn(Optional.of(user(UUID.randomUUID(), true))); + + assertThatThrownBy(() -> service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, "ext-9", null))) + .isInstanceOf(ExternalIdAlreadyExistsException.class); + } + + @Test + void updateExternalNeverTouchesUnownedFields() { + var entity = user(userId, true); + entity.setPasswordHash("hash"); + entity.setPlatformAdmin(true); + entity.setTotpEnabled(true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + + service.updateExternal(orgId, userId, + new UpdateExternalUserCommand("new@example.com", "New Name", "ext-2", null)); + + assertThat(entity.getPasswordHash()).isEqualTo("hash"); + assertThat(entity.isPlatformAdmin()).isTrue(); + assertThat(entity.isTotpEnabled()).isTrue(); + assertThat(entity.getAuthProvider()).isEqualTo(AuthProviderType.LOCAL); + } + + @Test + void updateExternalUnknownUserThrowsNotFound() { + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateExternal(orgId, userId, + new UpdateExternalUserCommand(null, null, null, null))) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + void findersMapToViews() { + var entity = user(userId, true); + when(userRepository.findByOrganization_IdAndId(orgId, userId)) + .thenReturn(Optional.of(entity)); + when(userRepository.findByOrganization_IdAndEmail(orgId, "jane@example.com")) + .thenReturn(Optional.of(entity)); + when(userRepository.findByOrganization_IdAndScimExternalId(orgId, "ext-1")) + .thenReturn(Optional.of(entity)); + + assertThat(service.findById(orgId, userId)).isPresent(); + assertThat(service.findByEmail(orgId, "Jane@Example.com")).isPresent(); + assertThat(service.findByExternalId(orgId, "ext-1")).isPresent(); + } + + @Test + void listReturnsOffsetPage() { + var entity = user(userId, true); + when(userRepository.findAllByOrganization_Id(eq(orgId), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(entity), Pageable.ofSize(2), 7)); + + var page = service.list(orgId, 4, 2); + + assertThat(page.content()).hasSize(1); + assertThat(page.totalResults()).isEqualTo(7); + } + + private UserEntity user(UUID id, boolean active) { + var org = new OrganizationEntity(); + org.setId(orgId); + var entity = new UserEntity(); + entity.setId(id); + entity.setOrganization(org); + entity.setEmail(id + "@example.com"); + entity.setActive(active); + return entity; + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupServiceTest.java index bea355f1..db1dbadb 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/DefaultUserGroupServiceTest.java @@ -4,6 +4,7 @@ import com.bablsoft.accessflow.core.api.PageRequest; import com.bablsoft.accessflow.core.api.UpdateUserGroupCommand; import com.bablsoft.accessflow.core.api.UserGroupMembershipNotFoundException; +import com.bablsoft.accessflow.core.api.UserGroupMembershipSourceType; import com.bablsoft.accessflow.core.api.UserGroupNameAlreadyExistsException; import com.bablsoft.accessflow.core.api.UserGroupNotFoundException; import com.bablsoft.accessflow.core.api.UserNotFoundException; @@ -203,6 +204,147 @@ void syncIdpMembershipsReplacesOnlyIdpRows() { assertThat(result).containsExactlyInAnyOrder(idpKeepGroupId, idpAddGroupId); } + @Test + void syncIdpMembershipsPreservesScimRows() { + var userId = UUID.randomUUID(); + var scimGroupId = UUID.randomUUID(); + var scimMembership = membership(userId, scimGroupId, UserGroupMembershipSource.SCIM); + when(membershipRepository.findAllByUser_Id(userId)).thenReturn(List.of(scimMembership)); + + // The SCIM-sourced group is also in the desired IdP set: it must be neither deleted nor + // duplicated with an IDP row (PK is (user_id, group_id)). + var result = service.syncIdpMemberships(userId, orgId, Set.of(scimGroupId)); + + verify(membershipRepository, never()).delete(any(UserGroupMembershipEntity.class)); + verify(membershipRepository, never()).save(any(UserGroupMembershipEntity.class)); + assertThat(result).containsExactly(scimGroupId); + } + + @Test + void addMemberWithScimSourcePersistsScimMembership() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + var user = user(UUID.randomUUID(), orgId); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(membershipRepository.existsByUser_IdAndGroup_Id(user.getId(), group.getId())) + .thenReturn(false); + when(membershipRepository.save(any(UserGroupMembershipEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var view = service.addMember(group.getId(), user.getId(), orgId, + UserGroupMembershipSourceType.SCIM); + + assertThat(view.source()).isEqualTo(UserGroupMembershipSourceType.SCIM); + } + + @Test + void addMemberWithSourceIsIdempotentWhenRowExists() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + var user = user(UUID.randomUUID(), orgId); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + when(membershipRepository.existsByUser_IdAndGroup_Id(user.getId(), group.getId())) + .thenReturn(true); + var existing = membership(user.getId(), group.getId(), UserGroupMembershipSource.MANUAL); + existing.setUser(user); + existing.setGroup(group); + when(membershipRepository.findAllByGroup_Id(group.getId())).thenReturn(List.of(existing)); + + var view = service.addMember(group.getId(), user.getId(), orgId, + UserGroupMembershipSourceType.SCIM); + + // First source wins: the MANUAL row is returned untouched. + assertThat(view.source()).isEqualTo(UserGroupMembershipSourceType.MANUAL); + verify(membershipRepository, never()).save(any()); + } + + @Test + void removeMemberBySourceOnlyRemovesMatchingSource() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + var user = user(UUID.randomUUID(), orgId); + var manualRow = membership(user.getId(), group.getId(), UserGroupMembershipSource.MANUAL); + manualRow.setUser(user); + manualRow.setGroup(group); + when(membershipRepository.findAllByGroup_Id(group.getId())).thenReturn(List.of(manualRow)); + + service.removeMemberBySource(group.getId(), user.getId(), orgId, + UserGroupMembershipSourceType.SCIM); + + verify(membershipRepository, never()).delete(any(UserGroupMembershipEntity.class)); + } + + @Test + void removeMemberBySourceRemovesOwnRow() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + var user = user(UUID.randomUUID(), orgId); + var scimRow = membership(user.getId(), group.getId(), UserGroupMembershipSource.SCIM); + scimRow.setUser(user); + scimRow.setGroup(group); + when(membershipRepository.findAllByGroup_Id(group.getId())).thenReturn(List.of(scimRow)); + + service.removeMemberBySource(group.getId(), user.getId(), orgId, + UserGroupMembershipSourceType.SCIM); + + verify(membershipRepository).delete(scimRow); + } + + @Test + void replaceMembersBySourceReplacesOnlyScimRows() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + + var manualUser = user(UUID.randomUUID(), orgId); + var staleScimUser = user(UUID.randomUUID(), orgId); + var keptScimUser = user(UUID.randomUUID(), orgId); + var newUser = user(UUID.randomUUID(), orgId); + + var manualRow = membership(manualUser.getId(), group.getId(), + UserGroupMembershipSource.MANUAL); + manualRow.setUser(manualUser); + manualRow.setGroup(group); + var staleScimRow = membership(staleScimUser.getId(), group.getId(), + UserGroupMembershipSource.SCIM); + staleScimRow.setUser(staleScimUser); + staleScimRow.setGroup(group); + var keptScimRow = membership(keptScimUser.getId(), group.getId(), + UserGroupMembershipSource.SCIM); + keptScimRow.setUser(keptScimUser); + keptScimRow.setGroup(group); + when(membershipRepository.findAllByGroup_Id(group.getId())) + .thenReturn(List.of(manualRow, staleScimRow, keptScimRow)); + when(userRepository.findByOrganization_IdAndId(orgId, newUser.getId())) + .thenReturn(Optional.of(newUser)); + when(membershipRepository.save(any(UserGroupMembershipEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var result = service.replaceMembersBySource(group.getId(), orgId, + List.of(keptScimUser.getId(), newUser.getId(), manualUser.getId()), + UserGroupMembershipSourceType.SCIM); + + verify(membershipRepository).delete(staleScimRow); + verify(membershipRepository, never()).delete(manualRow); + // manualUser keeps the MANUAL row — no SCIM duplicate is inserted for them. + assertThat(result).containsExactlyInAnyOrder(keptScimUser.getId(), newUser.getId()); + } + + @Test + void replaceMembersBySourceSkipsUnknownUsers() { + var group = group("Engineers"); + when(userGroupRepository.findById(group.getId())).thenReturn(Optional.of(group)); + when(membershipRepository.findAllByGroup_Id(group.getId())).thenReturn(List.of()); + var unknownUserId = UUID.randomUUID(); + when(userRepository.findByOrganization_IdAndId(orgId, unknownUserId)) + .thenReturn(Optional.empty()); + + var result = service.replaceMembersBySource(group.getId(), orgId, + List.of(unknownUserId), UserGroupMembershipSourceType.SCIM); + + assertThat(result).isEmpty(); + verify(membershipRepository, never()).save(any()); + } + private UserGroupEntity group(String name) { var org = new OrganizationEntity(); org.setId(orgId); diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/OffsetPageableTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/OffsetPageableTest.java new file mode 100644 index 00000000..85944fe9 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/OffsetPageableTest.java @@ -0,0 +1,45 @@ +package com.bablsoft.accessflow.core.internal; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OffsetPageableTest { + + @Test + void exposesArbitraryOffset() { + var pageable = new OffsetPageable(7, 3, Sort.by("id")); + + assertThat(pageable.getOffset()).isEqualTo(7); + assertThat(pageable.getPageSize()).isEqualTo(3); + assertThat(pageable.getPageNumber()).isEqualTo(2); + assertThat(pageable.getSort()).isEqualTo(Sort.by("id")); + } + + @Test + void navigationKeepsLimitAndSort() { + var pageable = new OffsetPageable(5, 10, Sort.unsorted()); + + assertThat(pageable.next().getOffset()).isEqualTo(15); + assertThat(pageable.previousOrFirst().getOffset()).isZero(); + assertThat(pageable.first().getOffset()).isZero(); + assertThat(pageable.withPage(3).getOffset()).isEqualTo(30); + assertThat(pageable.hasPrevious()).isTrue(); + assertThat(pageable.first().hasPrevious()).isFalse(); + } + + @Test + void nullSortBecomesUnsorted() { + assertThat(new OffsetPageable(0, 1, null).getSort()).isEqualTo(Sort.unsorted()); + } + + @Test + void rejectsInvalidArguments() { + assertThatThrownBy(() -> new OffsetPageable(-1, 10, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new OffsetPageable(0, 0, null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImplTest.java b/backend/src/test/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImplTest.java index cd22609b..44cb151f 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImplTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/core/internal/UserAdminServiceImplTest.java @@ -7,9 +7,11 @@ import com.bablsoft.accessflow.core.api.QuotaExceededException; import com.bablsoft.accessflow.core.api.QuotaService; import com.bablsoft.accessflow.core.api.QuotaType; +import com.bablsoft.accessflow.core.api.SessionRevocationService; import com.bablsoft.accessflow.core.api.UpdateUserCommand; import com.bablsoft.accessflow.core.api.UserNotFoundException; import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.events.UserDeactivatedEvent; import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; @@ -21,6 +23,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageImpl; import tools.jackson.databind.ObjectMapper; @@ -45,6 +48,8 @@ class UserAdminServiceImplTest { @Mock RoleRepository roleRepository; @Mock RolePermissionRepository rolePermissionRepository; @Mock QuotaService quotaService; + @Mock ApplicationEventPublisher eventPublisher; + @Mock SessionRevocationService sessionRevocationService; UserAdminServiceImpl service; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -56,7 +61,8 @@ class UserAdminServiceImplTest { @BeforeEach void setUp() { service = new UserAdminServiceImpl(userRepository, organizationRepository, roleRepository, - rolePermissionRepository, quotaService, objectMapper); + rolePermissionRepository, quotaService, objectMapper, eventPublisher, + sessionRevocationService); // System-role row missing → the service keeps the legacy enum-column behaviour. lenient().when(roleRepository.findByNameAndSystemTrue(any())).thenReturn(Optional.empty()); } @@ -205,6 +211,65 @@ void deactivateUserSetsActiveFalse() { assertThat(result.active()).isFalse(); } + @Test + void deactivateUserPublishesDeactivatedEvent() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + service.deactivateUser(userId, orgId, adminId); + + verify(eventPublisher).publishEvent(new UserDeactivatedEvent(userId, orgId)); + verify(sessionRevocationService).revokeAllSessions(userId); + } + + @Test + void deactivateUserAlreadyInactiveDoesNotPublish() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + entity.setActive(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + var result = service.deactivateUser(userId, orgId, adminId); + + assertThat(result.active()).isFalse(); + verify(eventPublisher, never()).publishEvent(any()); + verify(sessionRevocationService, never()).revokeAllSessions(any()); + } + + @Test + void updateUserActiveFalsePublishesDeactivatedEvent() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + service.updateUser(userId, orgId, adminId, new UpdateUserCommand(null, false, null, null)); + + verify(eventPublisher).publishEvent(new UserDeactivatedEvent(userId, orgId)); + verify(sessionRevocationService).revokeAllSessions(userId); + } + + @Test + void updateUserActiveFalseOnInactiveUserDoesNotPublish() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + entity.setActive(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + service.updateUser(userId, orgId, adminId, new UpdateUserCommand(null, false, null, null)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void updateUserReactivationDoesNotPublish() { + var entity = buildUser(userId, orgId, "user@example.com", UserRoleType.ANALYST); + entity.setActive(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(entity)); + + var result = service.updateUser(userId, orgId, adminId, + new UpdateUserCommand(null, true, null, null)); + + assertThat(result.active()).isTrue(); + verify(eventPublisher, never()).publishEvent(any()); + } + @Test void deactivateUserBlocksSelfDeactivation() { assertThatThrownBy(() -> service.deactivateUser(adminId, orgId, adminId)) diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigServiceTest.java new file mode 100644 index 00000000..c534f566 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimConfigServiceTest.java @@ -0,0 +1,105 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.scim.api.ScimInvalidMappingException; +import com.bablsoft.accessflow.scim.api.UpdateScimConfigCommand; +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimConfigEntity; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimConfigRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultScimConfigServiceTest { + + @Mock ScimConfigRepository configRepository; + + DefaultScimConfigService service; + + private final UUID orgId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new DefaultScimConfigService(configRepository); + } + + @Test + void getReturnsDefaultsWhenUnset() { + when(configRepository.findByOrganizationId(orgId)).thenReturn(Optional.empty()); + + var view = service.get(orgId); + + assertThat(view.enabled()).isFalse(); + assertThat(view.attrEmail()).isEqualTo("userName"); + assertThat(view.attrDisplayName()).isEqualTo("displayName"); + assertThat(view.defaultRole()).isEqualTo(UserRoleType.ANALYST); + assertThat(view.id()).isNull(); + } + + @Test + void updateCreatesRowOnFirstSave() { + when(configRepository.findByOrganizationId(orgId)).thenReturn(Optional.empty()); + when(configRepository.save(any(ScimConfigEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var view = service.update(orgId, new UpdateScimConfigCommand( + true, "emails.primary", "name.formatted", UserRoleType.READONLY)); + + assertThat(view.enabled()).isTrue(); + assertThat(view.attrEmail()).isEqualTo("emails.primary"); + assertThat(view.attrDisplayName()).isEqualTo("name.formatted"); + assertThat(view.defaultRole()).isEqualTo(UserRoleType.READONLY); + assertThat(view.organizationId()).isEqualTo(orgId); + } + + @Test + void updateLeavesOmittedFieldsUnchanged() { + var entity = new ScimConfigEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(orgId); + entity.setEnabled(true); + entity.setAttrEmail("emails.primary"); + when(configRepository.findByOrganizationId(orgId)).thenReturn(Optional.of(entity)); + when(configRepository.save(any(ScimConfigEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var view = service.update(orgId, new UpdateScimConfigCommand(null, null, null, null)); + + assertThat(view.enabled()).isTrue(); + assertThat(view.attrEmail()).isEqualTo("emails.primary"); + } + + @Test + void updateRejectsUnknownEmailSource() { + assertThatThrownBy(() -> service.update(orgId, + new UpdateScimConfigCommand(null, "nickName", null, null))) + .isInstanceOf(ScimInvalidMappingException.class); + verify(configRepository, never()).save(any()); + } + + @Test + void updateRejectsUnknownDisplayNameSource() { + assertThatThrownBy(() -> service.update(orgId, + new UpdateScimConfigCommand(null, null, "title", null))) + .isInstanceOf(ScimInvalidMappingException.class); + } + + @Test + void isEnabledDelegatesToRepository() { + when(configRepository.existsByOrganizationIdAndEnabledTrue(orgId)).thenReturn(true); + + assertThat(service.isEnabled(orgId)).isTrue(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenServiceTest.java new file mode 100644 index 00000000..e4debed8 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/DefaultScimTokenServiceTest.java @@ -0,0 +1,149 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.scim.api.ScimTokenNameConflictException; +import com.bablsoft.accessflow.scim.api.ScimTokenNotFoundException; +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimTokenEntity; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultScimTokenServiceTest { + + @Mock ScimTokenRepository tokenRepository; + + DefaultScimTokenService service; + + private final UUID orgId = UUID.randomUUID(); + private final UUID creatorId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + service = new DefaultScimTokenService(tokenRepository); + } + + @Test + void createReturnsRawTokenOnceAndStoresOnlyTheHash() { + when(tokenRepository.existsByOrganizationIdAndName(orgId, "okta-prod")).thenReturn(false); + when(tokenRepository.save(any(ScimTokenEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var issued = service.create(orgId, "okta-prod", creatorId); + + assertThat(issued.rawToken()).startsWith("af_scim_"); + assertThat(issued.token().tokenPrefix()).isEqualTo(issued.rawToken().substring(0, 12)); + assertThat(issued.token().name()).isEqualTo("okta-prod"); + assertThat(issued.token().revokedAt()).isNull(); + } + + @Test + void createRejectsDuplicateName() { + when(tokenRepository.existsByOrganizationIdAndName(orgId, "okta-prod")).thenReturn(true); + + assertThatThrownBy(() -> service.create(orgId, " okta-prod ", creatorId)) + .isInstanceOf(ScimTokenNameConflictException.class); + verify(tokenRepository, never()).save(any()); + } + + @Test + void authenticateResolvesActiveTokenByHash() { + when(tokenRepository.existsByOrganizationIdAndName(orgId, "t")).thenReturn(false); + when(tokenRepository.save(any(ScimTokenEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + var issued = service.create(orgId, "t", creatorId); + var entity = new ScimTokenEntity(); + entity.setId(issued.token().id()); + entity.setOrganizationId(orgId); + entity.setName("t"); + entity.setTokenHash(ScimTokenHasher.hash(issued.rawToken())); + when(tokenRepository.findByTokenHash(ScimTokenHasher.hash(issued.rawToken()))) + .thenReturn(Optional.of(entity)); + + var principal = service.authenticate(issued.rawToken()); + + assertThat(principal).isPresent(); + assertThat(principal.get().organizationId()).isEqualTo(orgId); + assertThat(principal.get().tokenName()).isEqualTo("t"); + assertThat(entity.getLastUsedAt()).isNotNull(); + } + + @Test + void authenticateRejectsRevokedToken() { + var entity = new ScimTokenEntity(); + entity.setOrganizationId(orgId); + entity.setRevokedAt(Instant.now()); + var raw = ScimTokenHasher.generate(); + when(tokenRepository.findByTokenHash(ScimTokenHasher.hash(raw))) + .thenReturn(Optional.of(entity)); + + assertThat(service.authenticate(raw)).isEmpty(); + } + + @Test + void authenticateRejectsUnknownAndMalformedTokens() { + var raw = ScimTokenHasher.generate(); + when(tokenRepository.findByTokenHash(ScimTokenHasher.hash(raw))) + .thenReturn(Optional.empty()); + + assertThat(service.authenticate(raw)).isEmpty(); + assertThat(service.authenticate("not-a-scim-token")).isEmpty(); + assertThat(service.authenticate(null)).isEmpty(); + verify(tokenRepository).findByTokenHash(any()); + } + + @Test + void revokeIsIdempotent() { + var tokenId = UUID.randomUUID(); + var entity = new ScimTokenEntity(); + entity.setId(tokenId); + entity.setOrganizationId(orgId); + entity.setRevokedAt(Instant.parse("2026-01-01T00:00:00Z")); + when(tokenRepository.findByOrganizationIdAndId(orgId, tokenId)) + .thenReturn(Optional.of(entity)); + + service.revoke(orgId, tokenId); + + assertThat(entity.getRevokedAt()).isEqualTo(Instant.parse("2026-01-01T00:00:00Z")); + } + + @Test + void revokeUnknownTokenThrows() { + var tokenId = UUID.randomUUID(); + when(tokenRepository.findByOrganizationIdAndId(orgId, tokenId)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.revoke(orgId, tokenId)) + .isInstanceOf(ScimTokenNotFoundException.class); + } + + @Test + void listMapsEntitiesToViews() { + var entity = new ScimTokenEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(orgId); + entity.setName("entra"); + entity.setTokenPrefix("af_scim_AbCd"); + when(tokenRepository.findAllByOrganizationIdOrderByCreatedAtDesc(orgId)) + .thenReturn(java.util.List.of(entity)); + + var views = service.list(orgId); + + assertThat(views).hasSize(1); + assertThat(views.get(0).name()).isEqualTo("entra"); + assertThat(views.get(0).tokenPrefix()).isEqualTo("af_scim_AbCd"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestratorTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestratorTest.java new file mode 100644 index 00000000..ab3a69a4 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimGroupOrchestratorTest.java @@ -0,0 +1,243 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.core.api.UserGroupMembershipSourceType; +import com.bablsoft.accessflow.core.api.UserGroupMembershipView; +import com.bablsoft.accessflow.core.api.UserGroupNameAlreadyExistsException; +import com.bablsoft.accessflow.core.api.UserGroupNotFoundException; +import com.bablsoft.accessflow.core.api.UserGroupService; +import com.bablsoft.accessflow.core.api.UserGroupView; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.protocol.ScimGroupResource; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidValueException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimMemberRef; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import com.bablsoft.accessflow.scim.internal.protocol.ScimResourceNotFoundException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUniquenessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScimGroupOrchestratorTest { + + private static final String BASE = "https://af.example.com/scim/v2"; + + @Mock UserGroupService userGroupService; + + ScimGroupOrchestrator orchestrator; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final UUID orgId = UUID.randomUUID(); + private final UUID groupId = UUID.randomUUID(); + private final ScimPrincipal principal = + new ScimPrincipal(orgId, UUID.randomUUID(), "entra-prod"); + + @BeforeEach + void setUp() { + orchestrator = new ScimGroupOrchestrator(userGroupService); + lenient().when(userGroupService.getGroup(groupId, orgId)) + .thenReturn(groupView(groupId, "Engineers", "grp-ext-1")); + lenient().when(userGroupService.listMembers(groupId, orgId)).thenReturn(List.of()); + } + + @Test + void createGroupWithMembersUsesScimSource() { + var memberId = UUID.randomUUID(); + when(userGroupService.listAll(orgId)).thenReturn(List.of()); + when(userGroupService.createGroup(any())) + .thenReturn(groupView(groupId, "Engineers", "grp-ext-1")); + + var created = orchestrator.create(principal, new ScimGroupResource(null, null, + "grp-ext-1", "Engineers", + List.of(new ScimMemberRef(memberId.toString(), null)), null), BASE); + + verify(userGroupService).replaceMembersBySource(eq(groupId), eq(orgId), + eq(List.of(memberId)), eq(UserGroupMembershipSourceType.SCIM)); + assertThat(created.displayName()).isEqualTo("Engineers"); + assertThat(created.meta().location()).isEqualTo(BASE + "/Groups/" + groupId); + } + + @Test + void createWithoutDisplayNameIsInvalidValue() { + assertThatThrownBy(() -> orchestrator.create(principal, + new ScimGroupResource(null, null, null, " ", null, null), BASE)) + .isInstanceOf(ScimInvalidValueException.class); + } + + @Test + void createDuplicateNameBecomesUniqueness() { + when(userGroupService.createGroup(any())) + .thenThrow(new UserGroupNameAlreadyExistsException("Engineers")); + + assertThatThrownBy(() -> orchestrator.create(principal, + new ScimGroupResource(null, null, null, "Engineers", null, null), BASE)) + .isInstanceOf(ScimUniquenessException.class); + } + + @Test + void createDuplicateExternalIdBecomesUniqueness() { + when(userGroupService.listAll(orgId)) + .thenReturn(List.of(groupView(UUID.randomUUID(), "Other", "grp-ext-1"))); + + assertThatThrownBy(() -> orchestrator.create(principal, + new ScimGroupResource(null, null, "grp-ext-1", "Engineers", null, null), BASE)) + .isInstanceOf(ScimUniquenessException.class); + } + + @Test + void listFiltersByDisplayNameCaseInsensitive() { + when(userGroupService.listAll(orgId)).thenReturn(List.of( + groupView(groupId, "Engineers", null), + groupView(UUID.randomUUID(), "Ops", null))); + + var response = orchestrator.list(principal, "displayName eq \"engineers\"", 1, 100, BASE); + + assertThat(response.totalResults()).isEqualTo(1); + assertThat(response.resources().get(0).displayName()).isEqualTo("Engineers"); + } + + @Test + void getIncludesMembers() { + var memberId = UUID.randomUUID(); + when(userGroupService.listMembers(groupId, orgId)).thenReturn(List.of( + new UserGroupMembershipView(memberId, groupId, "jane@example.com", "Jane", + UserGroupMembershipSourceType.SCIM, Instant.now()))); + + var resource = orchestrator.get(principal, groupId, BASE); + + assertThat(resource.members()).hasSize(1); + assertThat(resource.members().get(0).value()).isEqualTo(memberId.toString()); + } + + @Test + void getUnknownGroupIs404() { + var unknown = UUID.randomUUID(); + when(userGroupService.getGroup(unknown, orgId)) + .thenThrow(new UserGroupNotFoundException(unknown)); + + assertThatThrownBy(() -> orchestrator.get(principal, unknown, BASE)) + .isInstanceOf(ScimResourceNotFoundException.class); + } + + @Test + void replaceRenamesAndReplacesScimMembers() { + var memberId = UUID.randomUUID(); + when(userGroupService.updateGroup(eq(groupId), eq(orgId), any())) + .thenReturn(groupView(groupId, "Platform", null)); + + orchestrator.replace(principal, groupId, new ScimGroupResource(null, null, null, + "Platform", List.of(new ScimMemberRef(memberId.toString(), null)), null), BASE); + + verify(userGroupService).replaceMembersBySource(eq(groupId), eq(orgId), + eq(List.of(memberId)), eq(UserGroupMembershipSourceType.SCIM)); + } + + @Test + void entraShapedMemberAddPatch() { + var memberId = UUID.randomUUID(); + var patch = patchRequest(""" + {"Operations":[{"op":"Add","path":"members", + "value":[{"value":"%s"}]}]} + """.formatted(memberId)); + + orchestrator.patch(principal, groupId, patch, BASE); + + verify(userGroupService).addMember(groupId, memberId, orgId, + UserGroupMembershipSourceType.SCIM); + } + + @Test + void entraShapedFilteredMemberRemovePatch() { + var memberId = UUID.randomUUID(); + var patch = patchRequest(""" + {"Operations":[{"op":"Remove","path":"members[value eq \\"%s\\"]"}]} + """.formatted(memberId)); + + orchestrator.patch(principal, groupId, patch, BASE); + + verify(userGroupService).removeMemberBySource(groupId, memberId, orgId, + UserGroupMembershipSourceType.SCIM); + } + + @Test + void oktaShapedMemberReplacePatch() { + var memberId = UUID.randomUUID(); + var patch = patchRequest(""" + {"Operations":[{"op":"replace","path":"members", + "value":[{"value":"%s"}]}]} + """.formatted(memberId)); + + orchestrator.patch(principal, groupId, patch, BASE); + + verify(userGroupService).replaceMembersBySource(eq(groupId), eq(orgId), + eq(List.of(memberId)), eq(UserGroupMembershipSourceType.SCIM)); + } + + @Test + void patchRenameViaValueObject() { + var patch = patchRequest(""" + {"Operations":[{"op":"replace","value":{"displayName":"Platform"}}]} + """); + when(userGroupService.updateGroup(eq(groupId), eq(orgId), any())) + .thenReturn(groupView(groupId, "Platform", null)); + + orchestrator.patch(principal, groupId, patch, BASE); + + verify(userGroupService).updateGroup(eq(groupId), eq(orgId), any()); + } + + @Test + void patchInvalidMemberIdIsInvalidValue() { + var patch = patchRequest(""" + {"Operations":[{"op":"add","path":"members","value":[{"value":"not-a-uuid"}]}]} + """); + + assertThatThrownBy(() -> orchestrator.patch(principal, groupId, patch, BASE)) + .isInstanceOf(ScimInvalidValueException.class); + } + + @Test + void deleteReturnsTheDeletedView() { + var deleted = orchestrator.delete(principal, groupId); + + verify(userGroupService).deleteGroup(groupId, orgId); + assertThat(deleted.name()).isEqualTo("Engineers"); + } + + @Test + void replaceMembersResultIgnoredWhenMembersNull() { + when(userGroupService.updateGroup(eq(groupId), eq(orgId), any())) + .thenReturn(groupView(groupId, "Engineers", null)); + + orchestrator.replace(principal, groupId, new ScimGroupResource(null, null, null, + "Engineers", null, null), BASE); + + verify(userGroupService, org.mockito.Mockito.never()) + .replaceMembersBySource(any(), any(), any(), any()); + } + + private ScimPatchRequest patchRequest(String json) { + return objectMapper.readValue(json, ScimPatchRequest.class); + } + + private static UserGroupView groupView(UUID id, String name, String externalId) { + return new UserGroupView(id, UUID.randomUUID(), name, null, 0, + Instant.parse("2026-08-01T00:00:00Z"), Instant.parse("2026-08-02T00:00:00Z"), + externalId); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasherTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasherTest.java new file mode 100644 index 00000000..b8756d8b --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimTokenHasherTest.java @@ -0,0 +1,41 @@ +package com.bablsoft.accessflow.scim.internal; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimTokenHasherTest { + + @Test + void generateProducesPrefixedHighEntropyTokens() { + var token = ScimTokenHasher.generate(); + + assertThat(token).startsWith("af_scim_"); + assertThat(token.length()).isGreaterThan(40); + assertThat(ScimTokenHasher.generate()).isNotEqualTo(token); + } + + @Test + void hashIsDeterministicSha256Hex() { + var hash = ScimTokenHasher.hash("af_scim_test"); + + assertThat(hash).hasSize(64).matches("[0-9a-f]+"); + assertThat(ScimTokenHasher.hash("af_scim_test")).isEqualTo(hash); + assertThat(ScimTokenHasher.hash("af_scim_other")).isNotEqualTo(hash); + } + + @Test + void prefixOfTruncatesToTwelveChars() { + assertThat(ScimTokenHasher.prefixOf("af_scim_AbCdEfGh")).isEqualTo("af_scim_AbCd"); + assertThat(ScimTokenHasher.prefixOf("short")).isEqualTo("short"); + assertThat(ScimTokenHasher.prefixOf(null)).isEmpty(); + } + + @Test + void hasExpectedShapeRequiresPrefix() { + assertThat(ScimTokenHasher.hasExpectedShape(ScimTokenHasher.generate())).isTrue(); + assertThat(ScimTokenHasher.hasExpectedShape("af_notscim")).isFalse(); + assertThat(ScimTokenHasher.hasExpectedShape("af_scim_")).isFalse(); + assertThat(ScimTokenHasher.hasExpectedShape(null)).isFalse(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestratorTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestratorTest.java new file mode 100644 index 00000000..8bfb124d --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/ScimUserOrchestratorTest.java @@ -0,0 +1,325 @@ +package com.bablsoft.accessflow.scim.internal; + +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.CreateExternalUserCommand; +import com.bablsoft.accessflow.core.api.DirectoryPage; +import com.bablsoft.accessflow.core.api.EmailAlreadyExistsException; +import com.bablsoft.accessflow.core.api.ExternalUserDirectoryService; +import com.bablsoft.accessflow.core.api.UpdateExternalUserCommand; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.api.UserView; +import com.bablsoft.accessflow.scim.api.ScimConfigService; +import com.bablsoft.accessflow.scim.api.ScimConfigView; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.internal.protocol.ScimEmail; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidFilterException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidPathException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidValueException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimName; +import com.bablsoft.accessflow.scim.internal.protocol.ScimPatchRequest; +import com.bablsoft.accessflow.scim.internal.protocol.ScimResourceNotFoundException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUniquenessException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUserResource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScimUserOrchestratorTest { + + private static final String BASE = "https://af.example.com/scim/v2"; + + @Mock ExternalUserDirectoryService directory; + @Mock ScimConfigService configService; + + ScimUserOrchestrator orchestrator; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final UUID orgId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + private final ScimPrincipal principal = + new ScimPrincipal(orgId, UUID.randomUUID(), "okta-prod"); + + @BeforeEach + void setUp() { + orchestrator = new ScimUserOrchestrator(directory, configService); + lenient().when(configService.get(orgId)).thenReturn(defaultConfig()); + } + + @Test + void createMapsUserNameToEmailAndDefaultRole() { + when(directory.createExternal(any())).thenAnswer(inv -> { + CreateExternalUserCommand cmd = inv.getArgument(0); + return userView(cmd.email(), cmd.displayName(), cmd.scimExternalId(), true); + }); + + var created = orchestrator.create(principal, oktaUser("jane@example.com", "Jane Doe", + "00u1abcd", true), BASE); + + var captor = ArgumentCaptor.forClass(CreateExternalUserCommand.class); + verify(directory).createExternal(captor.capture()); + assertThat(captor.getValue().email()).isEqualTo("jane@example.com"); + assertThat(captor.getValue().displayName()).isEqualTo("Jane Doe"); + assertThat(captor.getValue().scimExternalId()).isEqualTo("00u1abcd"); + assertThat(captor.getValue().defaultRole()).isEqualTo(UserRoleType.ANALYST); + assertThat(created.userName()).isEqualTo("jane@example.com"); + assertThat(created.meta().location()).startsWith(BASE + "/Users/"); + assertThat(created.schemas()) + .containsExactly("urn:ietf:params:scim:schemas:core:2.0:User"); + } + + @Test + void createWithEmailsPrimaryMappingReadsPrimaryEmail() { + when(configService.get(orgId)).thenReturn(config("emails.primary", "displayName")); + when(directory.createExternal(any())).thenAnswer(inv -> { + CreateExternalUserCommand cmd = inv.getArgument(0); + return userView(cmd.email(), cmd.displayName(), null, true); + }); + + var resource = new ScimUserResource(null, null, null, "jdoe", "Jane", null, + List.of(new ScimEmail("secondary@example.com", "home", false), + new ScimEmail("primary@example.com", "work", true)), + true, null); + orchestrator.create(principal, resource, BASE); + + var captor = ArgumentCaptor.forClass(CreateExternalUserCommand.class); + verify(directory).createExternal(captor.capture()); + assertThat(captor.getValue().email()).isEqualTo("primary@example.com"); + } + + @Test + void createWithoutEmailSourceIsInvalidValue() { + var resource = new ScimUserResource(null, null, null, null, "Jane", null, null, true, null); + + assertThatThrownBy(() -> orchestrator.create(principal, resource, BASE)) + .isInstanceOf(ScimInvalidValueException.class); + } + + @Test + void createDuplicateEmailBecomesUniqueness() { + when(directory.createExternal(any())) + .thenThrow(new EmailAlreadyExistsException("jane@example.com")); + + assertThatThrownBy(() -> orchestrator.create(principal, + oktaUser("jane@example.com", "Jane", null, true), BASE)) + .isInstanceOf(ScimUniquenessException.class); + } + + @Test + void listWithoutFilterPagesTheDirectory() { + when(directory.list(orgId, 0, 100)).thenReturn( + new DirectoryPage<>(List.of(userView("a@x.io", "A", null, true)), 5)); + + var response = orchestrator.list(principal, null, 1, 100, BASE); + + assertThat(response.totalResults()).isEqualTo(5); + assertThat(response.startIndex()).isEqualTo(1); + assertThat(response.resources()).hasSize(1); + assertThat(response.schemas()) + .containsExactly("urn:ietf:params:scim:api:messages:2.0:ListResponse"); + } + + @Test + void listWithUserNameFilterLooksUpByEmail() { + when(directory.findByEmail(orgId, "jane@example.com")) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + + var response = orchestrator.list(principal, "userName eq \"jane@example.com\"", 1, 100, + BASE); + + assertThat(response.totalResults()).isEqualTo(1); + assertThat(response.resources().get(0).userName()).isEqualTo("jane@example.com"); + } + + @Test + void listWithUnmatchedFilterReturnsEmpty() { + when(directory.findByExternalId(orgId, "nope")).thenReturn(Optional.empty()); + + var response = orchestrator.list(principal, "externalId eq \"nope\"", 1, 100, BASE); + + assertThat(response.totalResults()).isZero(); + assertThat(response.resources()).isEmpty(); + } + + @Test + void listWithUnsupportedFilterAttributeIsInvalidFilter() { + assertThatThrownBy(() -> orchestrator.list(principal, "title eq \"boss\"", 1, 100, BASE)) + .isInstanceOf(ScimInvalidFilterException.class); + } + + @Test + void getUnknownUserIs404() { + when(directory.findById(orgId, userId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> orchestrator.get(principal, userId, BASE)) + .isInstanceOf(ScimResourceNotFoundException.class); + } + + @Test + void replaceUpdatesOnlyScimOwnedAttributes() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("old@example.com", "Old", null, true))); + when(directory.updateExternal(eq(orgId), eq(userId), any())) + .thenAnswer(inv -> userView("new@example.com", "New", "ext-9", true)); + + var result = orchestrator.replace(principal, userId, + oktaUser("new@example.com", "New", "ext-9", true), BASE); + + var captor = ArgumentCaptor.forClass(UpdateExternalUserCommand.class); + verify(directory).updateExternal(eq(orgId), eq(userId), captor.capture()); + assertThat(captor.getValue().email()).isEqualTo("new@example.com"); + assertThat(captor.getValue().active()).isTrue(); + assertThat(result.deactivated()).isFalse(); + } + + @Test + void oktaShapedPatchDeactivates() { + // Okta: {"schemas":[PatchOp],"Operations":[{"op":"replace","value":{"active":false}}]} + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + when(directory.updateExternal(eq(orgId), eq(userId), any())) + .thenAnswer(inv -> userView("jane@example.com", "Jane", null, false)); + var patch = patchRequest(""" + {"Operations":[{"op":"replace","value":{"active":false}}]} + """); + + var result = orchestrator.patch(principal, userId, patch, BASE); + + var captor = ArgumentCaptor.forClass(UpdateExternalUserCommand.class); + verify(directory).updateExternal(eq(orgId), eq(userId), captor.capture()); + assertThat(captor.getValue().active()).isFalse(); + assertThat(result.deactivated()).isTrue(); + } + + @Test + void entraShapedPatchWithStringBooleanAndPathDeactivates() { + // Entra: {"Operations":[{"op":"Replace","path":"active","value":"False"}]} + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + when(directory.updateExternal(eq(orgId), eq(userId), any())) + .thenAnswer(inv -> userView("jane@example.com", "Jane", null, false)); + var patch = patchRequest(""" + {"Operations":[{"op":"Replace","path":"active","value":"False"}]} + """); + + var result = orchestrator.patch(principal, userId, patch, BASE); + + assertThat(result.deactivated()).isTrue(); + } + + @Test + void patchIgnoresUnknownAttributesLikePassword() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + when(directory.updateExternal(eq(orgId), eq(userId), any())) + .thenAnswer(inv -> userView("jane@example.com", "Renamed", null, true)); + var patch = patchRequest(""" + {"Operations":[{"op":"replace","value": + {"password":"hunter2","displayName":"Renamed"}}]} + """); + + orchestrator.patch(principal, userId, patch, BASE); + + var captor = ArgumentCaptor.forClass(UpdateExternalUserCommand.class); + verify(directory).updateExternal(eq(orgId), eq(userId), captor.capture()); + assertThat(captor.getValue().displayName()).isEqualTo("Renamed"); + assertThat(captor.getValue().email()).isNull(); + assertThat(captor.getValue().active()).isNull(); + } + + @Test + void patchRemoveOpIsInvalidPath() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + var patch = patchRequest(""" + {"Operations":[{"op":"remove","path":"displayName"}]} + """); + + assertThatThrownBy(() -> orchestrator.patch(principal, userId, patch, BASE)) + .isInstanceOf(ScimInvalidPathException.class); + } + + @Test + void patchWithNonBooleanActiveIsInvalidValue() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + var patch = patchRequest(""" + {"Operations":[{"op":"replace","path":"active","value":"maybe"}]} + """); + + assertThatThrownBy(() -> orchestrator.patch(principal, userId, patch, BASE)) + .isInstanceOf(ScimInvalidValueException.class); + } + + @Test + void deleteDeactivatesOnceAndIsIdempotent() { + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, true))); + + assertThat(orchestrator.delete(principal, userId)).isTrue(); + verify(directory).updateExternal(eq(orgId), eq(userId), any()); + + when(directory.findById(orgId, userId)) + .thenReturn(Optional.of(userView("jane@example.com", "Jane", null, false))); + assertThat(orchestrator.delete(principal, userId)).isFalse(); + } + + @Test + void responseNeverCarriesPasswordData() { + var resource = ScimUserOrchestrator.toResource( + userView("jane@example.com", "Jane", "ext-1", true), defaultConfig(), BASE); + + var json = objectMapper.writeValueAsString(resource); + assertThat(json).doesNotContainIgnoringCase("password"); + assertThat(json).contains("\"userName\":\"jane@example.com\""); + assertThat(json).contains("\"externalId\":\"ext-1\""); + } + + private ScimPatchRequest patchRequest(String json) { + return objectMapper.readValue(json, ScimPatchRequest.class); + } + + private static ScimUserResource oktaUser(String userName, String displayName, + String externalId, Boolean active) { + return new ScimUserResource( + List.of("urn:ietf:params:scim:schemas:core:2.0:User"), + null, externalId, userName, displayName, + new ScimName(displayName, "Jane", "Doe"), + List.of(new ScimEmail(userName, "work", true)), + active, null); + } + + private UserView userView(String email, String displayName, String externalId, + boolean active) { + return new UserView(userId, email, displayName, UserRoleType.ANALYST, null, "ANALYST", + orgId, active, AuthProviderType.SCIM, null, null, null, false, false, + Instant.parse("2026-08-01T00:00:00Z"), externalId, + Instant.parse("2026-08-02T00:00:00Z")); + } + + private static ScimConfigView defaultConfig() { + return config("userName", "displayName"); + } + + private static ScimConfigView config(String attrEmail, String attrDisplayName) { + return new ScimConfigView(null, null, true, attrEmail, attrDisplayName, + UserRoleType.ANALYST, null, null); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParserTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParserTest.java new file mode 100644 index 00000000..bb1f69b4 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/protocol/ScimFilterParserTest.java @@ -0,0 +1,55 @@ +package com.bablsoft.accessflow.scim.internal.protocol; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ScimFilterParserTest { + + @Test + void parsesOktaShapedUserNameFilter() { + var filter = ScimFilterParser.parse("userName eq \"jane@example.com\""); + + assertThat(filter.attribute()).isEqualTo("username"); + assertThat(filter.value()).isEqualTo("jane@example.com"); + } + + @Test + void parsesUrnQualifiedAttribute() { + var filter = ScimFilterParser.parse( + "urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x@y.z\""); + + assertThat(filter.attribute()).isEqualTo("username"); + } + + @Test + void unescapesQuotedValues() { + var filter = ScimFilterParser.parse("displayName eq \"The \\\"A\\\" Team\""); + + assertThat(filter.value()).isEqualTo("The \"A\" Team"); + } + + @Test + void nullOrBlankMeansNoFilter() { + assertThat(ScimFilterParser.parse(null)).isNull(); + assertThat(ScimFilterParser.parse(" ")).isNull(); + } + + @Test + void rejectsNonEqExpressions() { + assertThatThrownBy(() -> ScimFilterParser.parse("userName co \"jane\"")) + .isInstanceOf(ScimInvalidFilterException.class); + assertThatThrownBy(() -> ScimFilterParser.parse( + "userName eq \"a\" and active eq true")) + .isInstanceOf(ScimInvalidFilterException.class); + assertThatThrownBy(() -> ScimFilterParser.parse("active pr")) + .isInstanceOf(ScimInvalidFilterException.class); + } + + @Test + void eqIsCaseInsensitive() { + assertThat(ScimFilterParser.parse("externalId EQ \"ext-1\"").attribute()) + .isEqualTo("externalid"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminControllerIntegrationTest.java new file mode 100644 index 00000000..94ec1c7a --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminControllerIntegrationTest.java @@ -0,0 +1,241 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.TestcontainersConfig; +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.api.UserView; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimConfigRepository; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimTokenRepository; +import com.bablsoft.accessflow.security.internal.jwt.JwtService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.web.context.WebApplicationContext; + +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateCrtKey; +import java.util.Base64; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +@SpringBootTest +@ImportTestcontainers(TestcontainersConfig.class) +class ScimAdminControllerIntegrationTest { + + @Autowired WebApplicationContext context; + @Autowired OrganizationRepository organizationRepository; + @Autowired UserRepository userRepository; + @Autowired ScimConfigRepository scimConfigRepository; + @Autowired ScimTokenRepository scimTokenRepository; + @Autowired JwtService jwtService; + + private MockMvcTester mvc; + private OrganizationEntity org; + private String adminToken; + private String analystToken; + + @DynamicPropertySource + static void env(DynamicPropertyRegistry registry) throws Exception { + var kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + var kp = kpg.generateKeyPair(); + var pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}) + .encodeToString(((RSAPrivateCrtKey) kp.getPrivate()).getEncoded()) + + "\n-----END PRIVATE KEY-----"; + registry.add("accessflow.jwt.private-key", () -> pem); + registry.add("accessflow.encryption-key", () -> + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + registry.add("accessflow.audit.hmac-key", () -> + "abababababababababababababababababababababababababababababababab"); + } + + @BeforeEach + void setUp() { + mvc = MockMvcTester.from(context, builder -> builder.apply(springSecurity()).build()); + scimTokenRepository.deleteAll(); + scimConfigRepository.deleteAll(); + userRepository.deleteAll(); + organizationRepository.deleteAll(); + + org = new OrganizationEntity(); + org.setId(UUID.randomUUID()); + org.setName("Primary"); + org.setSlug("primary-" + UUID.randomUUID()); + organizationRepository.save(org); + + adminToken = generateToken(saveUser("admin@example.com", UserRoleType.ADMIN)); + analystToken = generateToken(saveUser("analyst@example.com", UserRoleType.ANALYST)); + } + + @Test + void getReturnsDefaultsBeforeFirstUpdate() { + var result = mvc.get().uri("/api/v1/admin/scim-config") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.enabled").asBoolean().isFalse(); + assertThat(result).bodyJson().extractingPath("$.attr_email").asString() + .isEqualTo("userName"); + assertThat(result).bodyJson().extractingPath("$.attr_display_name").asString() + .isEqualTo("displayName"); + assertThat(result).bodyJson().extractingPath("$.default_role").asString() + .isEqualTo("ANALYST"); + } + + @Test + void putUpsertsTheSingletonRow() { + var result = mvc.put().uri("/api/v1/admin/scim-config") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"enabled":true,"attr_email":"emails.primary", + "attr_display_name":"name.formatted","default_role":"READONLY"} + """) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.enabled").asBoolean().isTrue(); + var stored = scimConfigRepository.findByOrganizationId(org.getId()).orElseThrow(); + assertThat(stored.isEnabled()).isTrue(); + assertThat(stored.getAttrEmail()).isEqualTo("emails.primary"); + assertThat(stored.getDefaultRole()).isEqualTo(UserRoleType.READONLY); + } + + @Test + void putRejectsUnknownMappingValue() { + var result = mvc.put().uri("/api/v1/admin/scim-config") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"attr_email\":\"nickName\"}") + .exchange(); + + assertThat(result).hasStatus(400); + assertThat(scimConfigRepository.findByOrganizationId(org.getId())).isEmpty(); + } + + @Test + void tokenLifecycleShowsRawValueExactlyOnce() throws Exception { + var created = mvc.post().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"okta-prod\"}") + .exchange(); + + assertThat(created).hasStatus(201); + assertThat(created).bodyJson().extractingPath("$.raw_token").asString() + .startsWith("af_scim_"); + assertThat(created).bodyJson().extractingPath("$.token.name").asString() + .isEqualTo("okta-prod"); + + var list = mvc.get().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + assertThat(list).hasStatus(200); + assertThat(list.getResponse().getContentAsString()).doesNotContain("raw_token"); + assertThat(list).bodyJson().extractingPath("$[0].token_prefix").asString() + .startsWith("af_scim_"); + + // The stored row carries only the SHA-256 hash. + var stored = scimTokenRepository + .findAllByOrganizationIdOrderByCreatedAtDesc(org.getId()).get(0); + assertThat(stored.getTokenHash()).hasSize(64).doesNotStartWith("af_scim_"); + } + + @Test + void duplicateTokenNameReturns409() { + mvc.post().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"dup\"}") + .exchange(); + + var second = mvc.post().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"dup\"}") + .exchange(); + + assertThat(second).hasStatus(409); + assertThat(second).bodyJson().extractingPath("$.error").asString() + .isEqualTo("SCIM_TOKEN_NAME_CONFLICT"); + } + + @Test + void revokeReturns204AndUnknownTokenIs404() { + mvc.post().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"to-revoke\"}") + .exchange(); + var tokenId = scimTokenRepository + .findAllByOrganizationIdOrderByCreatedAtDesc(org.getId()).get(0).getId(); + + var revoked = mvc.delete().uri("/api/v1/admin/scim/tokens/" + tokenId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + assertThat(revoked).hasStatus(204); + assertThat(scimTokenRepository.findById(tokenId).orElseThrow().getRevokedAt()).isNotNull(); + + var missing = mvc.delete().uri("/api/v1/admin/scim/tokens/" + UUID.randomUUID()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .exchange(); + assertThat(missing).hasStatus(404); + assertThat(missing).bodyJson().extractingPath("$.error").asString() + .isEqualTo("SCIM_TOKEN_NOT_FOUND"); + } + + @Test + void analystForbiddenEverywhere() { + assertThat(mvc.get().uri("/api/v1/admin/scim-config") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken) + .exchange()).hasStatus(403); + assertThat(mvc.get().uri("/api/v1/admin/scim/tokens") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + analystToken) + .exchange()).hasStatus(403); + } + + private UserEntity saveUser(String email, UserRoleType role) { + var user = new UserEntity(); + user.setId(UUID.randomUUID()); + user.setEmail(email); + user.setDisplayName(role.name()); + user.setPasswordHash("hashed"); + user.setRole(role); + user.setAuthProvider(AuthProviderType.LOCAL); + user.setActive(true); + user.setOrganization(org); + return userRepository.save(user); + } + + private String generateToken(UserEntity entity) { + var view = new UserView( + entity.getId(), + entity.getEmail(), + entity.getDisplayName(), + entity.getRole(), + entity.getOrganization().getId(), + entity.isActive(), + entity.getAuthProvider(), + entity.getPasswordHash(), + entity.getLastLoginAt(), + entity.getPreferredLanguage(), + entity.isTotpEnabled(), + entity.getCreatedAt()); + return jwtService.generateAccessToken(view); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminWebModelsTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminWebModelsTest.java new file mode 100644 index 00000000..2ded29b8 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/admin/ScimAdminWebModelsTest.java @@ -0,0 +1,55 @@ +package com.bablsoft.accessflow.scim.internal.web.admin; + +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.scim.api.IssuedScimToken; +import com.bablsoft.accessflow.scim.api.ScimConfigView; +import com.bablsoft.accessflow.scim.api.ScimTokenView; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimAdminWebModelsTest { + + @Test + void scimConfigResponseMapsAllFields() { + var view = new ScimConfigView(UUID.randomUUID(), UUID.randomUUID(), true, + "emails.primary", "name.formatted", UserRoleType.READONLY, + Instant.parse("2026-08-01T00:00:00Z"), Instant.parse("2026-08-02T00:00:00Z")); + + var response = ScimConfigResponse.from(view); + + assertThat(response.id()).isEqualTo(view.id()); + assertThat(response.enabled()).isTrue(); + assertThat(response.attrEmail()).isEqualTo("emails.primary"); + assertThat(response.attrDisplayName()).isEqualTo("name.formatted"); + assertThat(response.defaultRole()).isEqualTo(UserRoleType.READONLY); + } + + @Test + void updateRequestBuildsCommand() { + var request = new UpdateScimConfigRequest(true, "userName", "displayName", + UserRoleType.ANALYST); + + var command = request.toCommand(); + + assertThat(command.enabled()).isTrue(); + assertThat(command.attrEmail()).isEqualTo("userName"); + assertThat(command.attrDisplayName()).isEqualTo("displayName"); + assertThat(command.defaultRole()).isEqualTo(UserRoleType.ANALYST); + } + + @Test + void createdTokenResponseCarriesRawTokenOnce() { + var view = new ScimTokenView(UUID.randomUUID(), "okta", "af_scim_AbCd", + Instant.now(), null, null); + + var response = CreatedScimTokenResponse.from(new IssuedScimToken(view, "af_scim_raw")); + + assertThat(response.rawToken()).isEqualTo("af_scim_raw"); + assertThat(response.token().tokenPrefix()).isEqualTo("af_scim_AbCd"); + assertThat(response.token().revokedAt()).isNull(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriterTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriterTest.java new file mode 100644 index 00000000..3ac90548 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuditWriterTest.java @@ -0,0 +1,63 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditEntry; +import com.bablsoft.accessflow.audit.api.AuditLogService; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ScimAuditWriterTest { + + @Mock AuditLogService auditLogService; + + private final ScimPrincipal principal = + new ScimPrincipal(UUID.randomUUID(), UUID.randomUUID(), "okta-prod"); + private final RequestAuditContext auditContext = + new RequestAuditContext("10.0.0.1", "Okta-Provisioning"); + + @Test + void recordsNullActorRowWithTokenMetadata() { + var writer = new ScimAuditWriter(auditLogService); + var resourceId = UUID.randomUUID(); + + writer.record(AuditAction.SCIM_USER_PROVISIONED, AuditResourceType.USER, resourceId, + principal, Map.of("email", "jane@example.com"), auditContext); + + var captor = ArgumentCaptor.forClass(AuditEntry.class); + verify(auditLogService).record(captor.capture()); + var entry = captor.getValue(); + assertThat(entry.actorId()).isNull(); + assertThat(entry.organizationId()).isEqualTo(principal.organizationId()); + assertThat(entry.metadata()) + .containsEntry("email", "jane@example.com") + .containsEntry("scim_token_id", principal.tokenId().toString()) + .containsEntry("scim_token_name", "okta-prod"); + assertThat(entry.ipAddress()).isEqualTo("10.0.0.1"); + } + + @Test + void auditFailuresAreSwallowed() { + var writer = new ScimAuditWriter(auditLogService); + doThrow(new IllegalStateException("chain broken")).when(auditLogService).record(any()); + + assertThatCode(() -> writer.record(AuditAction.SCIM_USER_UPDATED, AuditResourceType.USER, + UUID.randomUUID(), principal, Map.of(), auditContext)) + .doesNotThrowAnyException(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPointTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPointTest.java new file mode 100644 index 00000000..984ebaf8 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationEntryPointTest.java @@ -0,0 +1,27 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import tools.jackson.databind.ObjectMapper; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimAuthenticationEntryPointTest { + + @Test + void writes401ScimErrorEnvelope() throws Exception { + var entryPoint = new ScimAuthenticationEntryPoint(new ObjectMapper()); + var response = new MockHttpServletResponse(); + + entryPoint.commence(new MockHttpServletRequest(), response, + new InsufficientAuthenticationException("nope")); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentType()).isEqualTo("application/scim+json"); + assertThat(response.getContentAsString()) + .contains("urn:ietf:params:scim:api:messages:2.0:Error") + .contains("\"status\":\"401\""); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationTokenTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationTokenTest.java new file mode 100644 index 00000000..0fc50f06 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimAuthenticationTokenTest.java @@ -0,0 +1,24 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimAuthenticationTokenTest { + + @Test + void carriesPrincipalAndOnlyTheScimAuthority() { + var principal = new ScimPrincipal(UUID.randomUUID(), UUID.randomUUID(), "okta"); + var token = new ScimAuthenticationToken(principal); + + assertThat(token.isAuthenticated()).isTrue(); + assertThat(token.getPrincipal()).isEqualTo(principal); + assertThat(token.getCredentials()).isNull(); + assertThat(token.getAuthorities()).extracting(GrantedAuthority::getAuthority) + .containsExactly("SCIM"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryControllerTest.java new file mode 100644 index 00000000..bf6235ff --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimDiscoveryControllerTest.java @@ -0,0 +1,62 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimDiscoveryControllerTest { + + private final ScimDiscoveryController controller = new ScimDiscoveryController(); + + @BeforeEach + void setUp() { + var request = new MockHttpServletRequest("GET", "/scim/v2/ServiceProviderConfig"); + request.setServerName("af.example.com"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void serviceProviderConfigAdvertisesPatchButNoBulkSortEtag() { + var config = controller.serviceProviderConfig(); + + assertThat(config.get("schemas")).isEqualTo( + List.of("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig")); + assertThat(((Map) config.get("patch")).get("supported")).isEqualTo(true); + assertThat(((Map) config.get("bulk")).get("supported")).isEqualTo(false); + assertThat(((Map) config.get("sort")).get("supported")).isEqualTo(false); + assertThat(((Map) config.get("etag")).get("supported")).isEqualTo(false); + assertThat(((Map) config.get("changePassword")).get("supported")).isEqualTo(false); + assertThat(((Map) config.get("filter")).get("maxResults")).isEqualTo(200); + } + + @Test + void resourceTypesDescribeUsersAndGroups() { + var types = controller.resourceTypes(); + + assertThat(types).hasSize(2); + assertThat(types.get(0).get("endpoint")).isEqualTo("/Users"); + assertThat(types.get(1).get("endpoint")).isEqualTo("/Groups"); + } + + @Test + void schemasListUserAndGroup() { + var schemas = controller.schemas(); + + assertThat(schemas).extracting(m -> m.get("id")).containsExactly( + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:core:2.0:Group"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimEndpointsIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimEndpointsIntegrationTest.java new file mode 100644 index 00000000..910fc140 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimEndpointsIntegrationTest.java @@ -0,0 +1,319 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.TestcontainersConfig; +import com.bablsoft.accessflow.core.api.AuthProviderType; +import com.bablsoft.accessflow.core.api.UserGroupMembershipSourceType; +import com.bablsoft.accessflow.core.api.UserGroupService; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.core.internal.persistence.entity.OrganizationEntity; +import com.bablsoft.accessflow.core.internal.persistence.entity.UserEntity; +import com.bablsoft.accessflow.core.internal.persistence.repo.OrganizationRepository; +import com.bablsoft.accessflow.core.internal.persistence.repo.UserRepository; +import com.bablsoft.accessflow.scim.api.ScimTokenService; +import com.bablsoft.accessflow.scim.internal.persistence.entity.ScimConfigEntity; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimConfigRepository; +import com.bablsoft.accessflow.scim.internal.persistence.repo.ScimTokenRepository; +import com.bablsoft.accessflow.security.internal.token.RefreshTokenStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.web.context.WebApplicationContext; + +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateCrtKey; +import java.time.Duration; +import java.util.Base64; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +/** + * Full-stack SCIM 2.0 protocol tests (#621): bearer-token chain, Okta/Entra-shaped payloads, + * deactivation fan-out, group membership provenance. + */ +@SpringBootTest +@ImportTestcontainers(TestcontainersConfig.class) +class ScimEndpointsIntegrationTest { + + @Autowired WebApplicationContext context; + @Autowired OrganizationRepository organizationRepository; + @Autowired UserRepository userRepository; + @Autowired ScimConfigRepository scimConfigRepository; + @Autowired ScimTokenRepository scimTokenRepository; + @Autowired ScimTokenService scimTokenService; + @Autowired UserGroupService userGroupService; + @Autowired RefreshTokenStore refreshTokenStore; + + private MockMvcTester mvc; + private OrganizationEntity org; + private String rawToken; + + @DynamicPropertySource + static void env(DynamicPropertyRegistry registry) throws Exception { + var kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + var kp = kpg.generateKeyPair(); + var pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}) + .encodeToString(((RSAPrivateCrtKey) kp.getPrivate()).getEncoded()) + + "\n-----END PRIVATE KEY-----"; + registry.add("accessflow.jwt.private-key", () -> pem); + registry.add("accessflow.encryption-key", () -> + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + registry.add("accessflow.audit.hmac-key", () -> + "abababababababababababababababababababababababababababababababab"); + } + + @BeforeEach + void setUp() { + mvc = MockMvcTester.from(context, builder -> builder.apply(springSecurity()).build()); + scimTokenRepository.deleteAll(); + scimConfigRepository.deleteAll(); + userRepository.deleteAll(); + organizationRepository.deleteAll(); + + org = new OrganizationEntity(); + org.setId(UUID.randomUUID()); + org.setName("Primary"); + org.setSlug("primary-" + UUID.randomUUID()); + organizationRepository.save(org); + + var config = new ScimConfigEntity(); + config.setId(UUID.randomUUID()); + config.setOrganizationId(org.getId()); + config.setEnabled(true); + scimConfigRepository.save(config); + + rawToken = scimTokenService.create(org.getId(), "it-token", null).rawToken(); + } + + @Test + void missingTokenReturns401ScimEnvelope() { + var result = mvc.get().uri("/scim/v2/Users").exchange(); + + assertThat(result).hasStatus(401); + assertThat(result).bodyJson().extractingPath("$.schemas[0]").asString() + .isEqualTo("urn:ietf:params:scim:api:messages:2.0:Error"); + assertThat(result).bodyJson().extractingPath("$.status").asString().isEqualTo("401"); + } + + @Test + void revokedTokenReturns401() { + var tokenId = scimTokenRepository.findAllByOrganizationIdOrderByCreatedAtDesc(org.getId()) + .get(0).getId(); + scimTokenService.revoke(org.getId(), tokenId); + + var result = scimGet("/scim/v2/Users"); + + assertThat(result).hasStatus(401); + } + + @Test + void disabledConfigReturns401() { + var config = scimConfigRepository.findByOrganizationId(org.getId()).orElseThrow(); + config.setEnabled(false); + scimConfigRepository.save(config); + + var result = scimGet("/scim/v2/ServiceProviderConfig"); + + assertThat(result).hasStatus(401); + } + + @Test + void discoveryEndpointsAnswerWithScimMediaType() { + var result = scimGet("/scim/v2/ServiceProviderConfig"); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.patch.supported").asBoolean().isTrue(); + assertThat(scimGet("/scim/v2/ResourceTypes")).hasStatus(200); + assertThat(scimGet("/scim/v2/Schemas")).hasStatus(200); + } + + @Test + void oktaShapedCreateProvisionsScimUser() throws Exception { + var result = scimPost("/scim/v2/Users", """ + {"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName":"jane@example.com", + "name":{"givenName":"Jane","familyName":"Doe","formatted":"Jane Doe"}, + "displayName":"Jane Doe", + "emails":[{"primary":true,"value":"jane@example.com","type":"work"}], + "password":"should-be-ignored", + "externalId":"00u1abcd", + "active":true} + """); + + assertThat(result).hasStatus(201); + assertThat(result).bodyJson().extractingPath("$.userName").asString() + .isEqualTo("jane@example.com"); + assertThat(result).bodyJson().extractingPath("$.externalId").asString() + .isEqualTo("00u1abcd"); + assertThat(result.getResponse().getContentAsString()).doesNotContainIgnoringCase("password"); + + var stored = userRepository.findByEmail("jane@example.com").orElseThrow(); + assertThat(stored.getAuthProvider()).isEqualTo(AuthProviderType.SCIM); + assertThat(stored.getPasswordHash()).isNull(); + assertThat(stored.getScimExternalId()).isEqualTo("00u1abcd"); + assertThat(stored.getRole()).isEqualTo(UserRoleType.ANALYST); + } + + @Test + void duplicateEmailReturns409Uniqueness() { + seedScimUser("dup@example.com"); + + var result = scimPost("/scim/v2/Users", + "{\"userName\":\"dup@example.com\",\"active\":true}"); + + assertThat(result).hasStatus(409); + assertThat(result).bodyJson().extractingPath("$.scimType").asString() + .isEqualTo("uniqueness"); + } + + @Test + void filterByUserNameFindsTheUser() { + seedScimUser("findme@example.com"); + + var result = mvc.get().uri("/scim/v2/Users") + .param("filter", "userName eq \"findme@example.com\"") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.totalResults").asNumber().isEqualTo(1); + assertThat(result).bodyJson().extractingPath("$.Resources[0].userName").asString() + .isEqualTo("findme@example.com"); + } + + @Test + void unsupportedFilterReturnsInvalidFilterEnvelope() { + var result = mvc.get().uri("/scim/v2/Users") + .param("filter", "title co \"boss\"") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + + assertThat(result).hasStatus(400); + assertThat(result).bodyJson().extractingPath("$.scimType").asString() + .isEqualTo("invalidFilter"); + } + + @Test + void entraShapedPatchDeactivatesAndRevokesRefreshTokens() { + var user = seedScimUser("leaver@example.com"); + refreshTokenStore.store("rt-leaver", user.getId().toString(), 3600); + + var result = mvc.patch().uri("/scim/v2/Users/" + user.getId()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .contentType(MediaType.parseMediaType("application/scim+json")) + .content(""" + {"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations":[{"op":"Replace","path":"active","value":"False"}]} + """) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.active").asBoolean().isFalse(); + assertThat(userRepository.findById(user.getId()).orElseThrow().isActive()).isFalse(); + await().atMost(Duration.ofSeconds(5)).untilAsserted( + () -> assertThat(refreshTokenStore.isRevoked("rt-leaver")).isTrue()); + } + + @Test + void deleteDeactivatesIdempotently() { + var user = seedScimUser("gone@example.com"); + + assertThat(scimDelete("/scim/v2/Users/" + user.getId())).hasStatus(204); + assertThat(userRepository.findById(user.getId()).orElseThrow().isActive()).isFalse(); + assertThat(scimDelete("/scim/v2/Users/" + user.getId())).hasStatus(204); + assertThat(scimDelete("/scim/v2/Users/" + UUID.randomUUID())).hasStatus(404); + } + + @Test + void groupLifecycleKeepsManualMembershipsIntact() { + var scimUser = seedScimUser("member@example.com"); + var manualUser = seedScimUser("manual@example.com"); + + // Create the group over SCIM with one member. + var created = scimPost("/scim/v2/Groups", """ + {"schemas":["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName":"Engineers","externalId":"grp-1", + "members":[{"value":"%s"}]} + """.formatted(scimUser.getId())); + assertThat(created).hasStatus(201); + var groupId = userGroupService.listAll(org.getId()).stream() + .filter(g -> "Engineers".equals(g.name())) + .findFirst().orElseThrow().id(); + + // An admin adds a MANUAL member out-of-band. + userGroupService.addMember(groupId, manualUser.getId(), org.getId(), + UserGroupMembershipSourceType.MANUAL); + + // SCIM replaces its member set with empty — the MANUAL row must survive. + var patch = mvc.patch().uri("/scim/v2/Groups/" + groupId) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"Operations":[{"op":"replace","path":"members","value":[]}]} + """) + .exchange(); + assertThat(patch).hasStatus(200); + + var members = userGroupService.listMembers(groupId, org.getId()); + assertThat(members).hasSize(1); + assertThat(members.get(0).userId()).isEqualTo(manualUser.getId()); + assertThat(members.get(0).source()).isEqualTo(UserGroupMembershipSourceType.MANUAL); + } + + @Test + void groupFilterByDisplayName() { + scimPost("/scim/v2/Groups", "{\"displayName\":\"Platform\"}"); + + var result = mvc.get().uri("/scim/v2/Groups") + .param("filter", "displayName eq \"platform\"") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + + assertThat(result).hasStatus(200); + assertThat(result).bodyJson().extractingPath("$.totalResults").asNumber().isEqualTo(1); + } + + private UserEntity seedScimUser(String email) { + var user = new UserEntity(); + user.setId(UUID.randomUUID()); + user.setEmail(email); + user.setDisplayName(email); + user.setAuthProvider(AuthProviderType.SCIM); + user.setRole(UserRoleType.ANALYST); + user.setActive(true); + user.setOrganization(org); + return userRepository.save(user); + } + + private org.springframework.test.web.servlet.assertj.MvcTestResult scimGet(String uri) { + return mvc.get().uri(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + } + + private org.springframework.test.web.servlet.assertj.MvcTestResult scimPost(String uri, + String body) { + return mvc.post().uri(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .contentType(MediaType.parseMediaType("application/scim+json")) + .content(body) + .exchange(); + } + + private org.springframework.test.web.servlet.assertj.MvcTestResult scimDelete(String uri) { + return mvc.delete().uri(uri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken) + .exchange(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandlerTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandlerTest.java new file mode 100644 index 00000000..fb69026a --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimErrorHandlerTest.java @@ -0,0 +1,51 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.core.api.QuotaExceededException; +import com.bablsoft.accessflow.core.api.QuotaType; +import com.bablsoft.accessflow.scim.internal.protocol.ScimInvalidFilterException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimResourceNotFoundException; +import com.bablsoft.accessflow.scim.internal.protocol.ScimUniquenessException; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScimErrorHandlerTest { + + private final ScimErrorHandler handler = new ScimErrorHandler(); + + @Test + void protocolExceptionsMapToTheirStatusAndScimType() { + var notFound = handler.handleProtocol( + new ScimResourceNotFoundException("User", "abc")); + assertThat(notFound.getStatusCode().value()).isEqualTo(404); + assertThat(notFound.getBody().scimType()).isNull(); + + var conflict = handler.handleProtocol(new ScimUniquenessException("dup")); + assertThat(conflict.getStatusCode().value()).isEqualTo(409); + assertThat(conflict.getBody().scimType()).isEqualTo("uniqueness"); + + var badFilter = handler.handleProtocol(new ScimInvalidFilterException("bad")); + assertThat(badFilter.getStatusCode().value()).isEqualTo(400); + assertThat(badFilter.getBody().scimType()).isEqualTo("invalidFilter"); + assertThat(badFilter.getBody().schemas()) + .containsExactly("urn:ietf:params:scim:api:messages:2.0:Error"); + } + + @Test + void quotaMapsTo403() { + var response = handler.handleQuota( + new QuotaExceededException(QuotaType.USER, UUID.randomUUID(), 5, 5)); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + } + + @Test + void unexpectedFailuresBecomeOpaque500() { + var response = handler.handleUnexpected(new IllegalStateException("secret detail")); + + assertThat(response.getStatusCode().value()).isEqualTo(500); + assertThat(response.getBody().detail()).isEqualTo("Internal error"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilterTest.java b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilterTest.java new file mode 100644 index 00000000..dd389902 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/scim/internal/web/scim/ScimTokenAuthenticationFilterTest.java @@ -0,0 +1,115 @@ +package com.bablsoft.accessflow.scim.internal.web.scim; + +import com.bablsoft.accessflow.core.api.OrganizationLookupService; +import com.bablsoft.accessflow.scim.api.ScimConfigService; +import com.bablsoft.accessflow.scim.api.ScimPrincipal; +import com.bablsoft.accessflow.scim.api.ScimTokenService; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScimTokenAuthenticationFilterTest { + + @Mock ScimTokenService tokenService; + @Mock ScimConfigService configService; + @Mock OrganizationLookupService organizationLookupService; + @Mock FilterChain filterChain; + + ScimTokenAuthenticationFilter filter; + + private final UUID orgId = UUID.randomUUID(); + private final ScimPrincipal principal = new ScimPrincipal(orgId, UUID.randomUUID(), "okta"); + + @BeforeEach + void setUp() { + filter = new ScimTokenAuthenticationFilter(tokenService, configService, + organizationLookupService); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void validTokenWithEnabledConfigAuthenticates() throws Exception { + when(tokenService.authenticate("af_scim_x")).thenReturn(Optional.of(principal)); + when(configService.isEnabled(orgId)).thenReturn(true); + when(organizationLookupService.isDisabled(orgId)).thenReturn(false); + + filter.doFilterInternal(request("Bearer af_scim_x"), new MockHttpServletResponse(), + filterChain); + + var authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isInstanceOf(ScimAuthenticationToken.class); + assertThat(authentication.getPrincipal()).isEqualTo(principal); + verify(filterChain).doFilter(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void disabledConfigLeavesContextEmpty() throws Exception { + when(tokenService.authenticate("af_scim_x")).thenReturn(Optional.of(principal)); + when(configService.isEnabled(orgId)).thenReturn(false); + + filter.doFilterInternal(request("Bearer af_scim_x"), new MockHttpServletResponse(), + filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void disabledOrganizationLeavesContextEmpty() throws Exception { + when(tokenService.authenticate("af_scim_x")).thenReturn(Optional.of(principal)); + when(configService.isEnabled(orgId)).thenReturn(true); + when(organizationLookupService.isDisabled(orgId)).thenReturn(true); + + filter.doFilterInternal(request("Bearer af_scim_x"), new MockHttpServletResponse(), + filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void unknownTokenLeavesContextEmpty() throws Exception { + when(tokenService.authenticate("af_scim_x")).thenReturn(Optional.empty()); + + filter.doFilterInternal(request("Bearer af_scim_x"), new MockHttpServletResponse(), + filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void missingOrNonBearerHeaderIsIgnored() throws Exception { + filter.doFilterInternal(request(null), new MockHttpServletResponse(), filterChain); + filter.doFilterInternal(request("ApiKey af_x"), new MockHttpServletResponse(), + filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + private static MockHttpServletRequest request(String authorization) { + var request = new MockHttpServletRequest("GET", "/scim/v2/Users"); + if (authorization != null) { + request.addHeader("Authorization", authorization); + } + return request; + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/AdminUserControllerIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/AdminUserControllerIntegrationTest.java index fc27482a..5e2267b7 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/AdminUserControllerIntegrationTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/security/internal/web/AdminUserControllerIntegrationTest.java @@ -27,10 +27,12 @@ import java.security.KeyPairGenerator; import java.security.interfaces.RSAPrivateCrtKey; +import java.time.Duration; import java.util.Base64; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; @SpringBootTest @@ -228,7 +230,9 @@ void deactivateUserReturns204AndRevokesRefreshTokens() { assertThat(result).hasStatus(204); var reloaded = userRepository.findById(analyst.getId()).orElseThrow(); assertThat(reloaded.isActive()).isFalse(); - assertThat(refreshTokenStore.isRevoked("rt-analyst")).isTrue(); + // Revocation now runs in an async UserDeactivatedEvent listener (AFTER_COMMIT). + await().atMost(Duration.ofSeconds(5)).untilAsserted( + () -> assertThat(refreshTokenStore.isRevoked("rt-analyst")).isTrue()); } @Test diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 6808b14e..f955dcae 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -57,7 +57,7 @@ AccessFlow is composed of six primary subsystems — Proxy Engine, Workflow, AI | Frontend Framework | React 18, Vite 5, TypeScript | | UI Component Library | Ant Design 5.x | | SQL Editor | CodeMirror 6 with SQL language plugin | -| Auth | JWT (RS256) with refresh token rotation + optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, Microsoft, and GitLab; additional providers via DB-driven `oauth2_config` rows — see [07-security.md](07-security.md)) | +| Auth | JWT (RS256) with refresh token rotation + optional SAML 2.0 SSO and OAuth 2.0 / OIDC sign-in (built-in templates for Google, GitHub, Microsoft, and GitLab; additional providers via DB-driven `oauth2_config` rows — see [07-security.md](07-security.md)). User/group lifecycle can be IdP-driven over SCIM 2.0 (`/scim/v2`, per-org bearer tokens, #621). | | Containerization | Docker, Docker Compose 2.x | | Kubernetes | Helm 3 chart with ConfigMap/Secret templating | | Cache / Locks | Redis (JWT refresh-token revocation, ShedLock distributed locks for `@Scheduled` jobs) | diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 7c56cf46..457ee5b1 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -34,7 +34,8 @@ The `disabled` / `max_*` columns are added by `V87__org_isolation_quotas_platfor ## users -Platform users. Can be created locally or auto-provisioned via SAML. +Platform users. Can be created locally, auto-provisioned via SAML/OIDC login, or provisioned by +an identity provider over SCIM 2.0 (#621). | Column | Type / Notes | |--------|-------------| @@ -43,7 +44,7 @@ Platform users. Can be created locally or auto-provisioned via SAML. | `email` | VARCHAR(255) UNIQUE NOT NULL | | `display_name` | VARCHAR(255) | | `password_hash` | VARCHAR — null if SSO-only user | -| `auth_provider` | ENUM: `LOCAL` \| `SAML` \| `OAUTH2` | +| `auth_provider` | ENUM: `LOCAL` \| `SAML` \| `OAUTH2` \| `SCIM` (`SCIM` added in V141, #621 — IdP-provisioned, no password, signs in via SSO) | | `saml_subject` | VARCHAR — SAML NameID, nullable | | `role` | ENUM: `ADMIN` \| `REVIEWER` \| `ANALYST` \| `READONLY` \| `AUDITOR` (`AUDITOR` added in V91 — dedicated read-only compliance role, AF-459). **Nullable since V114 (AF-522)** — populated (and kept in sync) for users on a system role, NULL for users on a custom role; `role_id` is the source of truth. | | `role_id` | FK → `roles`, nullable (AF-522, V114) — the user's assigned role (system or custom). Backfilled from the legacy `role` enum for pre-existing rows. Indexed (`idx_users_role_id`). | @@ -55,7 +56,9 @@ Platform users. Can be created locally or auto-provisioned via SAML. | `totp_enabled` | BOOLEAN NOT NULL DEFAULT false — flipped to true only after the user confirms enrolment with a valid code | | `totp_backup_codes_encrypted` | TEXT — AES-256-GCM ciphertext of a JSON array of bcrypt hashes (one per single-use recovery code). Codes are removed from the array as they're consumed. Null when 2FA is not enabled. | | `attributes` | JSONB NOT NULL DEFAULT `'{}'` (AF-380) — admin-editable per-user attribute map, resolvable in row-security predicates as `:user.`. Set via the user admin API; **not** synced from the IdP. Added by `V61__add_users_attributes.sql`. | +| `scim_external_id` | VARCHAR(255), nullable (#621, V139) — the IdP-side SCIM `externalId`. Partial unique index `uq_users_org_scim_external_id ON (organization_id, scim_external_id) WHERE scim_external_id IS NOT NULL`. | | `created_at` | TIMESTAMPTZ | +| `updated_at` | TIMESTAMPTZ NOT NULL (#621, V139; backfilled from `created_at`) — maintained by JPA `@PreUpdate`, feeds SCIM `meta.lastModified`. | --- @@ -577,7 +580,7 @@ Records the outcome of routing a single query request (AF-379, Flyway `V59__crea ## user_groups -Named, organisation-scoped collections of users. Groups are used as the indirection layer for reviewer assignment (see `datasource_reviewers`) and may be auto-synced from OAuth2 / SAML IdP claims. +Named, organisation-scoped collections of users. Groups are used as the indirection layer for reviewer assignment (see `datasource_reviewers`), may be auto-synced from OAuth2 / SAML IdP claims, and can be created and member-synced by an identity provider over SCIM 2.0 (#621). | Column | Type / Notes | |--------|-------------| @@ -585,6 +588,7 @@ Named, organisation-scoped collections of users. Groups are used as the indirect | `organization_id` | FK → `organizations` | | `name` | VARCHAR(128) NOT NULL — unique per `(organization_id, lower(name))` | | `description` | VARCHAR(512) NULL | +| `scim_external_id` | VARCHAR(255), nullable (#621, V139) — the IdP-side SCIM `externalId`; partial unique index per org, mirroring `users.scim_external_id` | | `version` | BIGINT NOT NULL DEFAULT 0 — optimistic locking | | `created_at`, `updated_at` | TIMESTAMPTZ | @@ -596,10 +600,10 @@ Composite-key join table that bundles users into groups. |--------|-------------| | `user_id` | FK → `users` ON DELETE CASCADE — part of PK | | `group_id` | FK → `user_groups` ON DELETE CASCADE — part of PK | -| `source` | ENUM: `MANUAL` \| `IDP` — `IDP` rows are owned by the OAuth2 / SAML login sync flow; `MANUAL` rows are owned by admins via the API | +| `source` | ENUM: `MANUAL` \| `IDP` \| `SCIM` — `IDP` rows are owned by the OAuth2 / SAML login sync flow; `MANUAL` rows are owned by admins via the API; `SCIM` rows (V140, #621) are owned by the IdP's SCIM provisioning engine | | `joined_at` | TIMESTAMPTZ | -The SSO group-sync flow replaces only `source = 'IDP'` rows per user on each login, leaving `source = 'MANUAL'` rows untouched. +The SSO group-sync flow replaces only `source = 'IDP'` rows per user on each login; SCIM member operations touch only `source = 'SCIM'` rows. The **automated sync paths** are blind to each other — first source wins on membership, so neither sync can wipe or duplicate the other's rows (or MANUAL ones). The admin remove-member API is deliberately source-agnostic: an admin can remove any membership, including IDP/SCIM-owned rows (the owning sync will re-add it on its next push if the IdP still asserts it). --- @@ -1428,7 +1432,9 @@ Bootstrap reuses the existing `*_CREATED` / `*_UPDATED` actions for `DATASOURCE` ### Audit Resource Types -`resource_type` is the snake_case form of one of the values in `AuditResourceType`: `query_request`, `datasource`, `user`, `api_key`, `permission`, `review_plan`, `notification_channel`, `ai_config`, `custom_jdbc_driver`, `system_smtp`, `user_invitation`, `organization`, `oauth2_config`, `saml_config`, `langfuse_config`, `audit_log`, `slack_app_config`, `access_grant_request`, `routing_policy`, `query_comment`, `break_glass_event`, `request_group`. +`resource_type` is the snake_case form of one of the values in `AuditResourceType`: `query_request`, `datasource`, `user`, `api_key`, `permission`, `review_plan`, `notification_channel`, `ai_config`, `custom_jdbc_driver`, `system_smtp`, `user_invitation`, `organization`, `oauth2_config`, `saml_config`, `langfuse_config`, `audit_log`, `slack_app_config`, `access_grant_request`, `routing_policy`, `query_comment`, `break_glass_event`, `request_group`, `scim_config`, `scim_token`. + +SCIM-driven mutations (#621) audit as `SCIM_USER_PROVISIONED` / `SCIM_USER_UPDATED` / `SCIM_USER_DEACTIVATED` / `SCIM_GROUP_SYNCED` / `SCIM_GROUP_DELETED` with `actor_id = NULL` (the actor is the IdP's provisioning engine) and `metadata.scim_token_id` / `metadata.scim_token_name` carrying the token identity; admin-side changes audit as `SCIM_CONFIG_UPDATED` / `SCIM_TOKEN_CREATED` / `SCIM_TOKEN_REVOKED` with the caller as actor. --- @@ -1751,6 +1757,48 @@ Tracing and prompt fetch are **best-effort and non-blocking** — a Langfuse out --- +## scim_config + +Per-organization SCIM 2.0 provisioning settings (#621, V137) — a singleton row per org, owned by +the `scim` module. `attr_email` / `attr_display_name` name the SCIM attribute the corresponding +user field is read from (attribute mapping); `default_role` is the system role assigned to +SCIM-provisioned users, mirroring `saml_config.default_role`. Every `/scim/v2/**` request +re-checks `enabled` — flipping it off cuts the IdP over immediately. + +| Column | Type / Notes | +|--------|-------------| +| `id` | UUID PK | +| `organization_id` | FK → `organizations` ON DELETE CASCADE, UNIQUE (one row per org) | +| `enabled` | BOOLEAN NOT NULL DEFAULT FALSE — master switch, checked per SCIM request | +| `attr_email` | VARCHAR(255) NOT NULL DEFAULT `'userName'` — ∈ {`userName`, `emails.primary`} (app-enforced; HTTP 400 `SCIM_INVALID_MAPPING` otherwise) | +| `attr_display_name` | VARCHAR(255) NOT NULL DEFAULT `'displayName'` — ∈ {`displayName`, `name.formatted`, `userName`} | +| `default_role` | ENUM `user_role_type` NOT NULL DEFAULT `'ANALYST'` — role given to SCIM-provisioned users; SCIM can never change a role afterwards | +| `version` | BIGINT — `@Version` optimistic lock | +| `created_at` / `updated_at` | TIMESTAMPTZ | + +## scim_tokens + +Long-lived SCIM bearer tokens (#621, V138) — one or more named tokens per org so an operator can +rotate without downtime. Shape mirrors `api_keys`: only the SHA-256 hex hash and a 12-char +display prefix are stored; the raw `af_scim_…` value is shown exactly once at creation. + +| Column | Type / Notes | +|--------|-------------| +| `id` | UUID PK | +| `organization_id` | FK → `organizations` ON DELETE CASCADE | +| `name` | VARCHAR(100) NOT NULL — UNIQUE per org (`scim_tokens_unique_name_per_org`); HTTP 409 `SCIM_TOKEN_NAME_CONFLICT` on duplicates | +| `token_prefix` | VARCHAR(16) NOT NULL — display prefix (`af_scim_XXXX`) | +| `token_hash` | VARCHAR(128) NOT NULL UNIQUE — SHA-256 hex; `@JsonIgnore`d, never serialized | +| `created_by` | FK → `users` ON DELETE SET NULL, nullable | +| `last_used_at` | TIMESTAMPTZ — best-effort bump on each successful authentication | +| `revoked_at` | TIMESTAMPTZ — set once, idempotent; revoked tokens never authenticate | +| `created_at` | TIMESTAMPTZ | + +Indexes: `(organization_id)`; partial `idx_scim_tokens_active_hash ON (token_hash) WHERE +revoked_at IS NULL` — authentication is a hash lookup on every SCIM request. + +--- + ## attestation_campaign A recurring access-recertification campaign (AF-384, Flyway V99). Owned by the `attestation` module. diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index 72fdfbe7..a6c4534f 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -2492,6 +2492,11 @@ on the CSV export with `row_count` / `truncated` / the applied filters in its me | `PUT` | `/admin/ai-config` | Update AI provider, model, API key *(ADMIN only)* | | `GET` | `/admin/saml-config` | Get SAML configuration *(ADMIN only)* | | `PUT` | `/admin/saml-config` | Update SAML configuration *(ADMIN only)* | +| `GET` | `/admin/scim-config` | Get SCIM provisioning configuration (#621) *(ADMIN only)* | +| `PUT` | `/admin/scim-config` | Update SCIM provisioning configuration *(ADMIN only)* | +| `GET` | `/admin/scim/tokens` | List SCIM bearer tokens — prefixes only, never the raw token *(ADMIN only)* | +| `POST` | `/admin/scim/tokens` | Create a SCIM bearer token; the raw value is returned exactly once *(ADMIN only)* | +| `DELETE` | `/admin/scim/tokens/{id}` | Revoke a SCIM bearer token *(ADMIN only)* | | `GET` | `/admin/slack-app-config` | Get the Slack app configuration; `404` when unconfigured *(ADMIN only)* | | `PUT` | `/admin/slack-app-config` | Create or update the Slack app configuration *(ADMIN only)* | | `DELETE` | `/admin/slack-app-config` | Delete the Slack app configuration *(ADMIN only)* | @@ -4057,6 +4062,92 @@ Validation: free-text fields ≤ 1024 chars (idp/sp/acs/slo URLs and entity IDs) **Response 200:** Updated configuration (same shape as GET, `signing_cert_pem` replaced with `"********"` if set). **Response 400:** Validation error. +### SCIM Configuration (`/admin/scim-config`, `/admin/scim/tokens`) *(ADMIN only)* (#621) + +Per-organization SCIM 2.0 provisioning settings (singleton row) plus the long-lived bearer tokens +identity providers use against the [SCIM provisioning endpoints](#scim-20-provisioning-endpoints-621). +Gated by `SSO_CONFIGURE`. + +#### GET /admin/scim-config + +Returns the org's configuration, or an all-default view (disabled, default mappings) when none exists. + +**Response 200:** +```json +{ + "id": "uuid", + "organization_id": "uuid", + "enabled": false, + "attr_email": "userName", + "attr_display_name": "displayName", + "default_role": "ANALYST", + "created_at": "2026-08-13T10:00:00Z", + "updated_at": "2026-08-13T10:00:00Z" +} +``` + +`attr_email` is the SCIM attribute the user's email is read from (`userName` or `emails.primary`); +`attr_display_name` is the SCIM attribute the display name is read from (`displayName`, +`name.formatted`, or `userName`). `default_role` is the system role assigned to SCIM-provisioned +users (mirrors `saml_config.default_role` / `oauth2_config.default_role`). + +#### PUT /admin/scim-config + +Partial update / upsert. Any omitted field is left unchanged. + +**Request body:** `{ "enabled": true, "attr_email": "userName", "attr_display_name": "displayName", "default_role": "ANALYST" }` + +Validation: `attr_email` ∈ {`userName`, `emails.primary`}; `attr_display_name` ∈ {`displayName`, +`name.formatted`, `userName`}; `default_role` a system-role enum name. + +**Response 200:** Updated configuration (same shape as GET). Emits a `SCIM_CONFIG_UPDATED` audit row. +**Response 400:** Validation error. + +#### GET /admin/scim/tokens + +Lists the org's SCIM bearer tokens, newest first. Only the `token_prefix` is ever returned — the +raw token is not recoverable after creation. + +**Response 200:** +```json +[ + { + "id": "uuid", + "name": "okta-prod", + "token_prefix": "af_scim_AbCd", + "created_at": "2026-08-13T10:00:00Z", + "last_used_at": "2026-08-13T11:30:00Z", + "revoked_at": null + } +] +``` + +#### POST /admin/scim/tokens + +Creates a named bearer token. The response is the **only** time the raw token is returned; only a +SHA-256 hash is persisted. + +**Request body:** `{ "name": "okta-prod" }` — 1–100 chars, unique per organization. + +**Response 201:** +```json +{ + "token": { "id": "uuid", "name": "okta-prod", "token_prefix": "af_scim_AbCd", "created_at": "…", "last_used_at": null, "revoked_at": null }, + "raw_token": "af_scim_AbCd…" +} +``` + +**Response 409:** A token with this name already exists. `error: SCIM_TOKEN_NAME_CONFLICT`. + +Emits a `SCIM_TOKEN_CREATED` audit row. + +#### DELETE /admin/scim/tokens/{id} + +Revokes the token (idempotent; revoked tokens fail SCIM authentication immediately). + +**Response 204.** **Response 404:** unknown token id (`error: SCIM_TOKEN_NOT_FOUND`). Emits a +`SCIM_TOKEN_REVOKED` audit row. + ### Langfuse Configuration (`/admin/langfuse-config`) *(ADMIN only)* Per-organization Langfuse integration settings (singleton row). Drives analyzer **tracing** and **prompt management** — see [docs/05-backend.md → "Langfuse integration"](05-backend.md#langfuse-integration). @@ -4525,6 +4616,69 @@ count). A breach is rejected with `409 Conflict` and a localized `detail` naming --- +## SCIM 2.0 Provisioning Endpoints (#621) + +The SCIM 2.0 service-provider surface identity providers (Okta, Microsoft Entra ID, Keycloak, +OneLogin) call to drive user and group lifecycle. **Not under `/api/v1`** — SCIM mandates its own +base path and PascalCase resource names (RFC 7644), a deliberate exception to the kebab-case rule. + +- **Base path:** `/scim/v2` +- **Authentication:** `Authorization: Bearer ` with a per-organization SCIM token created via + [`POST /admin/scim/tokens`](#post-adminscimtokens). Never a JWT. The org is derived from the + token; requests are rejected with 401 when the token is unknown/revoked, when the org's SCIM + config is disabled, or when the organization itself is disabled. +- **Media types:** `application/scim+json` and `application/json` are both accepted and produced. +- **Errors:** the SCIM error envelope — **not** RFC 9457 `ProblemDetail`: + +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "400", + "scimType": "invalidFilter", + "detail": "Unsupported filter expression" +} +``` + +`scimType` values used: `invalidFilter`, `uniqueness`, `invalidPath`, `invalidValue`. SCIM error +`detail` strings are intentionally not localized (the consumer is an IdP provisioning engine). + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/scim/v2/ServiceProviderConfig` | Capability discovery (patch supported, filter max 200, no bulk/sort/etag) | +| `GET` | `/scim/v2/ResourceTypes` | Resource-type discovery (User, Group) | +| `GET` | `/scim/v2/Schemas` | Schema discovery | +| `GET` | `/scim/v2/Users` | List/filter users — `filter=userName eq "…"`, `externalId eq "…"`, `emails eq "…"`; `startIndex` (1-based), `count` (max 200) | +| `POST` | `/scim/v2/Users` | Provision a user (201; 409 `uniqueness` when the email exists; 403 when the org user quota is exceeded) | +| `GET` | `/scim/v2/Users/{id}` | Fetch a user (404 when unknown in this org) | +| `PUT` | `/scim/v2/Users/{id}` | Replace SCIM-owned attributes (mapped email, display name, `externalId`, `active`) | +| `PATCH` | `/scim/v2/Users/{id}` | PatchOp — `replace`/`add` on `active`, `displayName`, `externalId`, the mapped email attribute | +| `DELETE` | `/scim/v2/Users/{id}` | Deactivates the user (AccessFlow never hard-deletes users) — 204 | +| `GET` | `/scim/v2/Groups` | List/filter groups — `filter=displayName eq "…"`, `externalId eq "…"` | +| `POST` | `/scim/v2/Groups` | Create a group (409 `uniqueness` on duplicate name/externalId) | +| `GET` | `/scim/v2/Groups/{id}` | Fetch a group with members | +| `PUT` | `/scim/v2/Groups/{id}` | Replace `displayName`, `externalId`, and SCIM-sourced members | +| `PATCH` | `/scim/v2/Groups/{id}` | `add`/`remove`/`replace` on `members` (including the `members[value eq "…"]` path form Entra sends) and `replace` on `displayName` | +| `DELETE` | `/scim/v2/Groups/{id}` | Delete the group — cascades memberships **and group-based grants** — 204 | + +Semantics worth knowing (full detail in [docs/07-security.md → SCIM 2.0 provisioning](07-security.md)): + +- **List responses** use the `urn:ietf:params:scim:api:messages:2.0:ListResponse` envelope with + `totalResults`, `startIndex`, `itemsPerPage`, `Resources`. +- **`userName`** echoes the configured email-source attribute (default: the user's email). `id` is + the AccessFlow user UUID. `meta.created`/`meta.lastModified` map to `created_at`/`updated_at`; + `meta.location` is the absolute resource URL. +- **User responses never contain password data**, and SCIM can never write `role`, `platform_admin`, + TOTP settings, or row-security `attributes` — only the mapped email, display name, `externalId`, + `active`, and group memberships. +- **`active: false`** (via PUT, PATCH, or DELETE) deactivates the user: login is disabled, refresh + tokens are revoked, and active JIT access grants are revoked. Deactivating an already-inactive + user is an idempotent no-op. +- **Group memberships written by SCIM are tracked with `source=SCIM`** — they coexist with + memberships an admin created manually (`MANUAL`) and with SSO-login group mapping (`IDP`); SCIM + member ops never touch the other two sources, and vice versa. +- **Provisioned users** are created with `auth_provider=SCIM`, no password, and the configured + `default_role`; they sign in through the org's SAML/OIDC SSO. + ## Slack Integration Endpoints (AF-362) Upgrades the one-way `SLACK` notification channel to a Slack **app** with interactive Approve / Reject buttons. See [docs/08-notifications.md → Slack app](08-notifications.md#slack-app-interactive-approve--reject--af-362) and [docs/07-security.md → Slack request verification](07-security.md#slack-request-verification-af-362). @@ -5219,6 +5373,8 @@ The following codes are returned in addition to the per-endpoint codes documente | `API_REQUEST_PERMISSION_DENIED` | 403 | Caller lacks read/write/break-glass on the connector. | | `API_REQUEST_VALIDATION_ERROR` | 422 | The call failed validation against the connector schema (`reason`). | | `API_EXECUTION_FAILED` | 502 | The upstream API call could not be executed (`reason`). | +| `SCIM_TOKEN_NOT_FOUND` | 404 | Unknown SCIM token id for the caller's organization (#621). | +| `SCIM_TOKEN_NAME_CONFLICT` | 409 | A SCIM token with that name already exists in the org (#621). | | Code | HTTP | Source | Notes | |------|------|--------|-------| diff --git a/docs/05-backend.md b/docs/05-backend.md index ceacc351..f3a8fb4b 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -2489,6 +2489,51 @@ User-managed API keys live alongside the rest of authentication in the **`securi The full REST contract is in `docs/04-api-spec.md` → "API Keys". +## SCIM provisioning (scim module, #621) + +The **`scim/` module** (`com.bablsoft.accessflow.scim`) is the SCIM 2.0 service provider: IdPs +(Okta, Entra ID, Keycloak, OneLogin) create/update/deactivate users and sync groups over +`/scim/v2/Users` and `/scim/v2/Groups`. + +- **Own security chain.** `scim.internal.config.ScimSecurityConfiguration` contributes a + `SecurityFilterChain` bean (`@Order(0)`, `securityMatcher("/scim/v2/**")`) — Spring collects + filter-chain beans from any `@Configuration`, so the security module's + `SecurityConfiguration` is untouched. `ScimTokenAuthenticationFilter` resolves the per-org + bearer token (SHA-256 hash lookup on `scim_tokens`), re-checks `scim_config.enabled` and the + org-disabled kill-switch per request, and a dedicated entry point emits 401 in the SCIM + error envelope (never ProblemDetail; see `docs/07-security.md`). +- **Protocol layer.** Hand-rolled RFC 7644 pragmatic subset in `scim.internal.protocol`: + Jackson wire records (annotated `@JsonNaming(LowerCamelCase)` to override the app-wide + SNAKE_CASE strategy — SCIM mandates camelCase), an `eq`-only filter parser, and a PatchOp + applier that handles both Okta shapes (no-path value objects, real booleans) and Entra + shapes (`path: "active"` with string `"False"`, `members[value eq "…"]` removes). +- **Orchestration.** `ScimUserOrchestrator` maps the wire contract onto + `core.api.ExternalUserDirectoryService` — the system-actor user primitives (create with + quota + global-email uniqueness, partial update limited to SCIM-owned attributes, offset + paging for `startIndex`). `ScimGroupOrchestrator` maps onto `core.api.UserGroupService`'s + source-scoped member operations (`source=SCIM`). DELETE on a user deactivates — AccessFlow + never hard-deletes users. +- **Admin surface.** `/api/v1/admin/scim-config` + `/api/v1/admin/scim/tokens` + (`PERM_SSO_CONFIGURE`), show-once token issuance mirroring API keys. +- No scheduled jobs — SCIM is entirely IdP-push-driven. + +### User deactivation fan-out (UserDeactivatedEvent) + +Whenever a user's `is_active` transitions `true → false` — admin `PUT /admin/users/{id} +active=false`, admin `DELETE /admin/users/{id}`, or any SCIM deactivation path — +`core.events.UserDeactivatedEvent` is published (transition-only: deactivating an inactive user +publishes nothing). Consumers, both `@ApplicationModuleListener` (async, AFTER_COMMIT): + +- `security.internal.UserDeactivationListener` — revokes every refresh token + (`RefreshTokenStore.revokeAllForUser`); outstanding access tokens expire within + `ACCESSFLOW_JWT_ACCESS_TOKEN_EXPIRY` (default 15 min). +- `access.internal.UserDeactivationGrantRevoker` — revokes the user's `APPROVED` JIT grants + through the ordinary revocation path (system-attributed, idempotent, per-row failures + swallowed). + +Before #621, refresh-token revocation lived in `AdminUserController` and only fired on the +DELETE path; the event unifies all deactivation paths. + ## API Access Governance (apigov module, AF-500) The **`apigov/` module** governs outbound API calls (REST / SOAP / GraphQL / gRPC) with the same diff --git a/docs/06-frontend.md b/docs/06-frontend.md index 8ccc978c..5f20c1cb 100644 --- a/docs/06-frontend.md +++ b/docs/06-frontend.md @@ -151,6 +151,7 @@ accessflow-ui/ │ │ ├── AIConfigPage.tsx │ │ ├── NotificationsPage.tsx │ │ ├── SamlConfigPage.tsx # SAML 2.0 SSO configuration +│ │ ├── ScimConfigPage.tsx # SCIM 2.0 provisioning config + bearer tokens (#621) │ │ └── LangfuseConfigPage.tsx # Langfuse tracing + prompt management │ │ │ ├── store/ @@ -855,6 +856,7 @@ for deployment recipes (Docker Compose, Helm). /admin/languages → LanguagesConfigPage /admin/drivers → CustomDriversPage (admin-uploaded JDBC drivers) /admin/saml → SamlConfigPage +/admin/scim → ScimConfigPage (lazy; SCIM 2.0 provisioning — #621) /admin/oauth2 → OAuth2ConfigPage (lazy) /admin/slack → SlackConfigPage (lazy; Slack app config — AF-362) /admin/langfuse → LangfuseConfigPage (lazy; Langfuse tracing + prompt management — AF-333) diff --git a/docs/07-security.md b/docs/07-security.md index 0b192cc6..a995a7ed 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -170,6 +170,63 @@ API returns `"********"` whenever a secret is stored — the plaintext never lea `AiAnalyzerStrategyHolder`. Config changes take effect on the next authorize request — no application restart. +### SCIM 2.0 provisioning (#621) + +Identity providers (Okta, Microsoft Entra ID, Keycloak, OneLogin) drive user and group +lifecycle over `/scim/v2/Users` and `/scim/v2/Groups` — the joiner/mover/leaver follow-on to +SSO. Implemented by the standalone `scim` module. + +- **Authentication.** A long-lived per-organization bearer token, never a JWT. Format + `af_scim_<32-byte base64url>`; only the SHA-256 hex hash and a 12-char display prefix are + stored (`scim_tokens.token_hash`, `token_prefix`), the plaintext is shown **once** on + creation (same reasoning as API keys: 256 bits of entropy make the unsalted hash safe to + look up per request). Multiple named tokens per org allow zero-downtime rotation; revocation + sets `revoked_at` and takes effect on the next request. +- **Filter chain.** `/scim/v2/**` has its own `SecurityFilterChain` (`@Order(0)`, ahead of the + SAML/OAuth2/catch-all chains) with `ScimTokenAuthenticationFilter` and a dedicated entry + point that answers 401 in the SCIM error envelope + (`urn:ietf:params:scim:api:messages:2.0:Error`) — IdP provisioning engines do not parse + ProblemDetail. CSRF and CORS are disabled: SCIM is server-to-server, a browser never calls + it. The org is **derived from the token**, never from the request; every request re-checks + `scim_config.enabled` and the org-disabled kill-switch, exactly like the JWT and API-key + filters. The filter's `SCIM` authority never overlaps `PERM_*`/`ROLE_*`, so a SCIM token can + never reach a JWT-guarded endpoint. +- **Write boundary.** SCIM owns exactly: the mapped email, display name, `externalId`, + `active`, and group memberships. It can never write roles, `platform_admin`, passwords, TOTP + settings, or row-security attributes — there is no role-escalation surface. User responses + never contain password-shaped fields (the wire records have none). SCIM-provisioned users + carry `auth_provider = SCIM` and a NULL password hash: local login is impossible and they + sign in through the org's SAML/OIDC SSO (whose email-match provisioning accepts non-LOCAL + rows without tripping the local-account takeover guard). +- **Deactivation fan-out.** `active=false` (PATCH, PUT, or DELETE — AccessFlow never + hard-deletes users) flips `is_active` and publishes `core.events.UserDeactivatedEvent`; the + security module revokes all refresh tokens and the access module revokes the user's APPROVED + JIT grants through the ordinary revocation path (system-attributed). Outstanding access + tokens expire naturally within `ACCESSFLOW_JWT_ACCESS_TOKEN_EXPIRY` (default 15 minutes). + The same event unifies admin-UI deactivation, so all paths behave identically. +- **Group provenance.** SCIM-pushed memberships carry `source = SCIM` in + `user_group_memberships`, disjoint from admin `MANUAL` rows and SSO-login `IDP` rows — no + path can overwrite another's memberships. Deleting a group over SCIM cascades its + memberships **and** its group-based grants (`datasource_group_permissions`, + `api_connector_group_permissions`) — the correct semantics for "group removed at the IdP", + and audited with member counts. +- **Failure modes.** Unknown/revoked token, disabled config, disabled org → 401 (SCIM + envelope). Duplicate email/externalId/group name → 409 `scimType=uniqueness` (emails are + globally unique across orgs; the IdP then adopts the existing user via its `userName eq` + lookup). User quota exhausted (create or reactivation) → 403. Unsupported filter → 400 + `invalidFilter`; unsupported PatchOp path/op → 400 `invalidPath`/`invalidValue`. SCIM error + details are intentionally not localized — the consumer is a machine. +- **Audit.** Every SCIM mutation writes a synchronous audit row (`SCIM_USER_PROVISIONED`, + `SCIM_USER_UPDATED`, `SCIM_USER_DEACTIVATED`, `SCIM_GROUP_SYNCED`, `SCIM_GROUP_DELETED`) + with `actor_id = NULL` and the token identity (`scim_token_id`, `scim_token_name`) in the + metadata. Admin config/token changes audit as `SCIM_CONFIG_UPDATED` / `SCIM_TOKEN_CREATED` / + `SCIM_TOKEN_REVOKED` with the caller as actor. +- **Load-bearing regression check:** `e2e/tests/admin-scim-config.spec.ts` — config CRUD, + show-once token issue/revoke, Okta-shaped provisioning, Entra-shaped deactivation, and the + 401 envelope after revocation. + +Operator setup guide (Okta / Entra ID walkthroughs): `website/docs/configuration/auth/#cfg-scim`. + ### API key authentication Users may create personal API keys (under **Profile → API keys**) to authenticate the MCP diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index 516749f5..9b59cb98 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -69,6 +69,7 @@ - SAML 2.0 SP-initiated and IdP-initiated SSO - Auto-provisioning of users from SAML assertions - SAML attribute → role mapping +- SCIM 2.0 provisioning — IdP-driven user & group lifecycle (Okta / Entra ID / Keycloak, #621) - OAuth 2.0 / OIDC sign-in with built-in templates for Google, GitHub, Microsoft, and GitLab; additional providers configurable via DB-driven `oauth2_config` rows --- diff --git a/e2e/tests/admin-scim-config.spec.ts b/e2e/tests/admin-scim-config.spec.ts new file mode 100644 index 00000000..eb907211 --- /dev/null +++ b/e2e/tests/admin-scim-config.spec.ts @@ -0,0 +1,293 @@ +import { + test, + expect, + type APIRequestContext, + type Page, +} from '@playwright/test'; +import { loginViaApi } from '../helpers/datasources'; + +const ADMIN_EMAIL = 'e2e@accessflow.test'; +const ADMIN_PASSWORD = 'E2ePassword!123'; + +const DEFAULT_API_BASE = 'http://localhost:8080'; + +// #621 — the standard e2e stack seeds no scim_config row, so we start from +// "SCIM disabled / no tokens". The spec drives the /admin/scim page for +// config + token management, then exercises the SCIM protocol surface itself +// with the issued bearer token against the backend origin (page.request with +// a relative URL would hit the SPA, not the backend). + +// Unique per run so re-running against a warm stack never collides. +const RUN_ID = Date.now(); +const TOKEN_NAME = `e2e-${RUN_ID}`; +const SCIM_USER_EMAIL = `scim-e2e-${RUN_ID}@accessflow.test`; + +function apiBase(): string { + return process.env.E2E_API_BASE ?? DEFAULT_API_BASE; +} + +async function loginViaUi(page: Page, email: string, password: string): Promise { + await page.goto('/login'); + await page.locator('#login-email').fill(email); + await page.locator('#login-password').fill(password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/dashboard', { timeout: 15_000 }); +} + +// Gate before driving the form: wait for the GET that ScimConfigPage issues on +// mount so the Form.useForm setFieldsValue effect has populated the inputs. +// Mirrors waitForSamlConfigLoaded in admin-saml-config.spec.ts. +async function waitForScimConfigLoaded(page: Page): Promise { + await page.waitForResponse( + (r) => + r.request().method() === 'GET' && + /\/api\/v1\/admin\/scim-config$/.test(r.url()) && + r.status() < 500, + { timeout: 15_000 }, + ); +} + +async function resetScimConfig( + request: APIRequestContext, + accessToken: string, +): Promise { + const res = await request.put(`${apiBase()}/api/v1/admin/scim-config`, { + headers: { Authorization: `Bearer ${accessToken}` }, + data: { enabled: false }, + }); + if (!res.ok()) { + // eslint-disable-next-line no-console + console.warn(`SCIM config reset returned ${res.status()}: ${await res.text()}`); + } +} + +// #621 covers the /admin/scim surface plus the SCIM protocol end-to-end: +// 1. Initial state — SCIM disabled, defaults rendered, base URL visible. +// 2. Enable + save → toast; PUT round-trips through the real backend. +// 3. Create a bearer token → show-once modal; the raw value authenticates +// against /scim/v2/ServiceProviderConfig. +// 4. Provision an Okta-shaped user over SCIM → 201; the user appears in the +// admin users page. +// 5. Entra-shaped PATCH active=false → the user shows as inactive. +// 6. Revoke the token in the UI → the SCIM call now returns 401 in the SCIM +// error envelope. +// +// describe.serial because each scenario depends on state established by the +// previous one; afterAll disables the config so adjacent specs are unaffected. +test.describe.serial('/admin/scim — config, tokens, and provisioning (#621)', () => { + let adminAccessToken = ''; + let rawScimToken = ''; + let scimUserId = ''; + + test.beforeAll(async ({ request }) => { + adminAccessToken = await loginViaApi(request, ADMIN_EMAIL, ADMIN_PASSWORD); + }); + + test.afterAll(async ({ request }) => { + if (adminAccessToken) { + await resetScimConfig(request, adminAccessToken); + } + // The provisioned user is deliberately left behind (deactivated by test 5): + // AccessFlow never hard-deletes users, and the per-run unique email prevents + // collisions on warm-stack reruns. + }); + + test('1) initial load → defaults rendered, SCIM disabled', async ({ page }) => { + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/scim'); + await waitForScimConfigLoaded(page); + + await expect(page.getByRole('heading', { name: 'SCIM Provisioning' })).toBeVisible(); + await expect(page.getByText('SCIM endpoint', { exact: true })).toBeVisible(); + await expect(page.getByText('Attribute mapping', { exact: true })).toBeVisible(); + await expect(page.getByText('Bearer tokens', { exact: true })).toBeVisible(); + await expect(page.getByText(/\/scim\/v2$/).first()).toBeVisible(); + // The Enabled switch reflects the unseeded "disabled" default. + await expect(page.getByRole('switch')).not.toBeChecked(); + }); + + test('2) enable + save → toast', async ({ page }) => { + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/scim'); + await waitForScimConfigLoaded(page); + + await page.getByRole('switch').click(); + await expect(page.getByRole('switch')).toBeChecked(); + + const saveResponsePromise = page.waitForResponse( + (r) => + r.request().method() === 'PUT' && + /\/api\/v1\/admin\/scim-config$/.test(r.url()), + { timeout: 15_000 }, + ); + await page.getByRole('button', { name: 'Save' }).click(); + const saveResponse = await saveResponsePromise; + expect(saveResponse.status()).toBe(200); + const body = (await saveResponse.json()) as { enabled?: boolean }; + expect(body.enabled).toBe(true); + + await expect( + page.getByText('SCIM configuration saved', { exact: true }), + ).toBeVisible({ timeout: 10_000 }); + }); + + test('3) create token → show-once modal; token authenticates against /scim/v2', async ({ + page, + request, + }) => { + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/scim'); + await waitForScimConfigLoaded(page); + + await page.getByRole('button', { name: 'Create token' }).click(); + const createDialog = page.getByRole('dialog').filter({ hasText: 'Create SCIM token' }); + await createDialog.getByLabel('Token name').fill(TOKEN_NAME); + + const createResponsePromise = page.waitForResponse( + (r) => + r.request().method() === 'POST' && + /\/api\/v1\/admin\/scim\/tokens$/.test(r.url()), + { timeout: 15_000 }, + ); + await createDialog.getByRole('button', { name: 'Create token' }).click(); + const createResponse = await createResponsePromise; + expect(createResponse.status()).toBe(201); + const created = (await createResponse.json()) as { raw_token?: string }; + expect(created.raw_token).toMatch(/^af_scim_/); + rawScimToken = created.raw_token ?? ''; + + // The show-once modal displays the same raw value and the copy warning. + const issuedDialog = page.getByRole('dialog').filter({ hasText: 'SCIM token created' }); + await expect(issuedDialog.getByText(rawScimToken)).toBeVisible(); + await expect( + issuedDialog.getByText('Copy this token now — it will not be shown again.'), + ).toBeVisible(); + // Two buttons in the dialog have the accessible name "Close": AntD's X icon + // (aria-label="Close", first in DOM) and the footer button — target the latter. + await issuedDialog.getByRole('button', { name: 'Close' }).last().click(); + // AntD keeps the modal mounted through its close animation — wait for it to hide + // so the token name below matches only the table row, not the modal's copy. + await expect(issuedDialog).toBeHidden(); + + // The token row lists only the prefix, never the raw value. + await expect( + page.getByLabel('Bearer tokens').getByText(TOKEN_NAME, { exact: true }), + ).toBeVisible(); + + // The raw token authenticates against the SCIM discovery endpoint. + const spConfig = await request.get(`${apiBase()}/scim/v2/ServiceProviderConfig`, { + headers: { Authorization: `Bearer ${rawScimToken}` }, + }); + expect(spConfig.status()).toBe(200); + const spBody = (await spConfig.json()) as { patch?: { supported?: boolean } }; + expect(spBody.patch?.supported).toBe(true); + }); + + test('4) provision an Okta-shaped user over SCIM → appears in admin users', async ({ + page, + request, + }) => { + const createRes = await request.post(`${apiBase()}/scim/v2/Users`, { + headers: { + Authorization: `Bearer ${rawScimToken}`, + 'Content-Type': 'application/scim+json', + }, + data: { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: SCIM_USER_EMAIL, + name: { givenName: 'Scim', familyName: 'User', formatted: 'Scim User' }, + displayName: 'Scim User', + emails: [{ primary: true, value: SCIM_USER_EMAIL, type: 'work' }], + externalId: `okta-${RUN_ID}`, + active: true, + }, + }); + expect(createRes.status()).toBe(201); + const createdUser = (await createRes.json()) as { + id: string; + userName?: string; + active?: boolean; + }; + expect(createdUser.userName).toBe(SCIM_USER_EMAIL); + expect(createdUser.active).toBe(true); + scimUserId = createdUser.id; + + // A userName eq filter (the Okta dedupe probe) finds the user. + const filterRes = await request.get( + `${apiBase()}/scim/v2/Users?filter=${encodeURIComponent( + `userName eq "${SCIM_USER_EMAIL}"`, + )}`, + { headers: { Authorization: `Bearer ${rawScimToken}` } }, + ); + expect(filterRes.status()).toBe(200); + const filterBody = (await filterRes.json()) as { totalResults?: number }; + expect(filterBody.totalResults).toBe(1); + + // The provisioned user shows up in the admin users page. + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/users'); + await expect(page.getByText(SCIM_USER_EMAIL)).toBeVisible({ timeout: 15_000 }); + }); + + test('5) Entra-shaped PATCH active=false → user deactivated', async ({ request }) => { + const patchRes = await request.patch(`${apiBase()}/scim/v2/Users/${scimUserId}`, { + headers: { + Authorization: `Bearer ${rawScimToken}`, + 'Content-Type': 'application/scim+json', + }, + data: { + schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], + Operations: [{ op: 'Replace', path: 'active', value: 'False' }], + }, + }); + expect(patchRes.status()).toBe(200); + const patched = (await patchRes.json()) as { active?: boolean }; + expect(patched.active).toBe(false); + + // The admin API confirms the flag flipped (backend state, not UI cache). + const adminUsers = await request.get( + `${apiBase()}/api/v1/admin/users?size=100`, + { headers: { Authorization: `Bearer ${adminAccessToken}` } }, + ); + expect(adminUsers.ok()).toBe(true); + const adminBody = (await adminUsers.json()) as { + content: Array<{ email: string; active: boolean }>; + }; + const row = adminBody.content.find((u) => u.email === SCIM_USER_EMAIL); + expect(row).toBeDefined(); + expect(row?.active).toBe(false); + }); + + test('6) revoke token in UI → SCIM call returns 401 envelope', async ({ + page, + request, + }) => { + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + await page.goto('/admin/scim'); + await waitForScimConfigLoaded(page); + + await page.getByRole('button', { name: `Revoke token ${TOKEN_NAME}` }).click(); + const revokeResponsePromise = page.waitForResponse( + (r) => + r.request().method() === 'DELETE' && + /\/api\/v1\/admin\/scim\/tokens\//.test(r.url()), + { timeout: 15_000 }, + ); + // Popconfirm ok button. + await page.getByRole('button', { name: 'Revoke', exact: true }).click(); + const revokeResponse = await revokeResponsePromise; + expect(revokeResponse.status()).toBe(204); + + await expect(page.getByText('Token revoked', { exact: true })).toBeVisible({ + timeout: 10_000, + }); + + const scimRes = await request.get(`${apiBase()}/scim/v2/Users`, { + headers: { Authorization: `Bearer ${rawScimToken}` }, + }); + expect(scimRes.status()).toBe(401); + const errorBody = (await scimRes.json()) as { schemas?: string[]; status?: string }; + expect(errorBody.schemas).toContain('urn:ietf:params:scim:api:messages:2.0:Error'); + expect(errorBody.status).toBe('401'); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2cd7762..ac0f48ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { LoginPage } from '@/pages/auth/LoginPage'; import { SetupPage } from '@/pages/auth/SetupPage'; const OAuthCallbackPage = lazy(() => import('@/pages/auth/OAuthCallbackPage')); const SamlCallbackPage = lazy(() => import('@/pages/auth/SamlCallbackPage')); +const ScimConfigPage = lazy(() => import('@/pages/admin/ScimConfigPage')); const AcceptInvitePage = lazy(() => import('@/pages/auth/AcceptInvitePage')); const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage')); const ResetPasswordPage = lazy(() => import('@/pages/auth/ResetPasswordPage')); @@ -589,6 +590,16 @@ export function App() { } /> + + + + + + } + /> { expect(put).toHaveBeenCalledWith('/api/v1/admin/saml-config', { active: true }); }); + // ── SCIM config + tokens (#621) ─────────────────────────────────────────── + it('getScimConfig GETs /admin/scim-config', async () => { + get.mockResolvedValueOnce({ data: { enabled: false } }); + await adminApi.getScimConfig(); + expect(get).toHaveBeenCalledWith('/api/v1/admin/scim-config'); + }); + + it('updateScimConfig PUTs the body', async () => { + put.mockResolvedValueOnce({ data: { enabled: true } }); + await adminApi.updateScimConfig({ enabled: true, attr_email: 'userName' }); + expect(put).toHaveBeenCalledWith('/api/v1/admin/scim-config', { + enabled: true, + attr_email: 'userName', + }); + }); + + it('listScimTokens GETs /admin/scim/tokens', async () => { + get.mockResolvedValueOnce({ data: [] }); + await adminApi.listScimTokens(); + expect(get).toHaveBeenCalledWith('/api/v1/admin/scim/tokens'); + }); + + it('createScimToken POSTs the name', async () => { + post.mockResolvedValueOnce({ data: { raw_token: 'af_scim_x' } }); + await adminApi.createScimToken({ name: 'okta-prod' }); + expect(post).toHaveBeenCalledWith('/api/v1/admin/scim/tokens', { name: 'okta-prod' }); + }); + + it('revokeScimToken DELETEs by id', async () => { + del.mockResolvedValueOnce({}); + await adminApi.revokeScimToken('token-1'); + expect(del).toHaveBeenCalledWith('/api/v1/admin/scim/tokens/token-1'); + }); + // ── Langfuse config ─────────────────────────────────────────────────────── it('getLangfuseConfig GETs /admin/langfuse-config', async () => { get.mockResolvedValueOnce({ data: { enabled: false } }); diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 9d83d4d8..17df87c4 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -21,6 +21,11 @@ import type { OAuth2Config, OAuth2Provider, SamlConfig, + ScimConfig, + ScimToken, + CreateScimTokenInput, + CreatedScimToken, + UpdateScimConfigInput, SetupProgress, TestAiConfigResult, TestNotificationChannelInput, @@ -48,6 +53,8 @@ const CHANNELS_BASE = '/api/v1/admin/notification-channels'; const AI_CONFIGS_BASE = '/api/v1/admin/ai-configs'; const AI_ANALYSES_BASE = '/api/v1/admin/ai-analyses'; const SAML_CONFIG_BASE = '/api/v1/admin/saml-config'; +const SCIM_CONFIG_BASE = '/api/v1/admin/scim-config'; +const SCIM_TOKENS_BASE = '/api/v1/admin/scim/tokens'; const LANGFUSE_CONFIG_BASE = '/api/v1/admin/langfuse-config'; const OAUTH2_CONFIG_BASE = '/api/v1/admin/oauth2-config'; const SETUP_PROGRESS_BASE = '/api/v1/admin/setup-progress'; @@ -96,6 +103,16 @@ export const samlConfigKeys = { current: () => ['samlConfig', 'current'] as const, }; +export const scimConfigKeys = { + all: ['scimConfig'] as const, + current: () => ['scimConfig', 'current'] as const, +}; + +export const scimTokenKeys = { + all: ['scimTokens'] as const, + list: () => ['scimTokens', 'list'] as const, +}; + export const langfuseConfigKeys = { all: ['langfuseConfig'] as const, current: () => ['langfuseConfig', 'current'] as const, @@ -354,6 +371,32 @@ export async function updateSamlConfig(input: UpdateSamlConfigInput): Promise { + const { data } = await apiClient.get(SCIM_CONFIG_BASE); + return data; +} + +export async function updateScimConfig(input: UpdateScimConfigInput): Promise { + const { data } = await apiClient.put(SCIM_CONFIG_BASE, input); + return data; +} + +export async function listScimTokens(): Promise { + const { data } = await apiClient.get(SCIM_TOKENS_BASE); + return data; +} + +export async function createScimToken(input: CreateScimTokenInput): Promise { + const { data } = await apiClient.post(SCIM_TOKENS_BASE, input); + return data; +} + +export async function revokeScimToken(id: string): Promise { + await apiClient.delete(`${SCIM_TOKENS_BASE}/${id}`); +} + // ── Langfuse config ────────────────────────────────────────────────────────── export async function getLangfuseConfig(): Promise { diff --git a/frontend/src/components/common/Sidebar.tsx b/frontend/src/components/common/Sidebar.tsx index 500a2124..b5a05a74 100644 --- a/frontend/src/components/common/Sidebar.tsx +++ b/frontend/src/components/common/Sidebar.tsx @@ -20,6 +20,7 @@ import { SlackOutlined, LeftOutlined, RightOutlined, + CloudSyncOutlined, CloseOutlined, BarChartOutlined, DashboardOutlined, @@ -129,6 +130,7 @@ export function Sidebar({ { id: 'auditor', to: '/admin/auditor', label: t('nav.auditor'), icon: , permissions: ['COMPLIANCE_REPORT_VIEW'] }, { id: 'saml', to: '/admin/saml', label: t('nav.saml'), icon: , permissions: ['SSO_CONFIGURE'] }, { id: 'oauth2', to: '/admin/oauth2', label: t('nav.oauth2'), icon: , permissions: ['SSO_CONFIGURE'] }, + { id: 'scim', to: '/admin/scim', label: t('nav.scim'), icon: , permissions: ['SSO_CONFIGURE'] }, { id: 'slack', to: '/admin/slack', label: t('nav.slack'), icon: , permissions: ['NOTIFICATION_CHANNEL_MANAGE'] }, ], }, diff --git a/frontend/src/config/docs.ts b/frontend/src/config/docs.ts index 79fb6b50..71fd87bc 100644 --- a/frontend/src/config/docs.ts +++ b/frontend/src/config/docs.ts @@ -39,6 +39,7 @@ export const DOCS_ANCHOR_PAGES = { 'cfg-langfuse': 'configuration/ai/', 'cfg-oauth': 'configuration/auth/', 'cfg-saml': 'configuration/auth/', + 'cfg-scim': 'configuration/auth/', 'cfg-notification-channels': 'configuration/notifications/', 'cfg-slack': 'configuration/notifications/', 'cfg-smtp': 'configuration/notifications/', diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 57ff9dc8..70d4371c 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -54,6 +54,7 @@ "ai_analyses": "KI-Analysen", "notifications": "Benachrichtigungen", "saml": "SAML / SSO", + "scim": "SCIM-Provisionierung", "oauth2": "OAuth-Anbieter", "languages": "Sprachen", "custom_drivers": "Benutzerdefinierte JDBC-Treiber", @@ -1837,6 +1838,44 @@ "save_button": "Speichern", "error_metadata_url_invalid": "Bitte eine gültige URL eingeben (https://…)" }, + "scim": { + "title": "SCIM-Provisionierung", + "subtitle": "Lassen Sie Ihren Identity Provider Benutzer automatisch anlegen, aktualisieren und deaktivieren sowie Gruppen synchronisieren.", + "load_error": "SCIM-Konfiguration konnte nicht geladen werden", + "save_success": "SCIM-Konfiguration gespeichert", + "save_button": "Speichern", + "section_endpoint": "SCIM-Endpunkt", + "label_base_url": "Basis-URL (im IdP konfigurieren)", + "label_enabled": "Aktiviert", + "section_mapping": "Attributzuordnung", + "label_attr_email": "Quellattribut für E-Mail", + "label_attr_display_name": "Quellattribut für Anzeigename", + "label_default_role": "Standardrolle für provisionierte Benutzer", + "section_tokens": "Bearer-Token", + "tokens_description": "Langlebige Token, mit denen sich Ihr IdP authentifiziert. Der Rohwert wird nur einmal angezeigt.", + "tokens_empty": "Noch keine SCIM-Token", + "tokens_load_error": "SCIM-Token konnten nicht geladen werden", + "token_create": "Token erstellen", + "token_create_title": "SCIM-Token erstellen", + "token_name_label": "Token-Name", + "token_name_placeholder": "z. B. okta-prod", + "token_name_required": "Token-Name ist erforderlich", + "token_name_size": "Token-Name darf höchstens 100 Zeichen lang sein", + "token_issued_title": "SCIM-Token erstellt", + "token_copy_once_warning": "Kopieren Sie dieses Token jetzt — es wird nicht erneut angezeigt.", + "token_raw_label": "Bearer-Token", + "token_revoked": "Token widerrufen", + "token_revoke": "Widerrufen", + "token_revoke_confirm": "Token „{{name}}“ widerrufen? Ihr IdP verliert sofort den Zugriff.", + "token_revoke_aria": "Token {{name}} widerrufen", + "token_column_name": "Name", + "token_column_prefix": "Präfix", + "token_column_created_at": "Erstellt", + "token_column_last_used_at": "Zuletzt verwendet", + "token_column_status": "Status", + "token_status_active": "Aktiv", + "token_status_revoked": "Widerrufen" + }, "languages": { "title": "Sprachen", "subtitle": "Lege fest, welche Sprachen Benutzer wählen können, die Standardsprache für neue Konten und die Sprache, in der die KI antwortet.", @@ -1968,7 +2007,8 @@ }, "source": { "MANUAL": "Manual", - "IDP": "IdP" + "IDP": "IdP", + "SCIM": "SCIM" }, "empty": { "title": "No groups yet", @@ -2191,7 +2231,8 @@ "save": "Passwort aktualisieren", "saved": "Passwort aktualisiert. Andere Sitzungen werden abgemeldet.", "saml_disabled": "Das Passwort wird von deinem SAML-Identitätsanbieter verwaltet.", - "oauth2_disabled": "Das Passwort wird von deinem OAuth-Anbieter verwaltet. Aktualisiere es in den Kontoeinstellungen dieses Anbieters." + "oauth2_disabled": "Das Passwort wird von deinem OAuth-Anbieter verwaltet. Aktualisiere es in den Kontoeinstellungen dieses Anbieters.", + "scim_disabled": "Das Passwort wird von Ihrem Identity Provider über SCIM verwaltet." }, "totp": { "title": "Zwei-Faktor-Authentifizierung", @@ -2202,6 +2243,7 @@ "disable": "2FA deaktivieren", "saml_disabled": "Die Zwei-Faktor-Authentifizierung wird von deinem SAML-Identitätsanbieter verwaltet.", "oauth2_disabled": "Die Zwei-Faktor-Authentifizierung wird von deinem OAuth-Anbieter verwaltet. Aktiviere sie in den Kontoeinstellungen dieses Anbieters.", + "scim_disabled": "Die Zwei-Faktor-Authentifizierung wird von Ihrem Identity Provider verwaltet.", "enroll_title": "Zwei-Faktor-Authentifizierung aktivieren", "step_scan": "QR-Code scannen", "step_scan_help": "Öffne Google Authenticator, 1Password oder eine andere TOTP-App und scanne diesen Code.", @@ -2423,7 +2465,8 @@ "auth_provider": { "LOCAL": "Lokal", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index f4bd1c69..7af81153 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -57,6 +57,7 @@ "ai_analyses": "AI analyses", "notifications": "Notifications", "saml": "SAML / SSO", + "scim": "SCIM Provisioning", "oauth2": "OAuth providers", "languages": "Languages", "custom_drivers": "Custom JDBC drivers", @@ -1422,7 +1423,8 @@ }, "source": { "MANUAL": "Manual", - "IDP": "IdP" + "IDP": "IdP", + "SCIM": "SCIM" }, "empty": { "title": "No groups yet", @@ -1894,6 +1896,44 @@ "save_button": "Save", "error_metadata_url_invalid": "Enter a valid URL (https://…)" }, + "scim": { + "title": "SCIM Provisioning", + "subtitle": "Let your identity provider create, update, and deactivate users and sync groups automatically.", + "load_error": "Failed to load SCIM configuration", + "save_success": "SCIM configuration saved", + "save_button": "Save", + "section_endpoint": "SCIM endpoint", + "label_base_url": "Base URL (configure this in your IdP)", + "label_enabled": "Enabled", + "section_mapping": "Attribute mapping", + "label_attr_email": "Email source attribute", + "label_attr_display_name": "Display-name source attribute", + "label_default_role": "Default role for provisioned users", + "section_tokens": "Bearer tokens", + "tokens_description": "Long-lived tokens your IdP authenticates with. The raw value is shown only once.", + "tokens_empty": "No SCIM tokens yet", + "tokens_load_error": "Failed to load SCIM tokens", + "token_create": "Create token", + "token_create_title": "Create SCIM token", + "token_name_label": "Token name", + "token_name_placeholder": "e.g. okta-prod", + "token_name_required": "Token name is required", + "token_name_size": "Token name must be at most 100 characters", + "token_issued_title": "SCIM token created", + "token_copy_once_warning": "Copy this token now — it will not be shown again.", + "token_raw_label": "Bearer token", + "token_revoked": "Token revoked", + "token_revoke": "Revoke", + "token_revoke_confirm": "Revoke token “{{name}}”? Your IdP will immediately lose access.", + "token_revoke_aria": "Revoke token {{name}}", + "token_column_name": "Name", + "token_column_prefix": "Prefix", + "token_column_created_at": "Created", + "token_column_last_used_at": "Last used", + "token_column_status": "Status", + "token_status_active": "Active", + "token_status_revoked": "Revoked" + }, "languages": { "title": "Languages", "subtitle": "Pick which languages users can choose, the default for new accounts, and the language the AI analyzer responds in.", @@ -2191,7 +2231,8 @@ "save": "Update password", "saved": "Password updated. You'll be signed out of other sessions.", "saml_disabled": "Password is managed by your SAML identity provider.", - "oauth2_disabled": "Password is managed by your OAuth provider. Update it in that provider's account settings." + "oauth2_disabled": "Password is managed by your OAuth provider. Update it in that provider's account settings.", + "scim_disabled": "Password is managed by your identity provider over SCIM." }, "totp": { "title": "Two-factor authentication", @@ -2202,6 +2243,7 @@ "disable": "Disable 2FA", "saml_disabled": "Two-factor authentication is managed by your SAML identity provider.", "oauth2_disabled": "Two-factor authentication is managed by your OAuth provider. Enable it in that provider's account settings.", + "scim_disabled": "Two-factor authentication is managed by your identity provider.", "enroll_title": "Enable two-factor authentication", "step_scan": "Scan the QR code", "step_scan_help": "Open Google Authenticator, 1Password, or any TOTP app and scan this code.", @@ -2447,7 +2489,8 @@ "auth_provider": { "LOCAL": "Local", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index ae2affbf..e56d9708 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -54,6 +54,7 @@ "ai_analyses": "Análisis de IA", "notifications": "Notificaciones", "saml": "SAML / SSO", + "scim": "Aprovisionamiento SCIM", "oauth2": "Proveedores de OAuth", "languages": "Idiomas", "custom_drivers": "Controladores JDBC personalizados", @@ -1837,6 +1838,44 @@ "save_button": "Guardar", "error_metadata_url_invalid": "Introduzca una URL válida (https://…)" }, + "scim": { + "title": "Aprovisionamiento SCIM", + "subtitle": "Permita que su proveedor de identidad cree, actualice y desactive usuarios y sincronice grupos automáticamente.", + "load_error": "No se pudo cargar la configuración SCIM", + "save_success": "Configuración SCIM guardada", + "save_button": "Guardar", + "section_endpoint": "Endpoint SCIM", + "label_base_url": "URL base (configúrela en su IdP)", + "label_enabled": "Habilitado", + "section_mapping": "Asignación de atributos", + "label_attr_email": "Atributo de origen del correo", + "label_attr_display_name": "Atributo de origen del nombre para mostrar", + "label_default_role": "Rol predeterminado para usuarios aprovisionados", + "section_tokens": "Tokens Bearer", + "tokens_description": "Tokens de larga duración con los que se autentica su IdP. El valor solo se muestra una vez.", + "tokens_empty": "Aún no hay tokens SCIM", + "tokens_load_error": "No se pudieron cargar los tokens SCIM", + "token_create": "Crear token", + "token_create_title": "Crear token SCIM", + "token_name_label": "Nombre del token", + "token_name_placeholder": "p. ej. okta-prod", + "token_name_required": "El nombre del token es obligatorio", + "token_name_size": "El nombre del token debe tener como máximo 100 caracteres", + "token_issued_title": "Token SCIM creado", + "token_copy_once_warning": "Copie este token ahora — no se volverá a mostrar.", + "token_raw_label": "Token Bearer", + "token_revoked": "Token revocado", + "token_revoke": "Revocar", + "token_revoke_confirm": "¿Revocar el token «{{name}}»? Su IdP perderá el acceso inmediatamente.", + "token_revoke_aria": "Revocar token {{name}}", + "token_column_name": "Nombre", + "token_column_prefix": "Prefijo", + "token_column_created_at": "Creado", + "token_column_last_used_at": "Último uso", + "token_column_status": "Estado", + "token_status_active": "Activo", + "token_status_revoked": "Revocado" + }, "languages": { "title": "Idiomas", "subtitle": "Elige qué idiomas pueden seleccionar los usuarios, el predeterminado para nuevas cuentas y el idioma en el que responde la IA.", @@ -1968,7 +2007,8 @@ }, "source": { "MANUAL": "Manual", - "IDP": "IdP" + "IDP": "IdP", + "SCIM": "SCIM" }, "empty": { "title": "No groups yet", @@ -2191,7 +2231,8 @@ "save": "Actualizar contraseña", "saved": "Contraseña actualizada. Se cerrará la sesión en otros dispositivos.", "saml_disabled": "La contraseña la gestiona tu proveedor de identidad SAML.", - "oauth2_disabled": "La contraseña la gestiona tu proveedor OAuth. Actualízala en la configuración de cuenta de ese proveedor." + "oauth2_disabled": "La contraseña la gestiona tu proveedor OAuth. Actualízala en la configuración de cuenta de ese proveedor.", + "scim_disabled": "La contraseña la gestiona su proveedor de identidad mediante SCIM." }, "totp": { "title": "Autenticación en dos pasos", @@ -2202,6 +2243,7 @@ "disable": "Desactivar 2FA", "saml_disabled": "La autenticación en dos pasos la gestiona tu proveedor de identidad SAML.", "oauth2_disabled": "La autenticación en dos pasos la gestiona tu proveedor OAuth. Actívala en la configuración de cuenta de ese proveedor.", + "scim_disabled": "La autenticación de dos factores la gestiona su proveedor de identidad.", "enroll_title": "Activar la autenticación en dos pasos", "step_scan": "Escanea el código QR", "step_scan_help": "Abre Google Authenticator, 1Password o cualquier aplicación TOTP y escanea este código.", @@ -2423,7 +2465,8 @@ "auth_provider": { "LOCAL": "Local", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index bf9136f7..f9db42e1 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -54,6 +54,7 @@ "ai_analyses": "Analyses IA", "notifications": "Notifications", "saml": "SAML / SSO", + "scim": "Provisionnement SCIM", "oauth2": "Fournisseurs OAuth", "languages": "Langues", "custom_drivers": "Pilotes JDBC personnalisés", @@ -1837,6 +1838,44 @@ "save_button": "Enregistrer", "error_metadata_url_invalid": "Saisissez une URL valide (https://…)" }, + "scim": { + "title": "Provisionnement SCIM", + "subtitle": "Laissez votre fournisseur d'identité créer, mettre à jour et désactiver les utilisateurs et synchroniser les groupes automatiquement.", + "load_error": "Échec du chargement de la configuration SCIM", + "save_success": "Configuration SCIM enregistrée", + "save_button": "Enregistrer", + "section_endpoint": "Point de terminaison SCIM", + "label_base_url": "URL de base (à configurer dans votre IdP)", + "label_enabled": "Activé", + "section_mapping": "Mappage des attributs", + "label_attr_email": "Attribut source de l'e-mail", + "label_attr_display_name": "Attribut source du nom d'affichage", + "label_default_role": "Rôle par défaut des utilisateurs provisionnés", + "section_tokens": "Jetons Bearer", + "tokens_description": "Jetons de longue durée avec lesquels votre IdP s'authentifie. La valeur brute n'est affichée qu'une seule fois.", + "tokens_empty": "Aucun jeton SCIM pour l'instant", + "tokens_load_error": "Échec du chargement des jetons SCIM", + "token_create": "Créer un jeton", + "token_create_title": "Créer un jeton SCIM", + "token_name_label": "Nom du jeton", + "token_name_placeholder": "p. ex. okta-prod", + "token_name_required": "Le nom du jeton est obligatoire", + "token_name_size": "Le nom du jeton ne doit pas dépasser 100 caractères", + "token_issued_title": "Jeton SCIM créé", + "token_copy_once_warning": "Copiez ce jeton maintenant — il ne sera plus affiché.", + "token_raw_label": "Jeton Bearer", + "token_revoked": "Jeton révoqué", + "token_revoke": "Révoquer", + "token_revoke_confirm": "Révoquer le jeton « {{name}} » ? Votre IdP perdra immédiatement l'accès.", + "token_revoke_aria": "Révoquer le jeton {{name}}", + "token_column_name": "Nom", + "token_column_prefix": "Préfixe", + "token_column_created_at": "Créé", + "token_column_last_used_at": "Dernière utilisation", + "token_column_status": "Statut", + "token_status_active": "Actif", + "token_status_revoked": "Révoqué" + }, "languages": { "title": "Langues", "subtitle": "Choisis quelles langues les utilisateurs peuvent sélectionner, la langue par défaut pour les nouveaux comptes et la langue dans laquelle l'IA répond.", @@ -1968,7 +2007,8 @@ }, "source": { "MANUAL": "Manual", - "IDP": "IdP" + "IDP": "IdP", + "SCIM": "SCIM" }, "empty": { "title": "No groups yet", @@ -2191,7 +2231,8 @@ "save": "Mettre à jour le mot de passe", "saved": "Mot de passe mis à jour. Vos autres sessions seront déconnectées.", "saml_disabled": "Votre mot de passe est géré par votre fournisseur d'identité SAML.", - "oauth2_disabled": "Votre mot de passe est géré par votre fournisseur OAuth. Mettez-le à jour dans les paramètres du compte chez ce fournisseur." + "oauth2_disabled": "Votre mot de passe est géré par votre fournisseur OAuth. Mettez-le à jour dans les paramètres du compte chez ce fournisseur.", + "scim_disabled": "Le mot de passe est géré par votre fournisseur d’identité via SCIM." }, "totp": { "title": "Authentification à deux facteurs", @@ -2202,6 +2243,7 @@ "disable": "Désactiver la 2FA", "saml_disabled": "L'authentification à deux facteurs est gérée par votre fournisseur d'identité SAML.", "oauth2_disabled": "L'authentification à deux facteurs est gérée par votre fournisseur OAuth. Activez-la dans les paramètres du compte chez ce fournisseur.", + "scim_disabled": "L’authentification à deux facteurs est gérée par votre fournisseur d’identité.", "enroll_title": "Activer l'authentification à deux facteurs", "step_scan": "Scannez le code QR", "step_scan_help": "Ouvrez Google Authenticator, 1Password ou toute application TOTP et scannez ce code.", @@ -2423,7 +2465,8 @@ "auth_provider": { "LOCAL": "Local", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json index 7f77853f..54797e72 100644 --- a/frontend/src/locales/hy.json +++ b/frontend/src/locales/hy.json @@ -54,6 +54,7 @@ "ai_analyses": "AI վերլուծություններ", "notifications": "Ծանուցումներ", "saml": "SAML / SSO", + "scim": "SCIM տրամադրում", "oauth2": "OAuth մատակարարներ", "languages": "Լեզուներ", "custom_drivers": "Հատուկ JDBC դրայվերներ", @@ -1837,6 +1838,44 @@ "save_button": "Պահպանել", "error_metadata_url_invalid": "Մուտքագրեք վավեր URL (https://…)" }, + "scim": { + "title": "SCIM տրամադրում", + "subtitle": "Թույլ տվեք ձեր ինքնության մատակարարին ավտոմատ ստեղծել, թարմացնել և ապաակտիվացնել օգտատերերին ու համաժամեցնել խմբերը:", + "load_error": "Չհաջողվեց բեռնել SCIM կարգավորումը", + "save_success": "SCIM կարգավորումը պահպանվեց", + "save_button": "Պահպանել", + "section_endpoint": "SCIM վերջնակետ", + "label_base_url": "Հիմնական URL (կարգավորեք ձեր IdP-ում)", + "label_enabled": "Միացված է", + "section_mapping": "Ատրիբուտների համապատասխանեցում", + "label_attr_email": "Էլ. փոստի աղբյուր ատրիբուտ", + "label_attr_display_name": "Ցուցադրվող անվան աղբյուր ատրիբուտ", + "label_default_role": "Տրամադրված օգտատերերի լռելյայն դերը", + "section_tokens": "Bearer թոքեններ", + "tokens_description": "Երկարակյաց թոքեններ, որոնցով նույնականանում է ձեր IdP-ն։ Արժեքը ցուցադրվում է միայն մեկ անգամ:", + "tokens_empty": "Դեռ SCIM թոքեններ չկան", + "tokens_load_error": "Չհաջողվեց բեռնել SCIM թոքենները", + "token_create": "Ստեղծել թոքեն", + "token_create_title": "Ստեղծել SCIM թոքեն", + "token_name_label": "Թոքենի անուն", + "token_name_placeholder": "օր.՝ okta-prod", + "token_name_required": "Թոքենի անունը պարտադիր է", + "token_name_size": "Թոքենի անունը պետք է լինի առավելագույնը 100 նիշ", + "token_issued_title": "SCIM թոքենը ստեղծվեց", + "token_copy_once_warning": "Պատճենեք այս թոքենը հիմա — այն այլևս չի ցուցադրվի:", + "token_raw_label": "Bearer թոքեն", + "token_revoked": "Թոքենը չեղարկվեց", + "token_revoke": "Չեղարկել", + "token_revoke_confirm": "Չեղարկե՞լ «{{name}}» թոքենը։ Ձեր IdP-ն անմիջապես կկորցնի մուտքը:", + "token_revoke_aria": "Չեղարկել {{name}} թոքենը", + "token_column_name": "Անուն", + "token_column_prefix": "Նախածանց", + "token_column_created_at": "Ստեղծվել է", + "token_column_last_used_at": "Վերջին օգտագործումը", + "token_column_status": "Կարգավիճակ", + "token_status_active": "Ակտիվ", + "token_status_revoked": "Չեղարկված" + }, "languages": { "title": "Լեզուներ", "subtitle": "Ընտրեք, թե որ լեզուները կարող են ընտրել օգտատերերը, կանխադրված լեզուն նոր հաշիվների համար և լեզուն, որով AI-ն պատասխանում է։", @@ -1968,7 +2007,8 @@ }, "source": { "MANUAL": "Manual", - "IDP": "IdP" + "IDP": "IdP", + "SCIM": "SCIM" }, "empty": { "title": "No groups yet", @@ -2191,7 +2231,8 @@ "save": "Թարմացնել գաղտնաբառը", "saved": "Գաղտնաբառը թարմացված է։ Դուք դուրս կբերվեք մյուս սեսիաներից։", "saml_disabled": "Գաղտնաբառը կառավարվում է ձեր SAML ինքնության մատակարարի կողմից։", - "oauth2_disabled": "Գաղտնաբառը կառավարվում է ձեր OAuth մատակարարի կողմից։ Թարմացրեք այն այդ մատակարարի հաշվի կարգավորումներում։" + "oauth2_disabled": "Գաղտնաբառը կառավարվում է ձեր OAuth մատակարարի կողմից։ Թարմացրեք այն այդ մատակարարի հաշվի կարգավորումներում։", + "scim_disabled": "Գաղտնաբառը կառավարվում է ձեր ինքնության մատակարարի կողմից SCIM-ի միջոցով:" }, "totp": { "title": "Երկգործոնի հաստատում", @@ -2202,6 +2243,7 @@ "disable": "Անջատել 2FA-ն", "saml_disabled": "Երկգործոնի հաստատումը կառավարվում է ձեր SAML ինքնության մատակարարի կողմից։", "oauth2_disabled": "Երկգործոնի հաստատումը կառավարվում է ձեր OAuth մատակարարի կողմից։ Միացրեք այն այդ մատակարարի հաշվի կարգավորումներում։", + "scim_disabled": "Երկգործոն նույնականացումը կառավարվում է ձեր ինքնության մատակարարի կողմից:", "enroll_title": "Միացնել երկգործոնի հաստատումը", "step_scan": "Սկանավորեք QR կոդը", "step_scan_help": "Բացեք Google Authenticator, 1Password կամ ցանկացած TOTP հավելված և սկանավորեք այս կոդը։", @@ -2423,7 +2465,8 @@ "auth_provider": { "LOCAL": "Տեղական", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 34652ad2..4adf699e 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -54,6 +54,7 @@ "ai_analyses": "Анализы ИИ", "notifications": "Уведомления", "saml": "SAML / SSO", + "scim": "SCIM-провижининг", "oauth2": "Провайдеры OAuth", "languages": "Языки", "custom_drivers": "Пользовательские JDBC-драйверы", @@ -1837,6 +1838,44 @@ "save_button": "Сохранить", "error_metadata_url_invalid": "Введите корректный URL (https://…)" }, + "scim": { + "title": "SCIM-провижининг", + "subtitle": "Позвольте вашему провайдеру идентификации автоматически создавать, обновлять и деактивировать пользователей и синхронизировать группы.", + "load_error": "Не удалось загрузить конфигурацию SCIM", + "save_success": "Конфигурация SCIM сохранена", + "save_button": "Сохранить", + "section_endpoint": "Конечная точка SCIM", + "label_base_url": "Базовый URL (настройте в вашем IdP)", + "label_enabled": "Включено", + "section_mapping": "Сопоставление атрибутов", + "label_attr_email": "Атрибут-источник email", + "label_attr_display_name": "Атрибут-источник отображаемого имени", + "label_default_role": "Роль по умолчанию для созданных пользователей", + "section_tokens": "Bearer-токены", + "tokens_description": "Долгоживущие токены, которыми аутентифицируется ваш IdP. Значение показывается только один раз.", + "tokens_empty": "SCIM-токенов пока нет", + "tokens_load_error": "Не удалось загрузить SCIM-токены", + "token_create": "Создать токен", + "token_create_title": "Создать SCIM-токен", + "token_name_label": "Имя токена", + "token_name_placeholder": "напр. okta-prod", + "token_name_required": "Имя токена обязательно", + "token_name_size": "Имя токена должно содержать не более 100 символов", + "token_issued_title": "SCIM-токен создан", + "token_copy_once_warning": "Скопируйте этот токен сейчас — он больше не будет показан.", + "token_raw_label": "Bearer-токен", + "token_revoked": "Токен отозван", + "token_revoke": "Отозвать", + "token_revoke_confirm": "Отозвать токен «{{name}}»? Ваш IdP немедленно потеряет доступ.", + "token_revoke_aria": "Отозвать токен {{name}}", + "token_column_name": "Имя", + "token_column_prefix": "Префикс", + "token_column_created_at": "Создан", + "token_column_last_used_at": "Последнее использование", + "token_column_status": "Статус", + "token_status_active": "Активен", + "token_status_revoked": "Отозван" + }, "languages": { "title": "Языки", "subtitle": "Выберите языки для пользователей, язык по умолчанию для новых учётных записей и язык, на котором отвечает ИИ.", @@ -1968,7 +2007,8 @@ }, "source": { "MANUAL": "Manual", - "IDP": "IdP" + "IDP": "IdP", + "SCIM": "SCIM" }, "empty": { "title": "No groups yet", @@ -2191,7 +2231,8 @@ "save": "Обновить пароль", "saved": "Пароль обновлён. Другие сеансы будут завершены.", "saml_disabled": "Пароль управляется вашим поставщиком SAML.", - "oauth2_disabled": "Пароль управляется вашим провайдером OAuth. Обновите его в настройках учётной записи этого провайдера." + "oauth2_disabled": "Пароль управляется вашим провайдером OAuth. Обновите его в настройках учётной записи этого провайдера.", + "scim_disabled": "Пароль управляется вашим провайдером идентификации через SCIM." }, "totp": { "title": "Двухфакторная аутентификация", @@ -2202,6 +2243,7 @@ "disable": "Отключить 2FA", "saml_disabled": "Двухфакторная аутентификация управляется вашим поставщиком SAML.", "oauth2_disabled": "Двухфакторная аутентификация управляется вашим провайдером OAuth. Включите её в настройках учётной записи этого провайдера.", + "scim_disabled": "Двухфакторная аутентификация управляется вашим провайдером идентификации.", "enroll_title": "Включить двухфакторную аутентификацию", "step_scan": "Отсканируйте QR-код", "step_scan_help": "Откройте Google Authenticator, 1Password или любое TOTP-приложение и отсканируйте этот код.", @@ -2423,7 +2465,8 @@ "auth_provider": { "LOCAL": "Локальный", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 0aab656f..7f87b95b 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -54,6 +54,7 @@ "ai_analyses": "AI 分析", "notifications": "通知", "saml": "SAML / SSO", + "scim": "SCIM 预配", "oauth2": "OAuth 服务商", "languages": "语言", "custom_drivers": "自定义 JDBC 驱动", @@ -1837,6 +1838,44 @@ "save_button": "保存", "error_metadata_url_invalid": "请输入有效的 URL(https://…)" }, + "scim": { + "title": "SCIM 预配", + "subtitle": "让您的身份提供商自动创建、更新和停用用户并同步组。", + "load_error": "无法加载 SCIM 配置", + "save_success": "SCIM 配置已保存", + "save_button": "保存", + "section_endpoint": "SCIM 端点", + "label_base_url": "基础 URL(在您的 IdP 中配置)", + "label_enabled": "已启用", + "section_mapping": "属性映射", + "label_attr_email": "邮箱来源属性", + "label_attr_display_name": "显示名称来源属性", + "label_default_role": "预配用户的默认角色", + "section_tokens": "Bearer 令牌", + "tokens_description": "您的 IdP 用于身份验证的长期令牌。原始值仅显示一次。", + "tokens_empty": "暂无 SCIM 令牌", + "tokens_load_error": "无法加载 SCIM 令牌", + "token_create": "创建令牌", + "token_create_title": "创建 SCIM 令牌", + "token_name_label": "令牌名称", + "token_name_placeholder": "例如 okta-prod", + "token_name_required": "令牌名称为必填项", + "token_name_size": "令牌名称最多 100 个字符", + "token_issued_title": "SCIM 令牌已创建", + "token_copy_once_warning": "请立即复制此令牌 — 它不会再次显示。", + "token_raw_label": "Bearer 令牌", + "token_revoked": "令牌已吊销", + "token_revoke": "吊销", + "token_revoke_confirm": "吊销令牌“{{name}}”?您的 IdP 将立即失去访问权限。", + "token_revoke_aria": "吊销令牌 {{name}}", + "token_column_name": "名称", + "token_column_prefix": "前缀", + "token_column_created_at": "创建时间", + "token_column_last_used_at": "最后使用", + "token_column_status": "状态", + "token_status_active": "有效", + "token_status_revoked": "已吊销" + }, "languages": { "title": "语言", "subtitle": "选择用户可以使用的语言、新账户的默认语言以及 AI 响应所用的语言。", @@ -1968,7 +2007,8 @@ }, "source": { "MANUAL": "Manual", - "IDP": "IdP" + "IDP": "IdP", + "SCIM": "SCIM" }, "empty": { "title": "No groups yet", @@ -2191,7 +2231,8 @@ "save": "更新密码", "saved": "密码已更新。您将从其他会话退出。", "saml_disabled": "您的密码由 SAML 身份提供方管理。", - "oauth2_disabled": "您的密码由 OAuth 服务商管理。请在该服务商的账户设置中更新。" + "oauth2_disabled": "您的密码由 OAuth 服务商管理。请在该服务商的账户设置中更新。", + "scim_disabled": "密码由您的身份提供商通过 SCIM 管理。" }, "totp": { "title": "双重身份验证", @@ -2202,6 +2243,7 @@ "disable": "禁用 2FA", "saml_disabled": "双重身份验证由您的 SAML 身份提供方管理。", "oauth2_disabled": "双重身份验证由您的 OAuth 服务商管理。请在该服务商的账户设置中启用。", + "scim_disabled": "双因素认证由您的身份提供商管理。", "enroll_title": "启用双重身份验证", "step_scan": "扫描二维码", "step_scan_help": "打开 Google Authenticator、1Password 或任意 TOTP 应用扫描此二维码。", @@ -2423,7 +2465,8 @@ "auth_provider": { "LOCAL": "本地", "SAML": "SAML", - "OAUTH2": "OAuth 2.0" + "OAUTH2": "OAuth 2.0", + "SCIM": "SCIM" }, "oauth2_provider": { "GOOGLE": "Google", diff --git a/frontend/src/pages/admin/ScimConfigPage.test.tsx b/frontend/src/pages/admin/ScimConfigPage.test.tsx new file mode 100644 index 00000000..d96c694d --- /dev/null +++ b/frontend/src/pages/admin/ScimConfigPage.test.tsx @@ -0,0 +1,181 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { App } from 'antd'; +import type { ReactNode } from 'react'; +import '@/i18n'; +import type { ScimConfig, ScimToken } from '@/types/api'; + +const { + getScimConfigMock, + updateScimConfigMock, + listScimTokensMock, + createScimTokenMock, + revokeScimTokenMock, +} = vi.hoisted(() => ({ + getScimConfigMock: vi.fn(), + updateScimConfigMock: vi.fn(), + listScimTokensMock: vi.fn(), + createScimTokenMock: vi.fn(), + revokeScimTokenMock: vi.fn(), +})); + +vi.mock('@/api/admin', async () => { + const actual = await vi.importActual('@/api/admin'); + return { + ...actual, + getScimConfig: getScimConfigMock, + updateScimConfig: updateScimConfigMock, + listScimTokens: listScimTokensMock, + createScimToken: createScimTokenMock, + revokeScimToken: revokeScimTokenMock, + }; +}); + +const { ScimConfigPage } = await import('./ScimConfigPage'); + +function config(partial: Partial = {}): ScimConfig { + return { + id: 'cfg-1', + organization_id: 'org-1', + enabled: false, + attr_email: 'userName', + attr_display_name: 'displayName', + default_role: 'ANALYST', + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', + ...partial, + }; +} + +function token(partial: Partial = {}): ScimToken { + return { + id: 'tok-1', + name: 'okta-prod', + token_prefix: 'af_scim_AbCd', + created_at: '2026-08-01T00:00:00Z', + last_used_at: null, + revoked_at: null, + ...partial, + }; +} + +function wrap(node: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + + {node} + + + ); +} + +describe('ScimConfigPage', () => { + beforeEach(() => { + getScimConfigMock.mockReset(); + updateScimConfigMock.mockReset(); + listScimTokensMock.mockReset(); + createScimTokenMock.mockReset(); + revokeScimTokenMock.mockReset(); + getScimConfigMock.mockResolvedValue(config()); + listScimTokensMock.mockResolvedValue([]); + }); + + it('renders the config form with values from the server', async () => { + getScimConfigMock.mockResolvedValue(config({ enabled: true, default_role: 'READONLY' })); + + render(wrap()); + + expect(await screen.findByText('SCIM Provisioning')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('switch')).toBeChecked()); + // The IdP-facing base URL is built from the API base, never the SPA origin. + expect(screen.getByText(/\/scim\/v2$/)).toBeInTheDocument(); + expect(screen.queryByText(`${window.location.origin}/scim/v2`)).not.toBeInTheDocument(); + }); + + it('saves the configuration', async () => { + updateScimConfigMock.mockResolvedValue(config({ enabled: true })); + + render(wrap()); + await screen.findByText('SCIM Provisioning'); + + fireEvent.click(screen.getByRole('switch')); + fireEvent.click(screen.getByRole('button', { name: /Save/ })); + + await waitFor(() => + expect(updateScimConfigMock).toHaveBeenCalledWith({ + enabled: true, + attr_email: 'userName', + attr_display_name: 'displayName', + default_role: 'ANALYST', + }), + ); + }); + + it('lists tokens with prefix and status', async () => { + listScimTokensMock.mockResolvedValue([ + token(), + token({ + id: 'tok-2', + name: 'old', + token_prefix: 'af_scim_ZzZz', + revoked_at: '2026-08-02T00:00:00Z', + }), + ]); + + render(wrap()); + + expect(await screen.findByText('okta-prod')).toBeInTheDocument(); + expect(screen.getByText('af_scim_AbCd…')).toBeInTheDocument(); + expect(screen.getByText('Revoked')).toBeInTheDocument(); + }); + + it('creating a token shows the raw value once', async () => { + createScimTokenMock.mockResolvedValue({ + token: token(), + raw_token: 'af_scim_raw_value_shown_once', + }); + + render(wrap()); + await screen.findByText('SCIM Provisioning'); + + fireEvent.click(screen.getByRole('button', { name: 'Create token' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.change(within(dialog).getByLabelText('Token name'), { + target: { value: 'okta-prod' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Create token' })); + // mutationFn receives (variables, context) — assert on the first argument only. + await waitFor(() => + expect(createScimTokenMock.mock.calls[0]?.[0]).toEqual({ name: 'okta-prod' }), + ); + + expect(await screen.findByText('af_scim_raw_value_shown_once')).toBeInTheDocument(); + expect( + screen.getByText('Copy this token now — it will not be shown again.'), + ).toBeInTheDocument(); + }); + + it('revokes a token after confirmation', async () => { + listScimTokensMock.mockResolvedValue([token()]); + revokeScimTokenMock.mockResolvedValue(undefined); + + render(wrap()); + await screen.findByText('okta-prod'); + + fireEvent.click(screen.getByRole('button', { name: /Revoke token okta-prod/ })); + fireEvent.click(await screen.findByRole('button', { name: 'Revoke' })); + + await waitFor(() => expect(revokeScimTokenMock).toHaveBeenCalledWith('tok-1')); + }); + + it('shows the load-error state', async () => { + getScimConfigMock.mockRejectedValue(new Error('boom')); + + render(wrap()); + + expect(await screen.findByText('Failed to load SCIM configuration')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/admin/ScimConfigPage.tsx b/frontend/src/pages/admin/ScimConfigPage.tsx new file mode 100644 index 00000000..884989c1 --- /dev/null +++ b/frontend/src/pages/admin/ScimConfigPage.tsx @@ -0,0 +1,418 @@ +import { useEffect, useState } from 'react'; +import { + Alert, + App, + Button, + Empty, + Form, + Input, + Modal, + Popconfirm, + Select, + Skeleton, + Space, + Switch, + Table, + Typography, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { CheckOutlined } from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { PageHeader } from '@/components/common/PageHeader'; +import { EmptyState } from '@/components/common/EmptyState'; +import { + createScimToken, + getScimConfig, + listScimTokens, + revokeScimToken, + scimConfigKeys, + scimTokenKeys, + updateScimConfig, +} from '@/api/admin'; +import { apiBaseUrl } from '@/api/client'; +import { adminErrorMessage } from '@/utils/apiErrors'; +import { enumOptions, roleLabel } from '@/utils/enumLabels'; +import { showApiError } from '@/utils/showApiError'; +import type { + CreatedScimToken, + Role, + ScimAttrDisplayName, + ScimAttrEmail, + ScimToken, + UpdateScimConfigInput, +} from '@/types/api'; + +const ROLE_VALUES: readonly Role[] = ['ADMIN', 'REVIEWER', 'ANALYST', 'READONLY'] as const; +const ATTR_EMAIL_VALUES: readonly ScimAttrEmail[] = ['userName', 'emails.primary'] as const; +const ATTR_DISPLAY_NAME_VALUES: readonly ScimAttrDisplayName[] = [ + 'displayName', + 'name.formatted', + 'userName', +] as const; + +interface ScimFormValues { + enabled: boolean; + attr_email: ScimAttrEmail; + attr_display_name: ScimAttrDisplayName; + default_role: Role; +} + +interface CreateTokenFormValues { + name: string; +} + +export function ScimConfigPage() { + const { t, i18n } = useTranslation(); + const { message } = App.useApp(); + const queryClient = useQueryClient(); + const [form] = Form.useForm(); + const [tokenForm] = Form.useForm(); + const [createOpen, setCreateOpen] = useState(false); + const [issuedToken, setIssuedToken] = useState(null); + + const cfgQuery = useQuery({ + queryKey: scimConfigKeys.current(), + queryFn: getScimConfig, + }); + + const tokensQuery = useQuery({ + queryKey: scimTokenKeys.list(), + queryFn: listScimTokens, + }); + + useEffect(() => { + if (cfgQuery.data) { + form.setFieldsValue({ + enabled: cfgQuery.data.enabled, + attr_email: cfgQuery.data.attr_email, + attr_display_name: cfgQuery.data.attr_display_name, + default_role: cfgQuery.data.default_role, + }); + } + }, [cfgQuery.data, form]); + + const saveMutation = useMutation({ + mutationFn: (payload: UpdateScimConfigInput) => updateScimConfig(payload), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: scimConfigKeys.all }); + message.success(t('admin.scim.save_success')); + }, + onError: (err) => showApiError(message, err, adminErrorMessage), + }); + + const createTokenMutation = useMutation({ + mutationFn: createScimToken, + onSuccess: (response) => { + setIssuedToken(response); + setCreateOpen(false); + tokenForm.resetFields(); + void queryClient.invalidateQueries({ queryKey: scimTokenKeys.all }); + }, + onError: (err) => showApiError(message, err, adminErrorMessage), + }); + + const revokeTokenMutation = useMutation({ + mutationFn: (id: string) => revokeScimToken(id), + onSuccess: () => { + message.success(t('admin.scim.token_revoked')); + void queryClient.invalidateQueries({ queryKey: scimTokenKeys.all }); + }, + onError: (err) => showApiError(message, err, adminErrorMessage), + }); + + const onFinish = (values: ScimFormValues) => { + saveMutation.mutate({ + enabled: values.enabled, + attr_email: values.attr_email, + attr_display_name: values.attr_display_name, + default_role: values.default_role, + }); + }; + + const dateFormatter = new Intl.DateTimeFormat(i18n.language, { + dateStyle: 'medium', + timeStyle: 'short', + }); + const fmtDate = (value: string | null) => (value ? dateFormatter.format(new Date(value)) : '—'); + + // The IdP calls the BACKEND, not the frontend — always the API base URL here, never + // window.location.origin (wrong host → silent fall-through to the SPA; see the same + // warning in OAuth2ConfigPage.callbackUrlFor). + const baseUrl = `${apiBaseUrl().replace(/\/+$/, '')}/scim/v2`; + + const tokenColumns: ColumnsType = [ + { + title: t('admin.scim.token_column_name'), + dataIndex: 'name', + key: 'name', + render: (name: string) => {name}, + }, + { + title: t('admin.scim.token_column_prefix'), + dataIndex: 'token_prefix', + key: 'token_prefix', + render: (prefix: string) => {prefix}…, + }, + { + title: t('admin.scim.token_column_created_at'), + dataIndex: 'created_at', + key: 'created_at', + render: fmtDate, + }, + { + title: t('admin.scim.token_column_last_used_at'), + dataIndex: 'last_used_at', + key: 'last_used_at', + render: fmtDate, + }, + { + title: t('admin.scim.token_column_status'), + key: 'status', + render: (_, token) => + token.revoked_at ? t('admin.scim.token_status_revoked') : t('admin.scim.token_status_active'), + }, + { + title: '', + key: 'actions', + width: 120, + render: (_, token) => + token.revoked_at ? null : ( + revokeTokenMutation.mutate(token.id)} + > + + + ), + }, + ]; + + if (cfgQuery.isLoading) { + return ( +

+ +
+ ); + } + if (cfgQuery.isError) { + return ( + + ); + } + + return ( +
+ +
+ form={form} layout="vertical" onFinish={onFinish}> +
+ + + {baseUrl} + + + + + +
+ +
+ + + {/* Options are literal SCIM wire attribute paths (RFC 7643), not + translatable enum labels — rendered verbatim on purpose. */} + ({ value, label: value }))} + /> + + + + + + + + setIssuedToken(null)} + footer={[ + , + ]} + destroyOnHidden + > + {issuedToken && ( + + + {t('admin.scim.token_name_label')} + {issuedToken.token.name} + {t('admin.scim.token_raw_label')} + + {issuedToken.raw_token} + + + )} + +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function Grid({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +export default ScimConfigPage; diff --git a/frontend/src/pages/admin/UsersPage.tsx b/frontend/src/pages/admin/UsersPage.tsx index 57515f15..f4e3ac67 100644 --- a/frontend/src/pages/admin/UsersPage.tsx +++ b/frontend/src/pages/admin/UsersPage.tsx @@ -60,7 +60,12 @@ import type { UserInvitation, } from '@/types/api'; -const AUTH_PROVIDER_VALUES: readonly AuthProvider[] = ['LOCAL', 'SAML', 'OAUTH2'] as const; +const AUTH_PROVIDER_VALUES: readonly AuthProvider[] = [ + 'LOCAL', + 'SAML', + 'OAUTH2', + 'SCIM', +] as const; /** Display name of a user's role — custom-role users carry it in role_name (AF-522). */ function roleNameOf(u: Pick): string | null { diff --git a/frontend/src/pages/admin/groups/GroupDetailPage.tsx b/frontend/src/pages/admin/groups/GroupDetailPage.tsx index 32a9d726..cd789957 100644 --- a/frontend/src/pages/admin/groups/GroupDetailPage.tsx +++ b/frontend/src/pages/admin/groups/GroupDetailPage.tsx @@ -33,6 +33,7 @@ import type { GroupMembershipSource, UserGroupMember } from '@/types/api'; const SOURCE_TAG_COLOR: Record = { MANUAL: 'blue', IDP: 'gold', + SCIM: 'purple', }; export function GroupDetailPage() { diff --git a/frontend/src/pages/profile/ProfilePage.tsx b/frontend/src/pages/profile/ProfilePage.tsx index 6ef9c61b..7f77fa58 100644 --- a/frontend/src/pages/profile/ProfilePage.tsx +++ b/frontend/src/pages/profile/ProfilePage.tsx @@ -19,15 +19,21 @@ export function ProfilePage() { }); const isExternal = - profile?.auth_provider === 'SAML' || profile?.auth_provider === 'OAUTH2'; + profile?.auth_provider === 'SAML' || + profile?.auth_provider === 'OAUTH2' || + profile?.auth_provider === 'SCIM'; const externalPasswordMessage = profile?.auth_provider === 'OAUTH2' ? t('profile.password.oauth2_disabled') - : t('profile.password.saml_disabled'); + : profile?.auth_provider === 'SCIM' + ? t('profile.password.scim_disabled') + : t('profile.password.saml_disabled'); const externalTotpMessage = profile?.auth_provider === 'OAUTH2' ? t('profile.totp.oauth2_disabled') - : t('profile.totp.saml_disabled'); + : profile?.auth_provider === 'SCIM' + ? t('profile.totp.scim_disabled') + : t('profile.totp.saml_disabled'); return (
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index cc011271..cbabed11 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,5 +1,5 @@ export type Role = 'READONLY' | 'ANALYST' | 'REVIEWER' | 'ADMIN' | 'AUDITOR'; -export type AuthProvider = 'LOCAL' | 'SAML' | 'OAUTH2'; +export type AuthProvider = 'LOCAL' | 'SAML' | 'OAUTH2' | 'SCIM'; export type OAuth2Provider = | 'GOOGLE' | 'GITHUB' @@ -458,6 +458,45 @@ export interface UpdateSamlConfigInput { active?: boolean; } +export type ScimAttrEmail = 'userName' | 'emails.primary'; +export type ScimAttrDisplayName = 'displayName' | 'name.formatted' | 'userName'; + +export interface ScimConfig { + id: string | null; + organization_id: string; + enabled: boolean; + attr_email: ScimAttrEmail; + attr_display_name: ScimAttrDisplayName; + default_role: Role; + created_at: string | null; + updated_at: string | null; +} + +export interface UpdateScimConfigInput { + enabled?: boolean; + attr_email?: ScimAttrEmail; + attr_display_name?: ScimAttrDisplayName; + default_role?: Role; +} + +export interface ScimToken { + id: string; + name: string; + token_prefix: string; + created_at: string; + last_used_at: string | null; + revoked_at: string | null; +} + +export interface CreateScimTokenInput { + name: string; +} + +export interface CreatedScimToken { + token: ScimToken; + raw_token: string; +} + export interface LangfuseConfig { id: string | null; organization_id: string; @@ -2123,7 +2162,7 @@ export interface ReplicaHealth { export type DatasourceHealthPage = PageEnvelope; // ── User groups (AF-353) ───────────────────────────────────────────────────── -export type GroupMembershipSource = 'MANUAL' | 'IDP'; +export type GroupMembershipSource = 'MANUAL' | 'IDP' | 'SCIM'; export interface UserGroup { id: string; diff --git a/frontend/src/utils/__tests__/enumLabels.test.ts b/frontend/src/utils/__tests__/enumLabels.test.ts index dd183eb4..5df0e89c 100644 --- a/frontend/src/utils/__tests__/enumLabels.test.ts +++ b/frontend/src/utils/__tests__/enumLabels.test.ts @@ -103,7 +103,7 @@ describe('enumLabels (English text round-trip)', () => { const ssl: SslMode[] = ['DISABLE', 'REQUIRE', 'VERIFY_CA', 'VERIFY_FULL']; const channels: ChannelType[] = ['EMAIL', 'SLACK', 'WEBHOOK', 'DISCORD', 'TELEGRAM', 'MS_TEAMS']; const ai: AiProvider[] = ['OPENAI', 'ANTHROPIC', 'OLLAMA', 'OPENAI_COMPATIBLE', 'HUGGING_FACE']; - const auth: AuthProvider[] = ['LOCAL', 'SAML', 'OAUTH2']; + const auth: AuthProvider[] = ['LOCAL', 'SAML', 'OAUTH2', 'SCIM']; const oauth2: OAuth2Provider[] = ['GOOGLE', 'GITHUB', 'MICROSOFT', 'GITLAB', 'OIDC']; const invites: InvitationStatus[] = ['PENDING', 'ACCEPTED', 'REVOKED', 'EXPIRED']; const maskingStrategies: MaskingStrategy[] = [...MASKING_STRATEGIES]; diff --git a/website/README.md b/website/README.md index 0d36f7d9..7360e3b0 100644 --- a/website/README.md +++ b/website/README.md @@ -39,6 +39,7 @@ the right. | [`backend/pom.xml`](../backend/pom.xml), [`frontend/package.json`](../frontend/package.json) | Architecture callouts, From-source toolchain versions in Install tab | | (no upstream — copy lives in the website) | System requirements panel sizing tiers (Evaluation / Production) | | [`docs/07-security.md`](../docs/07-security.md) | "Workforce-ready auth" feature tile | +| [`docs/07-security.md`](../docs/07-security.md) "SCIM 2.0 provisioning" | SCIM operator guide (`#cfg-scim`) in [`docs/configuration/auth/index.html`](docs/configuration/auth/index.html) + the SCIM blurb in the "Workforce-ready auth" feature tile | | [`docs/08-notifications.md`](../docs/08-notifications.md), [`docs/05-backend.md`](../docs/05-backend.md) "JIT time-bound access requests" + [`docs/07-security.md`](../docs/07-security.md) | "Configurable review workflows" feature tile (incl. JIT access-request blurb) | | [`docs/08-notifications.md`](../docs/08-notifications.md) "ServiceNow" / "Jira" / "Ticketing inbound webhooks & bi-directional sync" (AF-453), [`docs/03-data-model.md`](../docs/03-data-model.md) `query_tickets`, [`docs/04-api-spec.md`](../docs/04-api-spec.md) "Ticketing Integration Endpoints", [`docs/09-deployment.md`](../docs/09-deployment.md) `ACCESSFLOW_NOTIFICATIONS_TICKETING_SIGNATURE_TOLERANCE` | ServiceNow & Jira mentions in the "Configurable review workflows" tile blurb/tags + "Notify on every channel" panel + integrations docs card + "ServiceNow & Jira ticketing" item in the Planned roadmap group (homepage) + ServiceNow / Jira bullets, bi-directional-sync step, and encrypted-fields list under "Notification channels" in [`docs/index.html`](docs/index.html) | | [`docs/05-backend.md`](../docs/05-backend.md) "JIT time-bound access requests", [`docs/07-security.md`](../docs/07-security.md) JIT section, [`docs/09-deployment.md`](../docs/09-deployment.md) `ACCESSFLOW_ACCESS_*` env vars | "Just-in-time (JIT) access requests" subsection (`#cfg-access-requests`) + RBAC rows under "User roles & RBAC" in [`docs/index.html`](docs/index.html) | diff --git a/website/app.js b/website/app.js index 8819b99d..e87d79cd 100644 --- a/website/app.js +++ b/website/app.js @@ -206,6 +206,7 @@ 'compliance-reports': '/docs/configuration/audit-compliance/', 'cfg-oauth': '/docs/configuration/auth/', 'cfg-saml': '/docs/configuration/auth/', + 'cfg-scim': '/docs/configuration/auth/', 'cfg-api-connectors': '/docs/configuration/connectors/', 'cfg-connectors': '/docs/configuration/connectors/', 'cfg-data-classifications': '/docs/configuration/datasources/', diff --git a/website/docs/configuration/auth/index.html b/website/docs/configuration/auth/index.html index b2759704..0f0cb676 100644 --- a/website/docs/configuration/auth/index.html +++ b/website/docs/configuration/auth/index.html @@ -62,7 +62,7 @@ "inLanguage": "en", "articleSection": "Configuration", "datePublished": "2026-04-01", - "dateModified": "2026-08-03", + "dateModified": "2026-08-13", "url": "https://accessflow.bablsoft.com/docs/configuration/auth/", "mainEntityOfPage": "https://accessflow.bablsoft.com/docs/configuration/auth/", "image": "https://accessflow.bablsoft.com/og-image.png", @@ -216,6 +216,7 @@

On this page

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

Authentication & SSO.

-

Last updated

+

Last updated

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

SAML 2.0 SSO

ACCESSFLOW_SAML_SP_SIGNING_CERT_PEM.

+ +
+ +
+

SCIM 2.0 provisioning

+

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

+

+ Configure it. Open /admin/scim: +

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

Okta setup.

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

Microsoft Entra ID setup.

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

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

+

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

+

Troubleshooting.

+
    +
  • 401 from the IdP's connection test — the token was revoked, SCIM is not + enabled on /admin/scim, or the organization is disabled.
  • +
  • 409 on user create — a user with that email already exists (emails are + globally unique). The IdP links to the existing user via its + userName eq lookup instead.
  • +
  • 403 on user create — the organization's user quota is exhausted.
  • +
+
diff --git a/website/index.html b/website/index.html index e6f95026..a9e5da50 100644 --- a/website/index.html +++ b/website/index.html @@ -65,7 +65,7 @@ "operatingSystem": "Linux, macOS, Windows (Docker)", "softwareVersion": "2.1.1", "datePublished": "2026-04-01", - "dateModified": "2026-08-12", + "dateModified": "2026-08-13", "license": "https://www.apache.org/licenses/LICENSE-2.0", "image": "https://accessflow.bablsoft.com/og-image.png", "isAccessibleForFree": true, @@ -77,7 +77,7 @@ "Full query proxy for SQL, NoSQL and cloud data-warehouse engines", "AI query risk analysis with configurable providers", "Configurable multi-stage review and approval workflows", - "Workforce-ready auth — JWT, SAML 2.0, OAuth 2.0 / OIDC, TOTP", + "Workforce-ready auth — JWT, SAML 2.0, OAuth 2.0 / OIDC, SCIM 2.0 provisioning, TOTP", "Tamper-evident audit log and signed compliance reports", "Personalized dashboard and weekly digest", "Cloud-native deploy via Docker Compose and Helm", @@ -125,7 +125,7 @@ "primaryImageOfPage": "https://accessflow.bablsoft.com/og-image.png", "inLanguage": "en", "datePublished": "2026-04-01", - "dateModified": "2026-08-12" + "dateModified": "2026-08-13" } ] } @@ -397,8 +397,8 @@

Configurable review workflows

Workforce-ready auth

-

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

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

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

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

Review & access

Auth & audit

    -
  • JWT · SAML · OAuth/OIDC · TOTP · RBAC with custom roles
  • +
  • JWT · SAML · OAuth/OIDC · SCIM provisioning · TOTP · RBAC with custom roles
  • HMAC-chained audit log + CSV export
diff --git a/website/sitemap.xml b/website/sitemap.xml index 1791abec..a40959d8 100644 --- a/website/sitemap.xml +++ b/website/sitemap.xml @@ -2,7 +2,7 @@ https://accessflow.bablsoft.com/ - 2026-08-12 + 2026-08-13 weekly 1.0 @@ -50,7 +50,7 @@ https://accessflow.bablsoft.com/docs/configuration/auth/ - 2026-08-03 + 2026-08-13 weekly 0.7