Skip to content

feat(AF-621): SCIM 2.0 provisioning — IdP-driven user & group lifecycle - #721

Merged
babltiga merged 12 commits into
mainfrom
feature/AF-621-scim-provisioning
Aug 14, 2026
Merged

feat(AF-621): SCIM 2.0 provisioning — IdP-driven user & group lifecycle#721
babltiga merged 12 commits into
mainfrom
feature/AF-621-scim-provisioning

Conversation

@babltiga

Copy link
Copy Markdown
Contributor

Closes #621

What

A SCIM 2.0 provisioning server so identity providers (Okta, Microsoft Entra ID, Keycloak, OneLogin) drive user and group lifecycle — create, update, deactivate, group sync — instead of admins doing it by hand after SSO.

  • New scim Spring Modulith module/scim/v2/Users, /Groups, and discovery endpoints behind a dedicated @Order(0) SecurityFilterChain authenticated by long-lived per-org bearer tokens (af_scim_…, SHA-256 at rest, shown once; per-request enabled + org-disabled checks; SCIM error envelope, never ProblemDetail). Hand-rolled RFC 7644 pragmatic subset: eq filters, startIndex/count, PUT + PatchOp covering both Okta and Entra payload shapes. SCIM writes only its owned attributes — never roles, platform_admin, passwords, or TOTP.
  • Unified deactivation — any active true→false transition (admin API or SCIM) revokes refresh tokens synchronously and publishes core.events.UserDeactivatedEvent; the access module revokes the user's APPROVED JIT grants through the ordinary revocation path. This also fixes a pre-existing gap where PUT /admin/users/{id} with active=false revoked nothing.
  • Group provenance — SCIM memberships carry a new SCIM source, disjoint from admin MANUAL and SSO-login IDP rows; the SSO sync was fixed to neither wipe nor PK-collide with SCIM rows.
  • Migrations V137–V141scim_config, scim_tokens, users/user_groups.scim_external_id (+ per-org partial unique indexes), users.updated_at, SCIM enum values (with .sql.conf sidecars).
  • Admin UI/admin/scim (SSO_CONFIGURE): enabled switch, attribute mapping, default role, copyable API-base /scim/v2 URL, token table with show-once creation modal. All seven locales translated (frontend + backend keys).
  • e2ee2e/tests/admin-scim-config.spec.ts: config CRUD, show-once token, Okta-shaped provision + filter, Entra-shaped deactivation, 401 envelope after revocation.

Docs updated

docs/02-architecture.md, docs/03-data-model.md, docs/04-api-spec.md (spec-first), docs/05-backend.md, docs/06-frontend.md, docs/07-security.md, docs/12-roadmap.md, CLAUDE.md, README.md, and the website: website/docs/configuration/auth/index.html (#cfg-scim operator guide with Okta/Entra walkthroughs), website/index.html, website/app.js, website/sitemap.xml, website/README.md.

Deliberate exceptions, called out per convention: /scim/v2/Users PascalCase paths are RFC 7644-mandated (documented in docs/04-api-spec.md); SCIM protocol error strings are not localized (machine consumer). No new env vars — configuration is DB rows, so no docs/09-deployment.md change.

Review notes

Independent reviewers (af-reviewer, af-java-reviewer, af-frontend-reviewer) ran before this PR; every Blocker and all actionable Concerns were fixed in the last two commits (IdP-facing base URL from the API base, GroupMembershipSource/AuthProvider frontend fan-out, synchronous refresh-token revocation, transactional PatchOp, SCIM 400s for malformed bodies). Surviving items, deliberately not addressed:

  • JIT-grant revocation on deactivation is async fire-and-forget (af-java-reviewer): UserDeactivationGrantRevoker is an in-memory @ApplicationModuleListener; a crash between commit and listener execution drops the revocation (refresh-token revocation is synchronous and unaffected). A durable fix is the Spring Modulith JPA event-publication registry — proposed as a follow-up rather than growing this PR.
  • count=0 is clamped to 1 on SCIM list endpoints (RFC 7644 §3.4.2.4 says "total only, no resources") — none of the supported IdPs send count=0.
  • Admin remove-member stays source-agnostic (af-reviewer): an admin can remove an IdP/SCIM-owned membership; the owning sync re-adds it on its next push. Documented in docs/03-data-model.md instead of restricting the admin API.
  • AuditLogPage filter arrays omit the new SCIM_* actions, matching the existing curated-subset convention (SAML_CONFIG_UPDATED is absent too).

Verification

  • mvn verify (full unit + integration, Testcontainers) — green; architecture gates (ApplicationModulesTest, ApiPackageDependencyTest, MessagesParityTest) green; affected tests re-run after review fixes (117 tests green).
  • Frontend lint / typecheck / test:coverage (94% lines / 84% branches) / build — green.
  • e2e ran not locally: host ports 5173/8080 are occupied by a dev stack on this machine; the spec follows the existing admin-saml-config template and will run in the CI e2e job.

…dEvent

Deactivation (admin update, admin delete, and soon SCIM) now publishes
core.events.UserDeactivatedEvent on the true->false transition. The
security module revokes all refresh tokens (moved out of the
controller), and the access module revokes the user's APPROVED JIT
grants through the existing revocation path. Fixes the gap where
PUT /admin/users/{id} active=false revoked nothing.
V137-V141: scim_config + scim_tokens tables, users/user_groups
scim_external_id (+ per-org partial unique indexes), users.updated_at,
SCIM values on user_group_membership_source and auth_provider_type.

Core: ExternalUserDirectoryService (system-actor create/update/find/
offset-list, quota + uniqueness guards, deactivation event), source-
scoped group member ops for group-centric SCIM sync, and a fix so
SSO-login IDP sync neither wipes nor PK-collides with SCIM-sourced
memberships.
New scim Modulith module: /scim/v2 Users + Groups + discovery endpoints
behind an @order(0) bearer-token filter chain (per-org tokens, SHA-256
at rest, shown once; per-request enabled/org-disabled checks; SCIM
error envelope, never ProblemDetail). Hand-rolled RFC 7644 subset:
eq-filters, startIndex/count, PUT + PatchOp incl. Okta and Entra
payload shapes. SCIM writes only its owned attributes - never role,
platform_admin, password, or TOTP. Admin surface /api/v1/admin/scim-*
gated by SSO_CONFIGURE. New SCIM_* audit actions; admin-facing i18n
keys in all seven locales.
/admin/scim (SSO_CONFIGURE): enabled switch, attribute-mapping selects,
default role, copyable /scim/v2 base URL, and bearer-token management
with a show-once modal. AuthProvider union + labels gain SCIM. Website
auth chapter gets the cfg-scim operator guide (Okta + Entra ID setup,
troubleshooting) with the docs.ts <-> app.js anchor contract and
sitemap/JSON-LD freshness bumped. All seven locales translated.
Drives /admin/scim (enable + save, show-once token modal, revoke) and
exercises the SCIM protocol against the backend origin with the issued
bearer token: Okta-shaped create + userName filter, Entra-shaped
PATCH deactivate, and the 401 SCIM error envelope after revocation.
07-security: SCIM section (token model, filter chain, write boundary,
deactivation fan-out, failure modes, audit). 03-data-model: scim_config
+ scim_tokens tables, users/user_groups SCIM columns, membership source
and audit values. 05-backend: scim module + UserDeactivatedEvent flow.
02/06/12, CLAUDE.md module tree, README, website homepage tiles +
source map, freshness markers.
Review findings: window.location.origin points at the SPA, which
proxies nothing — the copyable base URL now uses apiBaseUrl() (same
trap OAuth2ConfigPage documents). Also: UsersPage provider filter and
the enumLabels exhaustiveness test gain SCIM, the token list renders a
real error state instead of 'no tokens yet', and the e2e cleanup
rationale is documented.
Backend: refresh-token revocation is synchronous again at both
deactivation services (the in-memory event channel is fire-and-forget;
the redundant security listener is removed and the event now owns only
JIT-grant fan-out), SCIM orchestrator writes are @transactional so a
PatchOp applies as a unit, the SCIM error handler maps malformed
bodies/ids to 400 instead of 500, authenticate() no longer flushes the
last-used bump outside its guard, and updated_at is stamped on the
returned view. Frontend: GroupMembershipSource union/tag/labels gain
SCIM (x7 locales), ProfilePage treats SCIM accounts as external.
Docs: the membership-isolation claim now matches the code.
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Frontend Test Results

1 480 tests   1 480 ✅  3m 27s ⏱️
  191 suites      0 💤
    1 files        0 ❌

Results for commit 76576f3.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Frontend Coverage (frontend)

Status Category Percentage Covered / Total
🟢 Lines 94.15% (🎯 90%) 2225 / 2363
🟢 Statements 92.27% (🎯 90%) 2473 / 2680
🟢 Functions 91.35% (🎯 90%) 676 / 740
🟢 Branches 84.37% (🎯 80%) 1393 / 1651
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
frontend/src/api/admin.ts 94.9% 86.84% 88.6% 94.14% 68, 74, 76, 86-87, 94, 118, 291, 326-327, 337
frontend/src/config/docs.ts 100% 100% 100% 100%
Generated in workflow #948 for commit 76576f3 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Backend Test Results

6 191 tests  +125   6 191 ✅ +125   19m 52s ⏱️ + 2m 5s
  749 suites + 18       0 💤 ±  0 
  749 files   + 18       0 ❌ ±  0 

Results for commit 76576f3. ± Comparison against base commit 572a8fe.

This pull request removes 1 and adds 126 tests. Note that renamed tests count towards both.
com.bablsoft.accessflow.core.api.UserGroupDtosTest ‑ userGroupMembershipSourceTypeHasTwoValues
com.bablsoft.accessflow.access.internal.UserDeactivationGrantRevokerTest ‑ noGrantsMeansNoRevocations
com.bablsoft.accessflow.access.internal.UserDeactivationGrantRevokerTest ‑ perGrantFailureDoesNotStopTheRest
com.bablsoft.accessflow.access.internal.UserDeactivationGrantRevokerTest ‑ revokesEveryApprovedGrantSystemAttributed
com.bablsoft.accessflow.core.api.UserGroupDtosTest ‑ userGroupMembershipSourceTypeHasThreeValues
com.bablsoft.accessflow.core.internal.DefaultExternalUserDirectoryServiceTest ‑ createExternalEnforcesQuota
com.bablsoft.accessflow.core.internal.DefaultExternalUserDirectoryServiceTest ‑ createExternalPersistsScimProviderWithoutPassword
com.bablsoft.accessflow.core.internal.DefaultExternalUserDirectoryServiceTest ‑ createExternalRejectsDuplicateEmail
com.bablsoft.accessflow.core.internal.DefaultExternalUserDirectoryServiceTest ‑ createExternalRejectsDuplicateExternalId
com.bablsoft.accessflow.core.internal.DefaultExternalUserDirectoryServiceTest ‑ findersMapToViews
com.bablsoft.accessflow.core.internal.DefaultExternalUserDirectoryServiceTest ‑ listReturnsOffsetPage
…

♻️ This comment has been updated with latest results.

AntD's modal X icon also carries the accessible name 'Close' — the
strict-mode locator resolved to two elements in CI.
Wait for the show-once modal to hide after Close (AntD keeps it mounted
through the close animation) and scope the token-name assertion to the
Bearer tokens table so it cannot double-match the modal's copy.
@github-actions

Copy link
Copy Markdown
Contributor

Backend Code Coverage

Overall Project 93.27% -0.34% 🍏
Files changed 86.82% 🍏

File Coverage
UserDeactivatedEvent.java 100% 🍏
ScimUserWriteResult.java 100% 🍏
DefaultScimConfigService.java 100% 🍏
UserViews.java 100% 🍏
UserDeactivationGrantRevoker.java 100% 🍏
UserGroupMembershipSource.java 100% 🍏
UserEntity.java 100% 🍏
UpdateScimConfigCommand.java 100% 🍏
ScimPrincipal.java 100% 🍏
ScimAttributeMapping.java 100% 🍏
ScimException.java 100% 🍏
ScimTokenNameConflictException.java 100% 🍏
ScimInvalidMappingException.java 100% 🍏
ScimConfigView.java 100% 🍏
ScimTokenView.java 100% 🍏
IssuedScimToken.java 100% 🍏
ScimTokenNotFoundException.java 100% 🍏
CreateScimTokenRequest.java 100% 🍏
ScimTokenResponse.java 100% 🍏
CreatedScimTokenResponse.java 100% 🍏
ScimConfigResponse.java 100% 🍏
UpdateScimConfigRequest.java 100% 🍏
ScimSecurityConfiguration.java 100% 🍏
ScimWebConfiguration.java 100% 🍏
ScimUserResource.java 100% 🍏
ScimError.java 100% 🍏
ScimMeta.java 100% 🍏
ScimResourceNotFoundException.java 100% 🍏
ScimPatchRequest.java 100% 🍏
ScimInvalidFilterException.java 100% 🍏
ScimProtocolException.java 100% 🍏
ScimGroupResource.java 100% 🍏
ScimInvalidPathException.java 100% 🍏
ScimFilterParser.java 100% 🍏
ScimListResponse.java 100% 🍏
ScimName.java 100% 🍏
ScimEmail.java 100% 🍏
ScimFilter.java 100% 🍏
ScimUniquenessException.java 100% 🍏
ScimInvalidValueException.java 100% 🍏
ScimPatchOperation.java 100% 🍏
ScimMemberRef.java 100% 🍏
UpdateExternalUserCommand.java 100% 🍏
CreateUserGroupCommand.java 100% 🍏
CreateExternalUserCommand.java 100% 🍏
ExternalIdAlreadyExistsException.java 100% 🍏
UpdateUserGroupCommand.java 100% 🍏
UserGroupMembershipSourceType.java 100% 🍏
UserGroupView.java 100% 🍏
UserView.java 100% 🍏
UserAdminException.java 100% 🍏
DirectoryPage.java 100% 🍏
AuthProviderType.java 100% 🍏
ScimConfigEntity.java 100% 🍏
AuditAction.java 100% 🍏
AuditResourceType.java 100% 🍏
ScimMediaTypes.java 100% 🍏
ScimAuthenticationEntryPoint.java 100% 🍏
ScimAuditWriter.java 100% 🍏
ScimDiscoveryController.java 100% 🍏
ScimAuthenticationToken.java 100% 🍏
OffsetPageable.java 98.31% -1.69% 🍏
ScimTokenAuthenticationFilter.java 97.3% -2.7% 🍏
DefaultUserGroupService.java 96.61% -1.58% 🍏
DefaultExternalUserDirectoryService.java 96.41% -3.59% 🍏
DefaultScimTokenService.java 94.3% -5.7% 🍏
ScimTokenHasher.java 93.27% -6.73% 🍏
ScimAdminConfigController.java 92.75% -7.25% 🍏
ScimAdminTokenController.java 84.21% -15.79% 🍏
AdminUserController.java 84% 🍏
ScimAdminExceptionHandler.java 83.33% -16.67% 🍏
UserAdminServiceImpl.java 82.55% 🍏
ScimUserController.java 80.41% -19.59% 🍏
ScimUserOrchestrator.java 79.1% -20.9% 🍏
ScimGroupOrchestrator.java 70.88% -29.12% 🍏
ScimErrorHandler.java 69.23% -30.77% 🍏
ScimGroupController.java 59.57% -40.43% 🍏

@babltiga
babltiga merged commit fe827a7 into main Aug 14, 2026
34 checks passed
@babltiga
babltiga deleted the feature/AF-621-scim-provisioning branch August 14, 2026 12:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security: SCIM 2.0 provisioning — IdP-driven user & group lifecycle (Okta / Entra ID / Keycloak)

1 participant