Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<dependencyManagement>
Expand Down Expand Up @@ -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
```
Expand Down
85 changes: 85 additions & 0 deletions docs/releases/upgrade-to-1.4.md
Original file line number Diff line number Diff line change
@@ -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
<dependency>
<groupId>com.open-elements</groupId>
<artifactId>spring-services-scim</artifactId>
</dependency>
```

### 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://<your-host>/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.
155 changes: 155 additions & 0 deletions docs/specs/015-scim-users-provider/steps.md
Original file line number Diff line number Diff line change
@@ -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 `<modules>`, 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<T>`, `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 |
2 changes: 1 addition & 1 deletion docs/specs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
<module>spring-services-dbbackup</module>
<module>spring-services-email</module>
<module>spring-services-mcp</module>
<module>spring-services-scim</module>
<module>spring-services-all</module>
<module>spring-services-bom</module>
</modules>
Expand Down
5 changes: 5 additions & 0 deletions spring-services-all/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@
<artifactId>spring-services-mcp</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.open-elements</groupId>
<artifactId>spring-services-scim</artifactId>
<version>${project.version}</version>
</dependency>

<!-- Test dependencies: the aggregate test boots the full classpath against Postgres. -->
<dependency>
Expand Down
5 changes: 5 additions & 0 deletions spring-services-bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
<artifactId>spring-services-mcp</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.open-elements</groupId>
<artifactId>spring-services-scim</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.open-elements</groupId>
<artifactId>spring-services-all</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading