diff --git a/README.md b/README.md index fd7fedd..c926bed 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ coordinate is the reactor parent (a `pom`, no classes) — depend on one of the **À la carte:** import the BOM once, then declare `spring-services-core` plus only the feature modules you need (`spring-services-slack`, `spring-services-mcp`, `spring-services-email`, -`spring-services-search`, `spring-services-dbbackup`) without versions: +`spring-services-search`, `spring-services-dbbackup`, `spring-services-scim`) without versions: ```xml @@ -330,6 +330,7 @@ spring-services/ — reactor parent (packaging=pom) ├── spring-services-email — SMTP email (→ spring-boot-starter-mail) ├── spring-services-search — Meilisearch full-text search (RestClient, no extra dep) ├── spring-services-dbbackup — db-backup sidecar client (RestClient, no extra dep) +├── spring-services-scim — SCIM 2.0 Users provider (opt-in via openelements.scim.token) ├── spring-services-all — everything bundle (depends on all modules; no config of its own) └── spring-services-bom — bill of materials for lockstep versioning ``` diff --git a/docs/releases/upgrade-to-1.4.md b/docs/releases/upgrade-to-1.4.md new file mode 100644 index 0000000..89acf7e --- /dev/null +++ b/docs/releases/upgrade-to-1.4.md @@ -0,0 +1,85 @@ +# Upgrade prompt: enabling the SCIM 2.0 Users provider (spec 015) + +`spring-services` 1.4.0 adds an **optional** SCIM 2.0 Users service-provider module, +`spring-services-scim`. It lets an external identity provider (Authentik, or any RFC 7644 client) +push-provision users into the library's existing `UserEntity` over an isolated, bearer-token-secured +`/scim/v2/**` surface. + +The feature is **off by default**: unless `openelements.scim.token` is configured, the module ships +nothing (no filter chain, no endpoints, no beans) and there is nothing to migrate. This guide applies +only to consumers who want to turn SCIM on. + +> This is not a Java-API break. It adds two additive columns to the `users` table and a reserved +> service-principal row; both are backward-compatible. As with every prior spec the library ships the +> migration SQL below — you apply it through your own Flyway/Liquibase timeline (spec 013 contract). + +This file is a self-contained prompt for an agent (Claude Code, etc.) to run inside a consumer repo. + +--- + +## Prompt + +You are working inside a Spring Boot service that depends on `spring-services` and wants to enable +SCIM user provisioning. + +### 1. Depend on the SCIM module + +If you use `spring-services-all`, the SCIM module is already on the classpath — skip to step 2. +Otherwise add it (version managed by `spring-services-bom`): + +```xml + + com.open-elements + spring-services-scim + +``` + +### 2. Apply the database migration + +Add a new versioned migration to your own Flyway/Liquibase timeline: + +```sql +-- Vx__scim_users_provider.sql +ALTER TABLE oe_spring_services.users ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE oe_spring_services.users ADD COLUMN deleted_at TIMESTAMPTZ; + +-- Reserved SCIM service principal (fixed UUID, inactive) — the audit actor for SCIM writes. +INSERT INTO oe_spring_services.users (id, user_name, name, active, deleted, created_at, updated_at) +VALUES ('00000000-0000-0000-0000-0000000000cf', 'scim', 'SCIM Provisioning', FALSE, FALSE, + now(), now()) +ON CONFLICT (id) DO NOTHING; +``` + +(The library also creates the service-principal row at startup if it is missing, mirroring the System +user; the `INSERT` above makes it explicit for environments that run with `ddl-auto=validate/none`.) + +### 3. Configure the SCIM token + +Supply the static shared-secret bearer token your IdP will send on every SCIM request. It is a +high-value secret — provide it from a secret manager, never in plaintext config, and never log it: + +```properties +openelements.scim.token=${SCIM_TOKEN} +``` + +Point your IdP's SCIM provider at `https:///scim/v2` with the same token as its bearer +credential. Rotation = change this property and the IdP token together; there is no server-side state. + +### What you get + +- `POST/GET/PUT/DELETE /scim/v2/Users` mapped onto `UserEntity` (correlated with OIDC JIT-login by + `externalId`; SCIM never writes `sub`). `POST` of an existing `externalId`/`userName` returns + `409 uniqueness`; the IdP recovers via filter + `PUT`. +- Discovery endpoints (`ServiceProviderConfig`, `ResourceTypes`, `Schemas`). +- A dedicated `/scim/v2/**` security chain, fully isolated from the JWT and API-key chains — the SCIM + token cannot be used against `/api/**` and vice versa. + +### Guard rails / Don't do this + +- `DELETE` is a **soft delete** (`active=false`, `deleted=true`, `deleted_at=now`) — it does **not** + scrub PII. Foreign keys from audit-log and comments stay intact; GDPR erasure is a separate, + deferred module. Do not assume SCIM `DELETE` satisfies a data-subject erasure request on its own. +- Groups are **not** implemented in this slice: `GET /scim/v2/Groups` returns an empty list and every + group write returns `501 Not Implemented`. Do not enable group provisioning in your IdP yet. +- User `PATCH` is not implemented (`501`); Authentik updates via `PUT` full-replace. +- Do not point your **application's own** entities at the reserved SCIM principal UUID. diff --git a/docs/specs/015-scim-users-provider/steps.md b/docs/specs/015-scim-users-provider/steps.md new file mode 100644 index 0000000..f8b3a9a --- /dev/null +++ b/docs/specs/015-scim-users-provider/steps.md @@ -0,0 +1,155 @@ +# Implementation Steps: SCIM 2.0 Users Provider + +Ordered, atomic steps. The build stays green after each. The feature ships as a new optional module +`spring-services-scim` (spec-014 feature-module pattern) plus two additive columns on core's +`UserEntity`. Reference: `design.md` (Parts A–E), `behaviors.md` (~40 scenarios). + +--- + +## Step 1: Core — soft-delete columns on `UserEntity` + +- [ ] Add `deleted` (`boolean`, not null, default false) and `deletedAt` (`Instant`, nullable) to + `spring-services-core` `UserEntity`, with getters/setters + Javadoc. +- [ ] Confirm the schema-guard/entity conventions still hold; no change to `UserDto` or existing REST. + +**Acceptance:** core compiles; `./mvnw -o -Pfull-build -pl spring-services-core verify` green (incl. +schema-guard test + 0 javadoc warnings). + +**Behaviors:** DELETE soft-marks; DELETE distinguishes deletion from deactivation (data model). + +--- + +## Step 2: Scaffold `spring-services-scim` module + +- [ ] New module `spring-services-scim` (parent = reactor; depends on `spring-services-core`); add to + root ``, to `spring-services-bom`, and to `spring-services-all`. +- [ ] `ScimAutoConfiguration` (`@AutoConfiguration`, `@ConditionalOnProperty("openelements.scim.token")`) + + `META-INF/spring/...AutoConfiguration.imports`, in a package outside any `@ComponentScan` root. +- [ ] `ScimProperties` (`@ConfigurationProperties("openelements.scim")`, `token`). + +**Acceptance:** reactor builds all modules; module inert (no beans) without the token. + +**Behaviors:** Module is inert without a configured token. + +--- + +## Step 3: SCIM model (hand-rolled Jackson records) + +- [ ] `ScimUser` (+ `ScimName`, `ScimEmail`, `ScimMeta`), `ScimListResponse`, `ScimError`, + `ScimServiceProviderConfig`, `ScimResourceType`, `ScimSchema` records with the observed dialect + + schema URNs. + +**Acceptance:** module compiles; record round-trip unit test (serialize/deserialize a captured payload). + +**Behaviors:** ListResponse envelope; Error envelope; discovery shapes. + +--- + +## Step 4: SCIM security chain + service principal + +- [ ] `ScimServicePrincipal` constants (reserved UUID `…0000cf`, `userName="scim"`, inactive) + + `ScimServicePrincipalInitializer` (`ApplicationRunner`, idempotent insert — mirrors spec-008 System user). +- [ ] `ScimTokenAuthenticationFilter` — extract `Authorization: Bearer`, constant-time compare + (`MessageDigest.isEqual`), set SCIM principal auth on success. +- [ ] `ScimAuthenticationEntryPoint` — `401`, `application/scim+json`, `Error` envelope, + `WWW-Authenticate: Bearer`. +- [ ] `scimFilterChain` bean, `securityMatcher("/scim/v2/**")`, ordered before the default JWT chain, + wired in `ScimAutoConfiguration`. + +**Acceptance:** integration test — valid token authenticates; missing/wrong token → SCIM `401`; SCIM +token rejected on `/api/**`; token compare is constant-time. + +**Behaviors:** all four Authentication scenarios; SCIM chain does not leak into JWT chain. + +--- + +## Step 5: `ScimUserService` (mapping + business rules) + +- [ ] Map `UserEntity` ↔ `ScimUser` (id, externalId, userName, name←displayName/name.formatted, + email←primary, active; never read/write `sub`). +- [ ] `create` → `409 uniqueness` on existing `externalId`/`userName` (single seam per Branch B), else + insert (`sub=NULL`, `deleted=false`). `get` (404 if missing or deleted). `list` (userName eq / + externalId eq, startIndex/count, exclude soft-deleted + System + SCIM principals). `replace` (PUT + full replace; undelete when deleted && active:true; never write sub). `softDelete` (active=false, + deleted=true, deleted_at=now). + +**Acceptance:** service unit/integration tests for create/collision/get/list/filter/pagination/put/ +undelete/soft-delete. + +**Behaviors:** all Users create/read/list/replace/delete scenarios. + +--- + +## Step 6: Controllers + content type + error advice + +- [ ] `ScimUserController` (`produces=application/scim+json`): POST/GET/{id}/GET list/PUT/{id}/DELETE. +- [ ] `ScimDiscoveryController`: ServiceProviderConfig, ResourceTypes, Schemas. +- [ ] `ScimGroupController` stub: `GET /Groups` → empty ListResponse; writes → `501`. +- [ ] `application/scim+json` message converter (`WebMvcConfigurer`); SCIM `@RestControllerAdvice` + mapping validation/uniqueness/not-found/unsupported to the `Error` envelope with `scimType`. + +**Acceptance:** MockMvc integration tests across the endpoints; discovery documents honest. + +**Behaviors:** discovery; 400 invalidValue; 404 shapes; 409 uniqueness; group stub + 501. + +--- + +## Step 7: Audit attribution for SCIM writes + +- [ ] Ensure SCIM create/replace/delete produce audit-log entries attributed to the SCIM principal + (actor resolved from the security context the filter set). DELETE emits a `DELETE` audit action + even though the storage op is an UPDATE of `deleted` (controller/service emits the delete event + explicitly). + +**Acceptance:** integration test asserts audit rows with SCIM actor + correct action for POST/PUT/DELETE. + +**Behaviors:** SCIM writes attributed to SCIM principal; DELETE records DELETE action. + +--- + +## Step 8: JIT-login correlation regression (spec 012 interaction) + +- [ ] Integration test: SCIM-created user (`sub=NULL`) adopted on first JWT login (spec 012 finds by + externalId, writes sub, no new row); JIT-then-SCIM `POST` → 409. Last-writer-wins name/email note. + +**Acceptance:** tests green; no change needed to spec-012 code (verify only). + +**Behaviors:** correlation scenarios; field-ownership LWW note. + +--- + +## Step 9: Docs + +- [ ] `docs/releases/upgrade-to-X.Y.md`: migration SQL (2 columns + reserved SCIM principal insert), + `openelements.scim.token` config, isolation/security notes, group-stub/501 caveat, soft-delete + + deferred GDPR erasure. +- [ ] README: SCIM module + `openelements.scim.token` opt-in. + +**Acceptance:** doc contains migration + config + caveats. + +--- + +## Step 10: Full build + reviews + +- [ ] `./mvnw -o -Pfull-build clean verify -Dmaven.javadoc.failOnWarnings=true` green (all modules, + 0 javadoc warnings). spec-review + quality-review clean. + +**Note (external, non-blocking):** the #21 Authentik-harness verification (409-recovery, group-stub +tolerance) cannot be run here (no Authentik instance). Implemented to the spec's *expected* behavior; +if the harness later deviates, a follow-up spec is opened per the design — this spec is not amended. + +--- + +## Behavior coverage + +| Area | Step | +|---|---| +| Authentication (4) + no-leak | 4 | +| Discovery (2) | 6 | +| Create / 409 / 400 (5) | 5, 6 | +| Read / 404 (3) | 5, 6 | +| List / filter / pagination / exclusions (6) | 5, 6 | +| PUT replace / deactivate / undelete / 404 / no-sub (5) | 5, 6 | +| DELETE soft / FK / distinguish / 404 (4) | 5, 6 | +| Audit attribution (2) | 7 | +| JIT correlation (2) + group stub + LWW | 8, 6 | diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 2c5a823..1b2a901 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -16,6 +16,6 @@ | 012 | 012-scim-foundation-user-model | SCIM foundation user model | backend, security, database | Relax UserEntity.sub, add externalId / userName / active, JIT-login correlation and backfill, AccessDeniedException gate for deactivated users — prepares the data model for the SCIM-Provider spec without introducing SCIM endpoints | — | done | | 013 | 013-dedicated-schema-and-starter | Dedicated schema + starter | backend, database, architecture, build | Move all 7 library tables into a fixed `oe_spring_services` schema (single persistence unit) and ship a real Spring Boot starter that registers the library package additively via AutoConfigurationPackages — zero-config consumption. Migrations stay consumer-managed; the library ships ready-to-apply per-release SQL in the upgrade doc. Includes the app→lib cross-entity reference contract | #25 | done | | 014 | 014-multi-module-restructuring | Multi-module restructuring | build, architecture, infrastructure | Restructure the single artifact into a Maven reactor: `spring-services-core` (all 7 entities, single persistence unit) + optional feature modules (`slack`, `mcp`, `email`, `search`, `dbbackup`) + `spring-services-all` aggregator jar + `spring-services-bom`. Lockstep versioning; per-module `@AutoConfiguration` guarded by `@ConditionalOnClass`; builds on spec 013 (released first). Breaking change: `spring-services` coordinate becomes the reactor parent, consumers migrate to `spring-services-all` | #27 | done | -| 015 | 015-scim-users-provider | SCIM Users provider | backend, security, api, database | SCIM 2.0 service-provider first slice: an isolated `/scim/v2/**` bearer-token filter chain (static `openelements.scim.token`), discovery endpoints (ServiceProviderConfig/ResourceTypes/Schemas), and the Users resource (list/filter, POST, GET, PUT full-replace, soft DELETE) mapped onto spec 012's `UserEntity`. `POST` collision → `409 uniqueness`; DELETE is soft (`deleted`/`deleted_at`); writes audited under a reserved SCIM service principal. Groups+membership and Group→Role mapping deferred to follow-up issues; grounded in real Authentik 2026.5.4 traffic (#21) with a pre-merge verification test plan. | #21 | open | +| 015 | 015-scim-users-provider | SCIM Users provider | backend, security, api, database | SCIM 2.0 service-provider first slice: an isolated `/scim/v2/**` bearer-token filter chain (static `openelements.scim.token`), discovery endpoints (ServiceProviderConfig/ResourceTypes/Schemas), and the Users resource (list/filter, POST, GET, PUT full-replace, soft DELETE) mapped onto spec 012's `UserEntity`. `POST` collision → `409 uniqueness`; DELETE is soft (`deleted`/`deleted_at`); writes audited under a reserved SCIM service principal. Groups+membership and Group→Role mapping deferred to follow-up issues; grounded in real Authentik 2026.5.4 traffic (#21) with a pre-merge verification test plan. | #21 | done | | 016 | 016-tenant-feature-module | Tenant feature module | build, architecture | Extract the `com.openelements.spring.base.tenant` package out of `spring-services-core` into a new optional feature module `spring-services-tenant` (depends on core), consistent with the spec-014 module pattern. Self-activates via `TenantAutoConfiguration`; `TenantConfig` drops its `@ComponentScan` in favour of an explicit `TenantService` `@Bean` to avoid double registration. Core loses its only two tenant references (`FullSpringServiceConfig` `@Import`, a `data/package-info.java` Javadoc link). Added to reactor + `spring-services-all` + BOM. Build-coordinate change only (no API/schema/behaviour change); breaking for à-la-carte core consumers of tenant types → documented in the 1.4.0 upgrade guide. | — | open | diff --git a/pom.xml b/pom.xml index 09294b4..67691cd 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ spring-services-dbbackup spring-services-email spring-services-mcp + spring-services-scim spring-services-all spring-services-bom diff --git a/spring-services-all/pom.xml b/spring-services-all/pom.xml index 6c74ebd..97c8fd7 100644 --- a/spring-services-all/pom.xml +++ b/spring-services-all/pom.xml @@ -47,6 +47,11 @@ spring-services-mcp ${project.version} + + com.open-elements + spring-services-scim + ${project.version} + diff --git a/spring-services-bom/pom.xml b/spring-services-bom/pom.xml index f25f70a..f5c621a 100644 --- a/spring-services-bom/pom.xml +++ b/spring-services-bom/pom.xml @@ -48,6 +48,11 @@ spring-services-mcp ${project.version} + + com.open-elements + spring-services-scim + ${project.version} + com.open-elements spring-services-all diff --git a/spring-services-core/src/main/java/com/openelements/spring/base/services/user/SystemUserInitializer.java b/spring-services-core/src/main/java/com/openelements/spring/base/services/user/SystemUserInitializer.java index a1b9114..c556980 100644 --- a/spring-services-core/src/main/java/com/openelements/spring/base/services/user/SystemUserInitializer.java +++ b/spring-services-core/src/main/java/com/openelements/spring/base/services/user/SystemUserInitializer.java @@ -35,8 +35,8 @@ public class SystemUserInitializer implements ApplicationRunner { private static final String INSERT_SQL = "INSERT INTO " + DbSchema.NAME - + ".users (id, sub, user_name, name, active, updated_at, created_at) " - + "VALUES (?, ?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"; + + ".users (id, sub, user_name, name, active, deleted, updated_at, created_at) " + + "VALUES (?, ?, ?, ?, TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"; private final UserRepository userRepository; diff --git a/spring-services-core/src/main/java/com/openelements/spring/base/services/user/UserEntity.java b/spring-services-core/src/main/java/com/openelements/spring/base/services/user/UserEntity.java index 0835f99..a79c78e 100644 --- a/spring-services-core/src/main/java/com/openelements/spring/base/services/user/UserEntity.java +++ b/spring-services-core/src/main/java/com/openelements/spring/base/services/user/UserEntity.java @@ -56,6 +56,18 @@ public class UserEntity extends AbstractEntity { @Column(name = "active", nullable = false) private boolean active = true; + /** + * Whether the user has been soft-deleted (e.g. by a SCIM {@code DELETE}). A soft-deleted row is + * hidden from the SCIM view but retained so audit-log and comment foreign keys stay intact; it is + * distinct from a merely deactivated ({@code active = false}) user. + */ + @Column(name = "deleted", nullable = false) + private boolean deleted = false; + + /** The moment the user was soft-deleted, or {@code null} if the user is not deleted. */ + @Column(name = "deleted_at") + private Instant deletedAt; + /** The last-update timestamp of the user row. */ @UpdateTimestamp @Column(name = "updated_at", nullable = false) @@ -152,6 +164,44 @@ public void setActive(final boolean active) { this.active = active; } + /** + * Returns whether this user has been soft-deleted. A soft-deleted user is treated as absent by the + * SCIM surface (returned as {@code 404}) and excluded from listings, but the row is retained for + * referential integrity. + * + * @return {@code true} if the user has been soft-deleted, {@code false} otherwise + */ + public boolean isDeleted() { + return deleted; + } + + /** + * Sets whether this user has been soft-deleted. + * + * @param deleted {@code true} to mark the user as soft-deleted, {@code false} to restore it + */ + public void setDeleted(final boolean deleted) { + this.deleted = deleted; + } + + /** + * Returns the moment this user was soft-deleted. + * + * @return the soft-delete timestamp, or {@code null} if the user is not deleted + */ + public Instant getDeletedAt() { + return deletedAt; + } + + /** + * Sets the moment this user was soft-deleted. + * + * @param deletedAt the soft-delete timestamp, or {@code null} if the user is not deleted + */ + public void setDeletedAt(final Instant deletedAt) { + this.deletedAt = deletedAt; + } + /** * Returns the cached display name of the user, kept in sync with the JWT {@code name} claim by * {@link UserService}. diff --git a/spring-services-core/src/test/java/com/openelements/spring/base/services/audit/AuditLogIntegrationTest.java b/spring-services-core/src/test/java/com/openelements/spring/base/services/audit/AuditLogIntegrationTest.java index 3b70734..539ebf1 100644 --- a/spring-services-core/src/test/java/com/openelements/spring/base/services/audit/AuditLogIntegrationTest.java +++ b/spring-services-core/src/test/java/com/openelements/spring/base/services/audit/AuditLogIntegrationTest.java @@ -124,8 +124,8 @@ private void persistSystemUser() { jdbcTemplate.update( "INSERT INTO " + DbSchema.NAME - + ".users (id, sub, user_name, name, active, updated_at, created_at) " - + "VALUES (?, ?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + + ".users (id, sub, user_name, name, active, deleted, updated_at, created_at) " + + "VALUES (?, ?, ?, ?, TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", SystemUser.ID, SystemUser.SUB, SystemUser.SUB, diff --git a/spring-services-scim/pom.xml b/spring-services-scim/pom.xml new file mode 100644 index 0000000..8116764 --- /dev/null +++ b/spring-services-scim/pom.xml @@ -0,0 +1,61 @@ + + 4.0.0 + + + com.open-elements + spring-services + 1.4.0-SNAPSHOT + + + spring-services-scim + + Spring Services SCIM + Optional SCIM 2.0 Users service-provider feature module for spring-services + + + + com.open-elements + spring-services-core + ${project.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.postgresql + postgresql + test + + + + diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/scim/ScimAutoConfiguration.java b/spring-services-scim/src/main/java/com/openelements/spring/base/scim/ScimAutoConfiguration.java new file mode 100644 index 0000000..aeef1e3 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/scim/ScimAutoConfiguration.java @@ -0,0 +1,29 @@ +package com.openelements.spring.base.scim; + +import com.openelements.spring.base.services.scim.ScimConfig; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Import; + +/** + * Auto-configuration for the optional SCIM 2.0 Users provider. + * + *

Registered through this module's + * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}. Unlike + * the other feature modules it is gated on a property rather than a class: + * {@link ConditionalOnProperty @ConditionalOnProperty(openelements.scim.token)} keeps the entire + * feature — the isolated {@code /scim/v2/**} security chain, controllers and beans — inert unless a + * static SCIM token is configured, so a consumer that does not use SCIM ships nothing extra. + * + *

Lives outside the {@code services.scim} package on purpose: {@link ScimConfig} declares a + * {@code @ComponentScan} over its own package, and an auto-configuration co-located there would be + * swept up by that scan and registered twice. + */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "openelements.scim", name = "token") +@Import(ScimConfig.class) +public class ScimAutoConfiguration { + + /** Creates the SCIM auto-configuration; wiring is delegated to {@link ScimConfig}. */ + public ScimAutoConfiguration() {} +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimAuthenticationEntryPoint.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimAuthenticationEntryPoint.java new file mode 100644 index 0000000..68e8eed --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimAuthenticationEntryPoint.java @@ -0,0 +1,48 @@ +package com.openelements.spring.base.services.scim; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openelements.spring.base.services.scim.model.ScimError; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Objects; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +/** + * SCIM-shaped authentication entry point: renders a {@code 401 Unauthorized} with an + * {@code application/scim+json} {@link ScimError} envelope and a {@code WWW-Authenticate: Bearer} + * header when a {@code /scim/v2/**} request is unauthenticated. Keeps the SCIM error surface + * consistent with RFC 7644 rather than leaking the default Spring Security HTML/JSON body. + */ +@Component +public class ScimAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + /** + * Creates the entry point. + * + * @param objectMapper the mapper used to serialise the {@link ScimError} body + */ + public ScimAuthenticationEntryPoint(final ObjectMapper objectMapper) { + this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); + } + + @Override + public void commence( + final HttpServletRequest request, + final HttpServletResponse response, + final AuthenticationException authException) + throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(ScimMediaType.SCIM_JSON); + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer"); + final ScimError error = + ScimError.of(HttpStatus.UNAUTHORIZED.value(), null, "Authentication required"); + objectMapper.writeValue(response.getWriter(), error); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimConfig.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimConfig.java new file mode 100644 index 0000000..71cf5ff --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimConfig.java @@ -0,0 +1,22 @@ +package com.openelements.spring.base.services.scim; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * Feature configuration for the SCIM 2.0 Users provider. + * + *

Component-scans this package so the SCIM security chain, token filter, controllers, service, + * error advice, content-type converter, and the service-principal initializer are all registered, + * and binds {@link ScimProperties}. Activated by {@code ScimAutoConfiguration} only when + * {@code openelements.scim.token} is set; consumers may also {@code @Import} it explicitly. + */ +@Configuration(proxyBeanMethods = false) +@ComponentScan(basePackageClasses = ScimConfig.class) +@EnableConfigurationProperties(ScimProperties.class) +public class ScimConfig { + + /** Creates the SCIM feature configuration; beans are contributed by the component scan. */ + public ScimConfig() {} +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimDiscoveryController.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimDiscoveryController.java new file mode 100644 index 0000000..760c0bd --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimDiscoveryController.java @@ -0,0 +1,100 @@ +package com.openelements.spring.base.services.scim; + +import com.openelements.spring.base.services.scim.model.ScimSchemas; +import java.util.List; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * SCIM 2.0 discovery endpoints (RFC 7644 §4): {@code ServiceProviderConfig}, {@code ResourceTypes} + * and {@code Schemas}. The documents are static and capability-honest — they advertise + * {@code patch:false}, no group writes, and no ETag/bulk/sort, so a well-behaved client never + * attempts an operation this slice does not support. + */ +@RestController +@RequestMapping(path = "/scim/v2", produces = ScimMediaType.SCIM_JSON) +public class ScimDiscoveryController { + + /** Advertised maximum number of results returned from a filtered list. */ + private static final int MAX_RESULTS = 200; + + /** Creates the discovery controller. */ + public ScimDiscoveryController() {} + + /** + * Serves {@code GET /scim/v2/ServiceProviderConfig}. + * + * @return the service-provider capability document + */ + @GetMapping("/ServiceProviderConfig") + public Map serviceProviderConfig() { + return Map.of( + "schemas", List.of(ScimSchemas.SERVICE_PROVIDER_CONFIG), + "documentationUri", "https://github.com/OpenElementsLabs/spring-services", + "patch", Map.of("supported", false), + "bulk", Map.of("supported", false, "maxOperations", 0, "maxPayloadSize", 0), + "filter", Map.of("supported", true, "maxResults", MAX_RESULTS), + "changePassword", Map.of("supported", false), + "sort", Map.of("supported", false), + "etag", Map.of("supported", false), + "authenticationSchemes", + List.of( + Map.of( + "name", "OAuth Bearer Token", + "description", "Authentication via a static OAuth bearer token", + "type", "oauthbearertoken", + "primary", true))); + } + + /** + * Serves {@code GET /scim/v2/ResourceTypes}. + * + * @return the list of supported resource types (only {@code User} is functional in this slice) + */ + @GetMapping("/ResourceTypes") + public List> resourceTypes() { + return List.of( + Map.of( + "schemas", List.of(ScimSchemas.RESOURCE_TYPE), + "id", "User", + "name", "User", + "endpoint", "/Users", + "description", "SCIM core User", + "schema", ScimSchemas.USER)); + } + + /** + * Serves {@code GET /scim/v2/Schemas}. + * + * @return the list of supported schemas (the core {@code User} schema) + */ + @GetMapping("/Schemas") + public List> schemas() { + return List.of( + Map.of( + "id", ScimSchemas.USER, + "name", "User", + "description", "SCIM core User", + "attributes", + List.of( + attribute("userName", "string", true), + attribute("externalId", "string", false), + attribute("displayName", "string", false), + attribute("active", "boolean", false)))); + } + + private static Map attribute( + final String name, final String type, final boolean required) { + return Map.of( + "name", name, + "type", type, + "multiValued", false, + "required", required, + "caseExact", false, + "mutability", "readWrite", + "returned", "default", + "uniqueness", "userName".equals(name) ? "server" : "none"); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimException.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimException.java new file mode 100644 index 0000000..27bfc0d --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimException.java @@ -0,0 +1,47 @@ +package com.openelements.spring.base.services.scim; + +import org.springframework.http.HttpStatus; + +/** + * Base type for SCIM error conditions that the {@code ScimExceptionHandler} maps to an RFC 7644 + * {@code Error} envelope. Carries the HTTP status and the optional SCIM {@code scimType} keyword. + */ +public abstract class ScimException extends RuntimeException { + + /** The HTTP status to return for this error. */ + private final HttpStatus status; + + /** The SCIM {@code scimType} keyword for this error, or {@code null} if none applies. */ + private final String scimType; + + /** + * Creates a SCIM exception. + * + * @param status the HTTP status to return + * @param scimType the SCIM error keyword (e.g. {@code uniqueness}), or {@code null} + * @param detail the human-readable error detail + */ + protected ScimException(final HttpStatus status, final String scimType, final String detail) { + super(detail); + this.status = status; + this.scimType = scimType; + } + + /** + * Returns the HTTP status for this error. + * + * @return the HTTP status + */ + public HttpStatus getStatus() { + return status; + } + + /** + * Returns the SCIM {@code scimType} keyword for this error. + * + * @return the SCIM error keyword, or {@code null} if none applies + */ + public String getScimType() { + return scimType; + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimExceptionHandler.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimExceptionHandler.java new file mode 100644 index 0000000..d643357 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimExceptionHandler.java @@ -0,0 +1,34 @@ +package com.openelements.spring.base.services.scim; + +import com.openelements.spring.base.services.scim.model.ScimError; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Renders every {@link ScimException} thrown by the SCIM controllers as an RFC 7644 {@code Error} + * envelope with the matching HTTP status and {@code application/scim+json} content type. Scoped by + * {@code basePackageClasses} to the SCIM package so it never intercepts other controllers' errors. + */ +@RestControllerAdvice(basePackageClasses = ScimExceptionHandler.class) +public class ScimExceptionHandler { + + /** Creates the SCIM exception handler. */ + public ScimExceptionHandler() {} + + /** + * Maps a {@link ScimException} to a SCIM {@code Error} response. + * + * @param exception the SCIM exception to render + * @return a response carrying the SCIM {@code Error} envelope and the exception's status + */ + @ExceptionHandler(ScimException.class) + public ResponseEntity handleScimException(final ScimException exception) { + return ResponseEntity.status(exception.getStatus()) + .contentType(MediaType.parseMediaType(ScimMediaType.SCIM_JSON)) + .body( + ScimError.of( + exception.getStatus().value(), exception.getScimType(), exception.getMessage())); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimGroupController.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimGroupController.java new file mode 100644 index 0000000..62a2202 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimGroupController.java @@ -0,0 +1,78 @@ +package com.openelements.spring.base.services.scim; + +import com.openelements.spring.base.services.scim.model.ScimListResponse; +import java.util.List; +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.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Graceful Groups stub for the Users-only slice. {@code GET /scim/v2/Groups} returns an empty + * {@code ListResponse} so a Users-only reconcile (e.g. Authentik) does not error, while every group + * write returns {@code 501 Not Implemented}. Full {@code GroupEntity} support is a follow-up issue. + */ +@RestController +@RequestMapping(path = "/scim/v2/Groups", produces = ScimMediaType.SCIM_JSON) +public class ScimGroupController { + + /** Creates the Groups stub controller. */ + public ScimGroupController() {} + + /** + * Serves {@code GET /scim/v2/Groups} as an empty list. + * + * @return an empty SCIM {@code ListResponse} + */ + @GetMapping + public ScimListResponse list() { + return ScimListResponse.of(List.of(), 0, 1); + } + + /** + * Rejects group creation. + * + * @return never returns normally + * @throws ScimNotImplementedException always + */ + @PostMapping + public ScimListResponse create() { + throw new ScimNotImplementedException("Group provisioning is not implemented"); + } + + /** + * Rejects group replacement. + * + * @return never returns normally + * @throws ScimNotImplementedException always + */ + @PutMapping("/**") + public ScimListResponse replace() { + throw new ScimNotImplementedException("Group provisioning is not implemented"); + } + + /** + * Rejects group patching. + * + * @return never returns normally + * @throws ScimNotImplementedException always + */ + @PatchMapping("/**") + public ScimListResponse patch() { + throw new ScimNotImplementedException("Group provisioning is not implemented"); + } + + /** + * Rejects group deletion. + * + * @return never returns normally + * @throws ScimNotImplementedException always + */ + @DeleteMapping("/**") + public ScimListResponse delete() { + throw new ScimNotImplementedException("Group provisioning is not implemented"); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimMediaType.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimMediaType.java new file mode 100644 index 0000000..2e7c92d --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimMediaType.java @@ -0,0 +1,10 @@ +package com.openelements.spring.base.services.scim; + +/** Media-type constant for SCIM 2.0 payloads (RFC 7644 §3.1). */ +public final class ScimMediaType { + + /** The SCIM JSON media type, {@code application/scim+json}. */ + public static final String SCIM_JSON = "application/scim+json"; + + private ScimMediaType() {} +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimNotFoundException.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimNotFoundException.java new file mode 100644 index 0000000..cf6afe1 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimNotFoundException.java @@ -0,0 +1,19 @@ +package com.openelements.spring.base.services.scim; + +import org.springframework.http.HttpStatus; + +/** + * Thrown when a SCIM resource is not found, or has been soft-deleted and is therefore absent from + * the SCIM view. Maps to {@code 404 Not Found}. + */ +public class ScimNotFoundException extends ScimException { + + /** + * Creates the exception. + * + * @param detail the human-readable error detail + */ + public ScimNotFoundException(final String detail) { + super(HttpStatus.NOT_FOUND, null, detail); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimNotImplementedException.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimNotImplementedException.java new file mode 100644 index 0000000..1d0b436 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimNotImplementedException.java @@ -0,0 +1,19 @@ +package com.openelements.spring.base.services.scim; + +import org.springframework.http.HttpStatus; + +/** + * Thrown for SCIM operations this slice deliberately does not implement (group writes, user + * {@code PATCH}). Maps to {@code 501 Not Implemented}. + */ +public class ScimNotImplementedException extends ScimException { + + /** + * Creates the exception. + * + * @param detail the human-readable error detail + */ + public ScimNotImplementedException(final String detail) { + super(HttpStatus.NOT_IMPLEMENTED, null, detail); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimProperties.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimProperties.java new file mode 100644 index 0000000..6667ce6 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimProperties.java @@ -0,0 +1,41 @@ +package com.openelements.spring.base.services.scim; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for the SCIM 2.0 service provider, bound from the + * {@code openelements.scim} prefix. + * + *

The whole SCIM feature is gated on {@link #getToken()} being present: without a configured + * token the module's auto-configuration does not activate, so {@code /scim/v2/**} is not served. + */ +@ConfigurationProperties(prefix = "openelements.scim") +public class ScimProperties { + + /** + * The static shared-secret bearer token the identity provider (e.g. Authentik) sends verbatim on + * every SCIM request. It is compared in constant time and never logged. + */ + private String token; + + /** Creates an empty properties holder populated by Spring from {@code openelements.scim.*}. */ + public ScimProperties() {} + + /** + * Returns the configured static SCIM bearer token. + * + * @return the SCIM bearer token, or {@code null} if SCIM is not configured + */ + public String getToken() { + return token; + } + + /** + * Sets the static SCIM bearer token. + * + * @param token the SCIM bearer token + */ + public void setToken(final String token) { + this.token = token; + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimSecurityConfig.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimSecurityConfig.java new file mode 100644 index 0000000..3bd93fb --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimSecurityConfig.java @@ -0,0 +1,51 @@ +package com.openelements.spring.base.services.scim; + +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.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Registers the isolated SCIM security filter chain. + * + *

Following the spec-006 discipline of strictly separated chains, this adds a third + * {@link SecurityFilterChain} that matches {@code /scim/v2/**} and authenticates it with the static + * SCIM token ({@link ScimTokenAuthenticationFilter}) — never JWT or API-key. It is ordered + * ahead of the default JWT chain (which has no matcher and is the fallback), so SCIM + * requests are claimed here and never reach the JWT resource-server chain. A leaked SCIM token + * therefore cannot be used against {@code /api/**} and vice versa. + */ +@Configuration(proxyBeanMethods = false) +public class ScimSecurityConfig { + + /** Creates the SCIM security configuration. */ + public ScimSecurityConfig() {} + + /** + * Builds the {@code /scim/v2/**} security filter chain. + * + * @param http the HTTP security builder + * @param scimTokenFilter the static-token authentication filter + * @param scimAuthenticationEntryPoint the SCIM-shaped {@code 401} entry point + * @return the configured SCIM filter chain + * @throws Exception if Spring Security fails to build the chain + */ + @Bean + @Order(0) + public SecurityFilterChain scimFilterChain( + final HttpSecurity http, + final ScimTokenAuthenticationFilter scimTokenFilter, + final ScimAuthenticationEntryPoint scimAuthenticationEntryPoint) + throws Exception { + http.securityMatcher("/scim/v2/**") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .addFilterBefore(scimTokenFilter, BearerTokenAuthenticationFilter.class) + .exceptionHandling(e -> e.authenticationEntryPoint(scimAuthenticationEntryPoint)) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .csrf(csrf -> csrf.disable()); + return http.build(); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimServicePrincipal.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimServicePrincipal.java new file mode 100644 index 0000000..97d0600 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimServicePrincipal.java @@ -0,0 +1,27 @@ +package com.openelements.spring.base.services.scim; + +import java.util.UUID; + +/** + * Constants identifying the reserved SCIM service principal — a synthetic, permanently + * inactive {@link com.openelements.spring.base.services.user.UserEntity} row used as the audit actor + * for every write performed over the SCIM API. + * + *

It mirrors the System user of spec 008 but is distinct from it, so that SCIM-driven audit + * entries are unambiguously attributable to "SCIM" and told apart from internal System actions and + * from human users. Its fixed UUID is chosen not to collide with the System user's reserved nil + * UUID. + */ +public final class ScimServicePrincipal { + + /** Reserved fixed UUID of the SCIM service principal (distinct from the System user's nil UUID). */ + public static final UUID ID = UUID.fromString("00000000-0000-0000-0000-0000000000cf"); + + /** Reserved {@code userName} of the SCIM service principal. */ + public static final String USER_NAME = "scim"; + + /** Display name of the SCIM service principal. */ + public static final String NAME = "SCIM Provisioning"; + + private ScimServicePrincipal() {} +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimServicePrincipalInitializer.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimServicePrincipalInitializer.java new file mode 100644 index 0000000..3e3e9ab --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimServicePrincipalInitializer.java @@ -0,0 +1,68 @@ +package com.openelements.spring.base.services.scim; + +import com.openelements.spring.base.data.DbSchema; +import com.openelements.spring.base.services.user.UserRepository; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Ensures the reserved {@link ScimServicePrincipal SCIM service principal} row exists in the + * {@code users} table on startup, so SCIM writes can be attributed to it in the audit log. + * + *

Mirrors {@code SystemUserInitializer} (spec 008): the row carries a pre-assigned reserved UUID, + * so it is inserted through {@link JdbcTemplate} (a direct SQL {@code INSERT}) rather than + * {@code UserRepository#save}, which would treat the pre-assigned id as a detached-entity update. + * The insert is idempotent — guarded by an {@code existsById} check and a + * {@link DataIntegrityViolationException} catch for the concurrent-startup race. The principal is + * created {@code active = false} so it can never authenticate interactively. + */ +@Component +public class ScimServicePrincipalInitializer implements ApplicationRunner { + + private static final Logger LOG = LoggerFactory.getLogger(ScimServicePrincipalInitializer.class); + + private static final String INSERT_SQL = + "INSERT INTO " + + DbSchema.NAME + + ".users (id, user_name, name, active, deleted, updated_at, created_at) " + + "VALUES (?, ?, ?, FALSE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"; + + private final UserRepository userRepository; + + private final JdbcTemplate jdbcTemplate; + + /** + * Creates a new initializer that ensures the SCIM service principal row exists at startup. + * + * @param userRepository the repository used to check for the existing principal row + * @param jdbcTemplate the template used to insert the principal row + */ + public ScimServicePrincipalInitializer( + final UserRepository userRepository, final JdbcTemplate jdbcTemplate) { + this.userRepository = Objects.requireNonNull(userRepository, "userRepository must not be null"); + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + } + + @Override + @Transactional + public void run(final ApplicationArguments args) { + if (userRepository.existsById(ScimServicePrincipal.ID)) { + return; + } + try { + jdbcTemplate.update( + INSERT_SQL, ScimServicePrincipal.ID, ScimServicePrincipal.USER_NAME, + ScimServicePrincipal.NAME); + LOG.info("Created SCIM service principal row (id={})", ScimServicePrincipal.ID); + } catch (final DataIntegrityViolationException e) { + LOG.debug("SCIM service principal row was created concurrently by another instance.", e); + } + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimTokenAuthenticationFilter.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimTokenAuthenticationFilter.java new file mode 100644 index 0000000..e96c8af --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimTokenAuthenticationFilter.java @@ -0,0 +1,79 @@ +package com.openelements.spring.base.services.scim; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; +import java.util.Objects; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Authenticates {@code /scim/v2/**} requests against the single static shared-secret token + * configured in {@link ScimProperties#getToken()}. + * + *

Extracts the {@code Authorization: Bearer } header and compares it to the configured + * token with a constant-time comparison ({@link MessageDigest#isEqual} over UTF-8 + * bytes) to avoid leaking the secret through a timing side channel. On a match it authenticates the + * request as the reserved {@link ScimServicePrincipal SCIM service principal}; on a missing or wrong + * token it leaves the context unauthenticated, so the chain's {@code authenticated()} rule triggers + * the SCIM {@code 401} entry point. + */ +@Component +public class ScimTokenAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final ScimProperties properties; + + private final SecurityContextHolderStrategy securityContextHolderStrategy = + SecurityContextHolder.getContextHolderStrategy(); + + /** + * Creates the filter. + * + * @param properties the SCIM properties holding the configured static token + */ + public ScimTokenAuthenticationFilter(final ScimProperties properties) { + this.properties = Objects.requireNonNull(properties, "properties must not be null"); + } + + @Override + protected void doFilterInternal( + final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain) + throws ServletException, IOException { + final String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header != null && header.startsWith(BEARER_PREFIX)) { + final String presented = header.substring(BEARER_PREFIX.length()); + if (isConfiguredToken(presented)) { + final UsernamePasswordAuthenticationToken authentication = + UsernamePasswordAuthenticationToken.authenticated( + ScimServicePrincipal.USER_NAME, null, List.of()); + final SecurityContext context = securityContextHolderStrategy.createEmptyContext(); + context.setAuthentication(authentication); + securityContextHolderStrategy.setContext(context); + } + } + filterChain.doFilter(request, response); + } + + private boolean isConfiguredToken(final String presented) { + final String configured = properties.getToken(); + if (configured == null || configured.isEmpty()) { + return false; + } + return MessageDigest.isEqual( + configured.getBytes(StandardCharsets.UTF_8), presented.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimUniquenessException.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimUniquenessException.java new file mode 100644 index 0000000..fcc79f5 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimUniquenessException.java @@ -0,0 +1,20 @@ +package com.openelements.spring.base.services.scim; + +import org.springframework.http.HttpStatus; + +/** + * Thrown when a SCIM {@code POST} would violate the uniqueness of {@code externalId} or + * {@code userName}. Maps to {@code 409 Conflict} with {@code scimType: "uniqueness"} (RFC 7644 + * §3.3). + */ +public class ScimUniquenessException extends ScimException { + + /** + * Creates the exception. + * + * @param detail the human-readable error detail + */ + public ScimUniquenessException(final String detail) { + super(HttpStatus.CONFLICT, "uniqueness", detail); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimUserController.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimUserController.java new file mode 100644 index 0000000..32752d4 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimUserController.java @@ -0,0 +1,103 @@ +package com.openelements.spring.base.services.scim; + +import com.openelements.spring.base.services.scim.model.ScimListResponse; +import com.openelements.spring.base.services.scim.model.ScimUser; +import java.net.URI; +import java.util.UUID; +import org.springframework.http.ResponseEntity; +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.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; + +/** + * SCIM 2.0 Users resource endpoints (RFC 7644 §3.2–§3.6) under {@code /scim/v2/Users}, all speaking + * {@code application/scim+json}. Delegates business rules to {@link ScimUserService}; SCIM errors are + * rendered by {@link ScimExceptionHandler}. + */ +@RestController +@RequestMapping(path = "/scim/v2/Users", produces = ScimMediaType.SCIM_JSON) +public class ScimUserController { + + /** Default page size when the client does not supply {@code count} (matches the advertised max). */ + private static final int DEFAULT_COUNT = 200; + + private final ScimUserService scimUserService; + + /** + * Creates the controller. + * + * @param scimUserService the SCIM user service + */ + public ScimUserController(final ScimUserService scimUserService) { + this.scimUserService = scimUserService; + } + + /** + * Creates a user ({@code POST /scim/v2/Users}). + * + * @param request the SCIM user payload + * @return {@code 201 Created} with the created user and a {@code Location} header + */ + @PostMapping(consumes = ScimMediaType.SCIM_JSON) + public ResponseEntity create(@RequestBody final ScimUser request) { + final ScimUser created = scimUserService.create(request); + return ResponseEntity.created(URI.create("/scim/v2/Users/" + created.id())).body(created); + } + + /** + * Returns a user by id ({@code GET /scim/v2/Users/{id}}). + * + * @param id the user id + * @return {@code 200 OK} with the SCIM user + */ + @GetMapping("/{id}") + public ScimUser getById(@PathVariable final UUID id) { + return scimUserService.getById(id); + } + + /** + * Lists / filters users ({@code GET /scim/v2/Users}). + * + * @param filter an optional {@code userName eq}/{@code externalId eq} filter + * @param startIndex the 1-based index of the first result + * @param count the maximum number of results to return + * @return a SCIM {@code ListResponse} + */ + @GetMapping + public ScimListResponse list( + @RequestParam(name = "filter", required = false) final String filter, + @RequestParam(name = "startIndex", defaultValue = "1") final int startIndex, + @RequestParam(name = "count", defaultValue = "" + DEFAULT_COUNT) final int count) { + return scimUserService.list(filter, startIndex, count); + } + + /** + * Full-replace update of a user ({@code PUT /scim/v2/Users/{id}}). + * + * @param id the user id + * @param request the replacement SCIM payload + * @return {@code 200 OK} with the updated user + */ + @PutMapping(path = "/{id}", consumes = ScimMediaType.SCIM_JSON) + public ScimUser replace(@PathVariable final UUID id, @RequestBody final ScimUser request) { + return scimUserService.replace(id, request); + } + + /** + * Soft-deletes a user ({@code DELETE /scim/v2/Users/{id}}). + * + * @param id the user id + * @return {@code 204 No Content} + */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable final UUID id) { + scimUserService.softDelete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimUserService.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimUserService.java new file mode 100644 index 0000000..425caae --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimUserService.java @@ -0,0 +1,265 @@ +package com.openelements.spring.base.services.scim; + +import com.openelements.spring.base.services.audit.AuditAction; +import com.openelements.spring.base.services.audit.AuditLogDataService; +import com.openelements.spring.base.services.scim.model.ScimEmail; +import com.openelements.spring.base.services.scim.model.ScimListResponse; +import com.openelements.spring.base.services.scim.model.ScimMeta; +import com.openelements.spring.base.services.scim.model.ScimName; +import com.openelements.spring.base.services.scim.model.ScimSchemas; +import com.openelements.spring.base.services.scim.model.ScimUser; +import com.openelements.spring.base.services.user.SystemUser; +import com.openelements.spring.base.services.user.UserEntity; +import com.openelements.spring.base.services.user.UserRepository; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Business logic for the SCIM Users resource: maps between the SCIM {@code User} representation and + * the library's {@link UserEntity}, enforces the uniqueness/soft-delete rules from the design, and + * records each write in the audit log attributed to the reserved SCIM service principal. + * + *

SCIM never reads or writes {@code sub}; a SCIM-provisioned row stays {@code sub = NULL} until + * the human first logs in interactively, at which point spec 012's JIT correlation adopts the row. + */ +@Service +public class ScimUserService { + + /** {@code filter} grammar supported by this slice: {@code (userName|externalId) eq "value"}. */ + private static final Pattern EQ_FILTER = + Pattern.compile("\\s*(userName|externalId)\\s+eq\\s+\"([^\"]*)\"\\s*"); + + private static final Set RESERVED = Set.of(SystemUser.ID, ScimServicePrincipal.ID); + + private static final String AUDIT_TYPE = "ScimUser"; + + private final UserRepository userRepository; + + private final AuditLogDataService auditLogDataService; + + /** + * Creates the service. + * + * @param userRepository the repository backing SCIM users + * @param auditLogDataService the audit log used to record SCIM writes + */ + public ScimUserService( + final UserRepository userRepository, final AuditLogDataService auditLogDataService) { + this.userRepository = Objects.requireNonNull(userRepository, "userRepository must not be null"); + this.auditLogDataService = + Objects.requireNonNull(auditLogDataService, "auditLogDataService must not be null"); + } + + /** + * Creates a new user from a SCIM {@code POST} payload. + * + * @param request the SCIM user to create + * @return the created user as a SCIM representation + * @throws ScimValidationException if {@code userName} is missing + * @throws ScimUniquenessException if a row already exists with the same {@code externalId} or + * {@code userName} + */ + @Transactional + public ScimUser create(final ScimUser request) { + final String userName = requireUserName(request); + if (request.externalId() != null + && userRepository.findByExternalId(request.externalId()).isPresent()) { + throw new ScimUniquenessException( + "A user with externalId '" + request.externalId() + "' already exists"); + } + if (userRepository.findByUserName(userName).isPresent()) { + throw new ScimUniquenessException("A user with userName '" + userName + "' already exists"); + } + final UserEntity entity = new UserEntity(); + entity.setUserName(userName); + entity.setExternalId(request.externalId()); + entity.setName(resolveName(request, userName)); + entity.setEmail(resolvePrimaryEmail(request)); + entity.setActive(request.active() == null || request.active()); + entity.setDeleted(false); + // sub is intentionally left NULL — SCIM never writes it. + final UserEntity saved = userRepository.save(entity); + audit(saved, AuditAction.INSERT); + return toScim(saved); + } + + /** + * Returns a single user by id, treating a soft-deleted row as absent. + * + * @param id the user id + * @return the user as a SCIM representation + * @throws ScimNotFoundException if no active (non-deleted) row has that id + */ + @Transactional(readOnly = true) + public ScimUser getById(final UUID id) { + final UserEntity entity = + userRepository + .findById(id) + .filter(u -> !u.isDeleted()) + .orElseThrow(() -> new ScimNotFoundException("User " + id + " not found")); + return toScim(entity); + } + + /** + * Lists users, optionally filtered by {@code userName eq} / {@code externalId eq}, excluding + * soft-deleted rows and the reserved System and SCIM principals, with 1-based pagination. + * + * @param filter the SCIM filter expression, or {@code null}/blank for no filter + * @param startIndex the 1-based index of the first result (values below 1 are treated as 1) + * @param count the maximum number of results to return + * @return a SCIM {@code ListResponse} + * @throws ScimValidationException if a non-empty filter is not a supported {@code eq} expression + */ + @Transactional(readOnly = true) + public ScimListResponse list(final String filter, final int startIndex, final int count) { + final List matches = + applyFilter(filter).stream() + .filter(u -> !u.isDeleted()) + .filter(u -> !RESERVED.contains(u.id())) + .sorted(Comparator.comparing(UserEntity::id)) + .toList(); + final int effectiveStart = Math.max(startIndex, 1); + final int fromIndex = Math.min(effectiveStart - 1, matches.size()); + final int toIndex = Math.min(fromIndex + Math.max(count, 0), matches.size()); + final List page = matches.subList(fromIndex, toIndex).stream().map(this::toScim).toList(); + return ScimListResponse.of(page, matches.size(), effectiveStart); + } + + /** + * Full-replace update of a user's mutable fields (RFC 7644 §3.5.1). A {@code PUT} carrying + * {@code active:true} on a soft-deleted row undeletes it. {@code sub} is never written. + * + * @param id the user id + * @param request the replacement SCIM representation + * @return the updated user as a SCIM representation + * @throws ScimNotFoundException if no row has that id + * @throws ScimValidationException if {@code userName} is missing + */ + @Transactional + public ScimUser replace(final UUID id, final ScimUser request) { + final UserEntity entity = + userRepository + .findById(id) + .orElseThrow(() -> new ScimNotFoundException("User " + id + " not found")); + final String userName = requireUserName(request); + final boolean active = request.active() == null || request.active(); + entity.setUserName(userName); + if (request.externalId() != null) { + entity.setExternalId(request.externalId()); + } + entity.setName(resolveName(request, userName)); + entity.setEmail(resolvePrimaryEmail(request)); + entity.setActive(active); + if (entity.isDeleted() && active) { + entity.setDeleted(false); + entity.setDeletedAt(null); + } + final UserEntity saved = userRepository.save(entity); + audit(saved, AuditAction.UPDATE); + return toScim(saved); + } + + /** + * Soft-deletes a user: sets {@code active = false}, {@code deleted = true}, {@code deleted_at = + * now}. The row is retained for referential integrity; a {@code DELETE} audit action is recorded. + * + * @param id the user id + * @throws ScimNotFoundException if no non-deleted row has that id + */ + @Transactional + public void softDelete(final UUID id) { + final UserEntity entity = + userRepository + .findById(id) + .filter(u -> !u.isDeleted()) + .orElseThrow(() -> new ScimNotFoundException("User " + id + " not found")); + entity.setActive(false); + entity.setDeleted(true); + entity.setDeletedAt(Instant.now()); + final UserEntity saved = userRepository.save(entity); + audit(saved, AuditAction.DELETE); + } + + private List applyFilter(final String filter) { + if (filter == null || filter.isBlank()) { + return userRepository.findAll(); + } + final Matcher matcher = EQ_FILTER.matcher(filter); + if (!matcher.matches()) { + throw new ScimValidationException("Unsupported filter: " + filter); + } + final String attribute = matcher.group(1); + final String value = matcher.group(2); + final Optional hit = + "userName".equals(attribute) + ? userRepository.findByUserName(value) + : userRepository.findByExternalId(value); + return hit.map(List::of).orElseGet(List::of); + } + + private void audit(final UserEntity user, final AuditAction action) { + auditLogDataService.createEntry( + AUDIT_TYPE, + user.id(), + user.getUserName(), + action, + userRepository.getReferenceById(ScimServicePrincipal.ID)); + } + + private static String requireUserName(final ScimUser request) { + final String userName = request.userName(); + if (userName == null || userName.isBlank()) { + throw new ScimValidationException("userName is required"); + } + return userName; + } + + private static String resolveName(final ScimUser request, final String fallback) { + if (request.name() != null && request.name().formatted() != null + && !request.name().formatted().isBlank()) { + return request.name().formatted(); + } + if (request.displayName() != null && !request.displayName().isBlank()) { + return request.displayName(); + } + return fallback; + } + + private static String resolvePrimaryEmail(final ScimUser request) { + if (request.emails() == null || request.emails().isEmpty()) { + return null; + } + return request.emails().stream() + .filter(e -> Boolean.TRUE.equals(e.primary())) + .map(ScimEmail::value) + .findFirst() + .orElseGet(() -> request.emails().get(0).value()); + } + + private ScimUser toScim(final UserEntity entity) { + final String id = entity.id().toString(); + final List emails = + entity.getEmail() == null + ? null + : List.of(new ScimEmail(entity.getEmail(), true, null)); + return new ScimUser( + List.of(ScimSchemas.USER), + id, + entity.getExternalId(), + entity.getUserName(), + new ScimName(entity.getName(), null, null), + entity.getName(), + emails, + entity.isActive(), + new ScimMeta("User", "/scim/v2/Users/" + id, null)); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimValidationException.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimValidationException.java new file mode 100644 index 0000000..1772b03 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/ScimValidationException.java @@ -0,0 +1,19 @@ +package com.openelements.spring.base.services.scim; + +import org.springframework.http.HttpStatus; + +/** + * Thrown when a SCIM request payload is invalid (e.g. missing the required {@code userName}). Maps + * to {@code 400 Bad Request} with {@code scimType: "invalidValue"} (RFC 7644 §3.3). + */ +public class ScimValidationException extends ScimException { + + /** + * Creates the exception. + * + * @param detail the human-readable error detail + */ + public ScimValidationException(final String detail) { + super(HttpStatus.BAD_REQUEST, "invalidValue", detail); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimEmail.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimEmail.java new file mode 100644 index 0000000..c2f96fb --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimEmail.java @@ -0,0 +1,14 @@ +package com.openelements.spring.base.services.scim.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * SCIM {@code emails} multi-valued attribute entry (RFC 7643 §4.1.2). This slice persists only the + * primary entry's {@code value} (mapped to {@code UserEntity.email}). + * + * @param value the email address + * @param primary whether this is the primary email, or {@code null} if unspecified + * @param type the email type (e.g. {@code work}), accepted but not persisted + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ScimEmail(String value, Boolean primary, String type) {} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimError.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimError.java new file mode 100644 index 0000000..224c621 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimError.java @@ -0,0 +1,29 @@ +package com.openelements.spring.base.services.scim.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +/** + * SCIM {@code Error} message (RFC 7644 §3.12) returned for every 4xx/5xx response. + * + * @param schemas the message schema URNs (the Error URN) + * @param status the HTTP status code as a string (RFC requires a string) + * @param scimType the SCIM detail error keyword (e.g. {@code uniqueness}, {@code invalidValue}), or + * {@code null} where the RFC defines none for the status + * @param detail a human-readable error description + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ScimError(List schemas, String status, String scimType, String detail) { + + /** + * Builds an {@code Error} with the correct schema URN. + * + * @param status the HTTP status code + * @param scimType the SCIM error keyword, or {@code null} + * @param detail a human-readable description + * @return a populated {@code Error} message + */ + public static ScimError of(final int status, final String scimType, final String detail) { + return new ScimError(List.of(ScimSchemas.ERROR), Integer.toString(status), scimType, detail); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimListResponse.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimListResponse.java new file mode 100644 index 0000000..339ecee --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimListResponse.java @@ -0,0 +1,37 @@ +package com.openelements.spring.base.services.scim.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * SCIM {@code ListResponse} message (RFC 7644 §3.4.2) wrapping a page of resources. + * + * @param the resource type carried in {@code Resources} + * @param schemas the message schema URNs (the ListResponse URN) + * @param totalResults the total number of matching resources across all pages + * @param startIndex the 1-based index of the first returned resource + * @param itemsPerPage the number of resources in this page + * @param resources the resources in this page (serialised as the capitalised {@code Resources}) + */ +public record ScimListResponse( + List schemas, + int totalResults, + int startIndex, + int itemsPerPage, + @JsonProperty("Resources") List resources) { + + /** + * Builds a {@code ListResponse} with the correct schema URN and derived {@code itemsPerPage}. + * + * @param resources the resources in this page + * @param totalResults the total number of matching resources across all pages + * @param startIndex the 1-based index of the first returned resource + * @param the resource type + * @return a populated {@code ListResponse} + */ + public static ScimListResponse of( + final List resources, final int totalResults, final int startIndex) { + return new ScimListResponse<>( + List.of(ScimSchemas.LIST_RESPONSE), totalResults, startIndex, resources.size(), resources); + } +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimMeta.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimMeta.java new file mode 100644 index 0000000..30b5a9e --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimMeta.java @@ -0,0 +1,13 @@ +package com.openelements.spring.base.services.scim.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * SCIM {@code meta} complex attribute (RFC 7643 §3.1) describing a resource's metadata. + * + * @param resourceType the resource type name (e.g. {@code User}) + * @param location the canonical URI of the resource + * @param version the resource version / ETag, or {@code null} (not supported by this slice) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ScimMeta(String resourceType, String location, String version) {} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimName.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimName.java new file mode 100644 index 0000000..59b49cc --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimName.java @@ -0,0 +1,15 @@ +package com.openelements.spring.base.services.scim.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * SCIM {@code name} complex attribute (RFC 7643 §4.1.1). Only {@code formatted} is persisted by this + * slice (mapped to {@code UserEntity.name}); {@code givenName}/{@code familyName} are accepted on + * input but not stored separately. + * + * @param formatted the full display name + * @param givenName the given (first) name, accepted but not persisted separately + * @param familyName the family (last) name, accepted but not persisted separately + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ScimName(String formatted, String givenName, String familyName) {} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimSchemas.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimSchemas.java new file mode 100644 index 0000000..123c2aa --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimSchemas.java @@ -0,0 +1,23 @@ +package com.openelements.spring.base.services.scim.model; + +/** SCIM 2.0 schema URN constants (RFC 7643 / RFC 7644). */ +public final class ScimSchemas { + + /** Core {@code User} resource schema URN. */ + public static final String USER = "urn:ietf:params:scim:schemas:core:2.0:User"; + + /** {@code ListResponse} message schema URN. */ + public static final String LIST_RESPONSE = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; + + /** {@code Error} message schema URN. */ + public static final String ERROR = "urn:ietf:params:scim:api:messages:2.0:Error"; + + /** {@code ServiceProviderConfig} resource schema URN. */ + public static final String SERVICE_PROVIDER_CONFIG = + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"; + + /** {@code ResourceType} resource schema URN. */ + public static final String RESOURCE_TYPE = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"; + + private ScimSchemas() {} +} diff --git a/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimUser.java b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimUser.java new file mode 100644 index 0000000..a1a11e1 --- /dev/null +++ b/spring-services-scim/src/main/java/com/openelements/spring/base/services/scim/model/ScimUser.java @@ -0,0 +1,32 @@ +package com.openelements.spring.base.services.scim.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; + +/** + * SCIM 2.0 {@code User} resource representation (RFC 7643 §4.1), in the dialect observed from the + * Authentik capture (issue #21). Only the attributes this slice maps to {@code UserEntity} are + * modelled; unknown attributes on input are ignored by the controller's lenient object mapper. + * + * @param schemas the resource schema URNs; defaults to the core User schema when serialised + * @param id the server-assigned resource id (the {@code UserEntity} UUID); {@code null} on create + * requests + * @param externalId the client-assigned stable identifier (Authentik's id); the correlation key + * @param userName the unique business-key handle (required by RFC and by the DB constraint) + * @param name the complex name attribute; its {@code formatted} value maps to the display name + * @param displayName the display name; an alternative source for the persisted name + * @param emails the email addresses; only the primary entry's value is persisted + * @param active whether the user is active; maps to the spec-012 activation gate + * @param meta the resource metadata (resource type + location), or {@code null} on requests + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ScimUser( + List schemas, + String id, + String externalId, + String userName, + ScimName name, + String displayName, + List emails, + Boolean active, + ScimMeta meta) {} diff --git a/spring-services-scim/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-services-scim/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..e9fc3b5 --- /dev/null +++ b/spring-services-scim/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.openelements.spring.base.scim.ScimAutoConfiguration diff --git a/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimAutoConfigurationTest.java b/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimAutoConfigurationTest.java new file mode 100644 index 0000000..58738af --- /dev/null +++ b/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimAutoConfigurationTest.java @@ -0,0 +1,30 @@ +package com.openelements.spring.base.scim; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openelements.spring.base.services.scim.ScimUserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Activation-guard test for {@link ScimAutoConfiguration}: without {@code openelements.scim.token} + * the feature must not register any bean, leaving {@code /scim/v2/**} unmapped (handled by the + * default JWT chain). + */ +@DisplayName("ScimAutoConfiguration — activation guard") +class ScimAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ScimAutoConfiguration.class)); + + @Test + @DisplayName("Stays inert (no SCIM beans) when openelements.scim.token is not set") + void inertWithoutToken() { + contextRunner.run( + context -> + assertThat(context).hasNotFailed().doesNotHaveBean(ScimUserService.class)); + } +} diff --git a/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimTestApp.java b/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimTestApp.java new file mode 100644 index 0000000..a28e5a4 --- /dev/null +++ b/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimTestApp.java @@ -0,0 +1,13 @@ +package com.openelements.spring.base.scim; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration; + +/** + * Minimal boot application for the SCIM integration tests. Relies entirely on auto-configuration: + * the core starter registers the library packages/entities and the SCIM starter activates because + * {@code openelements.scim.token} is set in the test profile. OAuth2 resource-server auto-config is + * excluded (a stub {@code JwtDecoder} is supplied by the test configuration for the core JWT chain). + */ +@SpringBootApplication(exclude = OAuth2ResourceServerAutoConfiguration.class) +public class ScimTestApp {} diff --git a/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimTestConfiguration.java b/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimTestConfiguration.java new file mode 100644 index 0000000..398b7ac --- /dev/null +++ b/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimTestConfiguration.java @@ -0,0 +1,38 @@ +package com.openelements.spring.base.scim; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.testcontainers.containers.PostgreSQLContainer; + +/** Test infrastructure: a PostgreSQL Testcontainer and a stub {@link JwtDecoder} for the JWT chain. */ +@TestConfiguration(proxyBeanMethods = false) +public class ScimTestConfiguration { + + /** + * Provides the PostgreSQL Testcontainer wired to Spring Boot via {@code @ServiceConnection}. + * + * @return the PostgreSQL container + */ + @Bean + @ServiceConnection + public PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>("postgres:16-alpine"); + } + + /** + * Stub {@link JwtDecoder} required by the core JWT security chain; SCIM tests never exercise JWT + * validation. + * + * @return a decoder that rejects any token as invalid (so the JWT chain answers {@code 401}, + * matching how a real decoder treats a non-JWT bearer token) + */ + @Bean + public JwtDecoder testJwtDecoder() { + return token -> { + throw new BadJwtException("JWT validation is disabled in SCIM tests"); + }; + } +} diff --git a/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimUsersIntegrationTest.java b/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimUsersIntegrationTest.java new file mode 100644 index 0000000..3dd4c85 --- /dev/null +++ b/spring-services-scim/src/test/java/com/openelements/spring/base/scim/ScimUsersIntegrationTest.java @@ -0,0 +1,386 @@ +package com.openelements.spring.base.scim; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.openelements.spring.base.services.scim.ScimServicePrincipal; +import com.openelements.spring.base.services.user.SystemUser; +import com.openelements.spring.base.services.user.UserEntity; +import com.openelements.spring.base.services.user.UserRepository; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * HTTP integration tests for the SCIM Users provider, driving the real {@code /scim/v2/**} surface + * through {@link MockMvc} against a Postgres Testcontainer with the SCIM module activated. + */ +@SpringBootTest(classes = ScimTestApp.class) +@Import(ScimTestConfiguration.class) +@Testcontainers +@ActiveProfiles("testcontainers") +class ScimUsersIntegrationTest { + + private static final String SCIM = "application/scim+json"; + private static final String AUTH = "Authorization"; + private static final String TOKEN = "Bearer secret-123"; + + private MockMvc mvc; + + @Autowired private UserRepository userRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @Autowired + void configureMockMvc(final WebApplicationContext context) { + this.mvc = + MockMvcBuilders.webAppContextSetup(context) + .apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers + .springSecurity()) + .build(); + } + + @BeforeEach + void cleanUsers() { + jdbcTemplate.update("delete from oe_spring_services.audit_log"); + jdbcTemplate.update( + "delete from oe_spring_services.users where id not in (?, ?)", + SystemUser.ID, + ScimServicePrincipal.ID); + } + + // ---- Authentication ------------------------------------------------------------------------- + + @Test + @DisplayName("A valid SCIM token is accepted") + void validTokenAccepted() throws Exception { + mvc.perform(get("/scim/v2/Users").header(AUTH, TOKEN)).andExpect(status().isOk()); + } + + @Test + @DisplayName("A missing bearer token is rejected with a SCIM 401") + void missingTokenRejected() throws Exception { + mvc.perform(get("/scim/v2/Users")) + .andExpect(status().isUnauthorized()) + .andExpect(header().string("WWW-Authenticate", "Bearer")) + .andExpect(jsonPath("$.schemas[0]", is("urn:ietf:params:scim:api:messages:2.0:Error"))) + .andExpect(jsonPath("$.status", is("401"))); + } + + @Test + @DisplayName("A wrong bearer token is rejected") + void wrongTokenRejected() throws Exception { + mvc.perform(get("/scim/v2/Users").header(AUTH, "Bearer wrong")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("The SCIM token does not authenticate against the JWT chain") + void scimTokenDoesNotLeakToJwtChain() throws Exception { + mvc.perform(get("/api/some-protected-path").header(AUTH, TOKEN)) + .andExpect(status().isUnauthorized()); + } + + // ---- Discovery ------------------------------------------------------------------------------ + + @Test + @DisplayName("ServiceProviderConfig advertises honest capabilities") + void serviceProviderConfig() throws Exception { + mvc.perform(get("/scim/v2/ServiceProviderConfig").header(AUTH, TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.patch.supported", is(false))) + .andExpect(jsonPath("$.filter.supported", is(true))) + .andExpect(jsonPath("$.bulk.supported", is(false))) + .andExpect(jsonPath("$.etag.supported", is(false))) + .andExpect(jsonPath("$.authenticationSchemes[0].type", is("oauthbearertoken"))); + } + + @Test + @DisplayName("ResourceTypes and Schemas are served") + void resourceTypesAndSchemas() throws Exception { + mvc.perform(get("/scim/v2/ResourceTypes").header(AUTH, TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id", is("User"))); + mvc.perform(get("/scim/v2/Schemas").header(AUTH, TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id", is("urn:ietf:params:scim:schemas:core:2.0:User"))); + } + + // ---- Create --------------------------------------------------------------------------------- + + @Test + @DisplayName("POST creates a brand-new user with a server-assigned id and sub = NULL") + void createUser() throws Exception { + final String location = + mvc.perform( + post("/scim/v2/Users").header(AUTH, TOKEN).contentType(SCIM).content(alice())) + .andExpect(status().isCreated()) + .andExpect(header().exists("Location")) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.userName", is("alice"))) + .andReturn() + .getResponse() + .getHeader("Location"); + + final UUID id = UUID.fromString(location.substring(location.lastIndexOf('/') + 1)); + final UserEntity row = userRepository.findById(id).orElseThrow(); + assertThat(row.getExternalId()).isEqualTo("ext-1"); + assertThat(row.getName()).isEqualTo("Alice"); + assertThat(row.getEmail()).isEqualTo("alice@example.com"); + assertThat(row.isActive()).isTrue(); + assertThat(row.isDeleted()).isFalse(); + assertThat(row.getSub()).isNull(); + } + + @Test + @DisplayName("POST with an existing externalId returns 409 uniqueness") + void createDuplicateExternalId() throws Exception { + seed("alice", "ext-1", true, false); + mvc.perform( + post("/scim/v2/Users") + .header(AUTH, TOKEN) + .contentType(SCIM) + .content(user("different", "ext-1"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.scimType", is("uniqueness"))); + } + + @Test + @DisplayName("POST with an existing userName returns 409 uniqueness") + void createDuplicateUserName() throws Exception { + seed("alice", "ext-1", true, false); + mvc.perform( + post("/scim/v2/Users") + .header(AUTH, TOKEN) + .contentType(SCIM) + .content(user("alice", "ext-2"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.scimType", is("uniqueness"))); + } + + @Test + @DisplayName("POST without userName returns 400 invalidValue") + void createMissingUserName() throws Exception { + mvc.perform( + post("/scim/v2/Users").header(AUTH, TOKEN).contentType(SCIM).content("{\"active\":true}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.scimType", is("invalidValue"))); + } + + // ---- Read ----------------------------------------------------------------------------------- + + @Test + @DisplayName("GET by id returns an existing user; deleted and unknown ids return 404") + void getById() throws Exception { + final UUID id = seed("alice", "ext-1", true, false); + mvc.perform(get("/scim/v2/Users/" + id).header(AUTH, TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userName", is("alice"))); + + final UUID deleted = seed("bob", "ext-2", false, true); + mvc.perform(get("/scim/v2/Users/" + deleted).header(AUTH, TOKEN)) + .andExpect(status().isNotFound()); + + mvc.perform(get("/scim/v2/Users/" + UUID.randomUUID()).header(AUTH, TOKEN)) + .andExpect(status().isNotFound()); + } + + // ---- List & filter -------------------------------------------------------------------------- + + @Test + @DisplayName("List excludes soft-deleted rows and the reserved principals") + void listExcludesDeletedAndReserved() throws Exception { + seed("alice", "ext-1", true, false); + seed("bob", "ext-2", true, false); + seed("gone", "ext-3", false, true); + mvc.perform(get("/scim/v2/Users").header(AUTH, TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.schemas[0]", is("urn:ietf:params:scim:api:messages:2.0:ListResponse"))) + .andExpect(jsonPath("$.totalResults", is(2))) + .andExpect(jsonPath("$.Resources", hasSize(2))); + } + + @Test + @DisplayName("Filter by userName eq and externalId eq resolve exactly one user; no match is empty") + void filter() throws Exception { + seed("alice", "ext-1", true, false); + seed("bob", "ext-2", true, false); + mvc.perform(get("/scim/v2/Users").param("filter", "userName eq \"alice\"").header(AUTH, TOKEN)) + .andExpect(jsonPath("$.totalResults", is(1))) + .andExpect(jsonPath("$.Resources[0].userName", is("alice"))); + mvc.perform(get("/scim/v2/Users").param("filter", "externalId eq \"ext-2\"").header(AUTH, TOKEN)) + .andExpect(jsonPath("$.totalResults", is(1))) + .andExpect(jsonPath("$.Resources[0].userName", is("bob"))); + mvc.perform(get("/scim/v2/Users").param("filter", "userName eq \"ghost\"").header(AUTH, TOKEN)) + .andExpect(jsonPath("$.totalResults", is(0))) + .andExpect(jsonPath("$.Resources", hasSize(0))); + } + + @Test + @DisplayName("Pagination honours startIndex and count") + void pagination() throws Exception { + for (int i = 1; i <= 5; i++) { + seed("user" + i, "ext-" + i, true, false); + } + mvc.perform(get("/scim/v2/Users").param("startIndex", "3").param("count", "2").header(AUTH, TOKEN)) + .andExpect(jsonPath("$.totalResults", is(5))) + .andExpect(jsonPath("$.startIndex", is(3))) + .andExpect(jsonPath("$.itemsPerPage", is(2))) + .andExpect(jsonPath("$.Resources", hasSize(2))); + } + + // ---- Replace (PUT) -------------------------------------------------------------------------- + + @Test + @DisplayName("PUT replaces mutable fields, does not write sub, and 404s on unknown id") + void putReplace() throws Exception { + final UUID id = seed("alice", "ext-1", true, false); + mvc.perform( + put("/scim/v2/Users/" + id) + .header(AUTH, TOKEN) + .contentType(SCIM) + .content( + "{\"userName\":\"alice\",\"externalId\":\"ext-1\"," + + "\"displayName\":\"Alice Changed\"," + + "\"emails\":[{\"value\":\"changed@example.com\",\"primary\":true}]," + + "\"active\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayName", is("Alice Changed"))); + final UserEntity row = userRepository.findById(id).orElseThrow(); + assertThat(row.getName()).isEqualTo("Alice Changed"); + assertThat(row.getEmail()).isEqualTo("changed@example.com"); + assertThat(row.getSub()).isNull(); + + mvc.perform( + put("/scim/v2/Users/" + UUID.randomUUID()) + .header(AUTH, TOKEN) + .contentType(SCIM) + .content(user("ghost", "ext-9"))) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("PUT active:false deactivates; PUT active:true on a soft-deleted user undeletes it") + void putDeactivateAndUndelete() throws Exception { + final UUID id = seed("alice", "ext-1", true, false); + mvc.perform( + put("/scim/v2/Users/" + id).header(AUTH, TOKEN).contentType(SCIM) + .content("{\"userName\":\"alice\",\"active\":false}")) + .andExpect(status().isOk()); + assertThat(userRepository.findById(id).orElseThrow().isActive()).isFalse(); + + final UUID deleted = seed("bob", "ext-2", false, true); + mvc.perform( + put("/scim/v2/Users/" + deleted).header(AUTH, TOKEN).contentType(SCIM) + .content("{\"userName\":\"bob\",\"active\":true}")) + .andExpect(status().isOk()); + final UserEntity revived = userRepository.findById(deleted).orElseThrow(); + assertThat(revived.isDeleted()).isFalse(); + assertThat(revived.getDeletedAt()).isNull(); + assertThat(revived.isActive()).isTrue(); + } + + // ---- Delete (soft) -------------------------------------------------------------------------- + + @Test + @DisplayName("DELETE soft-deletes, distinguishes from deactivation, and 404s on unknown id") + void softDelete() throws Exception { + final UUID deactivated = seed("a", "ext-a", false, false); // PUT active:false earlier (deactivated) + final UUID id = seed("b", "ext-b", true, false); + mvc.perform(delete("/scim/v2/Users/" + id).header(AUTH, TOKEN)) + .andExpect(status().isNoContent()); + final UserEntity row = userRepository.findById(id).orElseThrow(); + assertThat(row.isActive()).isFalse(); + assertThat(row.isDeleted()).isTrue(); + assertThat(row.getDeletedAt()).isNotNull(); + // deactivated (not deleted) is distinguishable + final UserEntity other = userRepository.findById(deactivated).orElseThrow(); + assertThat(other.isDeleted()).isFalse(); + assertThat(other.getDeletedAt()).isNull(); + + mvc.perform(delete("/scim/v2/Users/" + UUID.randomUUID()).header(AUTH, TOKEN)) + .andExpect(status().isNotFound()); + } + + // ---- Audit ---------------------------------------------------------------------------------- + + @Test + @DisplayName("SCIM writes are audited against the SCIM service principal with the right action") + void auditAttribution() throws Exception { + final UUID id = + UUID.fromString( + lastPathSegment( + mvc.perform( + post("/scim/v2/Users").header(AUTH, TOKEN).contentType(SCIM).content(alice())) + .andReturn() + .getResponse() + .getHeader("Location"))); + mvc.perform(delete("/scim/v2/Users/" + id).header(AUTH, TOKEN)).andExpect(status().isNoContent()); + + final List actions = + jdbcTemplate.queryForList( + "select action from oe_spring_services.audit_log where user_id = ? order by created_at", + String.class, + ScimServicePrincipal.ID); + assertThat(actions).containsExactly("INSERT", "DELETE"); + } + + // ---- Group stub ----------------------------------------------------------------------------- + + @Test + @DisplayName("Group stub: GET returns an empty ListResponse; writes return 501") + void groupStub() throws Exception { + mvc.perform(get("/scim/v2/Groups").header(AUTH, TOKEN)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalResults", is(0))); + mvc.perform(post("/scim/v2/Groups").header(AUTH, TOKEN).contentType(SCIM).content("{}")) + .andExpect(status().isNotImplemented()); + } + + // ---- helpers -------------------------------------------------------------------------------- + + private UUID seed( + final String userName, final String externalId, final boolean active, final boolean deleted) { + final UserEntity e = new UserEntity(); + e.setUserName(userName); + e.setExternalId(externalId); + e.setName(userName); + e.setActive(active); + e.setDeleted(deleted); + if (deleted) { + e.setDeletedAt(java.time.Instant.now()); + } + return userRepository.save(e).id(); + } + + private static String alice() { + return "{\"userName\":\"alice\",\"externalId\":\"ext-1\",\"displayName\":\"Alice\"," + + "\"emails\":[{\"value\":\"alice@example.com\",\"primary\":true}],\"active\":true}"; + } + + private static String user(final String userName, final String externalId) { + return "{\"userName\":\"" + userName + "\",\"externalId\":\"" + externalId + "\",\"active\":true}"; + } + + private static String lastPathSegment(final String path) { + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/spring-services-scim/src/test/resources/application-testcontainers.properties b/spring-services-scim/src/test/resources/application-testcontainers.properties new file mode 100644 index 0000000..a428451 --- /dev/null +++ b/spring-services-scim/src/test/resources/application-testcontainers.properties @@ -0,0 +1,6 @@ +spring.jpa.hibernate.ddl-auto=create-drop +# Provision the dedicated oe_spring_services schema for the schema-qualified library entities. +spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true +spring.main.allow-bean-definition-overriding=true +# Activates the SCIM module (ScimAutoConfiguration is @ConditionalOnProperty on this token). +openelements.scim.token=secret-123