Repository navigation
fix(scim): unbreak provisioning + add Tier 1 handler tests (V2-273) - #27
Merged
Merged
Conversation
Adds 22 handler integration tests exercising the SCIM 2.0 endpoints
through the real chi router via httptest. Tests immediately surfaced
three latent production bugs that any real Okta or Azure AD integration
would have hit on the first request — all fixed in the same commit.
Bugs found and fixed:
1. SCIM mount stripped the wrong prefix → every IdP call 404'd.
chi.Mount does not rewrite r.URL.Path, but elimity-com/scim does its
own TrimPrefix(path, "/v2") internally and matches "/Users" /
"/Groups". Wrapped with http.StripPrefix("/scim/v2", scimServer).
2. Group membership silently dropped on every SCIM provisioning call.
The SCIM handler passed addedBy=0 to AddMember / ReplaceMembers, but
group_members.added_by is FK to users(id) (no user has id=0), so the
INSERT failed and the handler swallowed the error with _ =. Added a
nullableAddedBy() helper that maps 0 → SQL NULL.
3. Group Create returned stale externalId. The handler called
SetExternalID after Create but then passed the pre-update struct to
groupToResource, so the response always showed externalId: null.
Re-fetch via GetByID between write and response.
Test coverage:
- Discovery (3): ServiceProviderConfig, Schemas, ResourceTypes
- Auth (5): disabled-SCIM 404, missing / wrong-prefix / invalid /
revoked tokens (revoke via real admin API)
- Users (10): create, get, 404, filter=userName eq (Azure AD existence
check shape), paginated list, PUT, PATCH active, PATCH "Op: Replace"
(Okta capitalization quirk), delete, full lifecycle
- Groups (5): create-with-members, PATCH add+remove (incl. Azure
members[value eq "X"] filtered path), atomic PUT replace, delete,
list
Closes V2-273 Tier 1. Tier 1.5 (fixture replay from real IdP captures)
and Tier 2 (manual rehearsal via cloudflared tunnel) still TODO.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 18, 2026
Nic-dorman
added a commit
that referenced
this pull request
May 19, 2026
…w, identity matching (V2-272 chunk A) (#31) First of four PRs implementing the SSO login flow. V2-272 was ~60% done on master (oidc_providers/identities tables + admin CRUD); this lands the missing service layer that handles authorize → IdP → callback + identity matching. Chunk B will mount HTTP handlers on top. Deps - github.com/coreos/go-oidc/v3 — OIDC discovery + ID-token verification - golang.org/x/oauth2 — code exchange with PKCE-S256 Schema (004_oidc_provisioning.sql for sqlite + postgres) - oidc_providers.default_group_id (FK → groups, nullable) - oidc_providers.auto_provision (bool, default false — off by default so a misconfigured provider can't silently grow the user table) - Reflected on OIDCProvider struct + scanned in all SELECTs. New service: internal/services/oidc_login.go - BuildAuthorizeURL(providerID, opts) → returns the IdP redirect URL + the opaque encrypted-cookie value the caller must set on the response. Generates state (32 random bytes hex), nonce (same), PKCE verifier via oauth2.GenerateVerifier(), runs OIDC discovery, builds the authorize URL with S256 challenge. - HandleCallback(cookieValue, queryState, code) → completes the exchange, validates the ID token (signature via JWKS, issuer, audience, nonce, exp), and resolves the identity. Returns a CallbackOutcome that distinguishes login vs linking flows. - Identity matching tree (per V2-272 decisions): 1. (provider_id, sub) hit → log in as that user 2. users.external_id == sub → auto-link (SCIM correlation) + log in 3. auto_provision=false → ErrOIDCNoAccount 4. auto_provision=true + email already used → ErrOIDCEmailCollision (the explicit email-confusion guard — never auto-link by email) 5. auto_provision=true + email free → create user via CreateFromSCIM + LinkIdentity + AddMember(default_group_id), log in - LinkIdentity / UnlinkIdentity / ListIdentitiesForUser for the profile "connected accounts" flow. UnlinkIdentity refuses to leave the user with no login method (no password AND only one identity). State cookie - JSON payload {provider_id, state, nonce, code_verifier, redirect_url, link_to_user_id?, exp} AES-256-GCM encrypted via the existing internal/crypto package (reuses wallet_encryption_key — no separate secret to provision), then base64url-encoded for the cookie value. - 10-minute TTL. Tampering and wrong-key decryption both fail closed via the GCM auth tag; both covered in tests. OIDCProviderService.SetAutoProvision(id, enabled, groupID) - Separate method so the admin UI toggle (Chunk C) doesn't need to re-supply every other field. Tests (19 active + 1 skipped) in internal/services/oidc_login_test.go - Cookie round-trip, tampered cookie rejected, wrong-key rejected - splitScopes (auto-prepends openid), splitName fallbacks - LinkIdentity idempotent + rejects-different-user - UnlinkIdentity: rejects-last, allows-with-password, allows-multiple-identities, rejects-foreign - HandleCallback against an in-memory fake IdP (httptest + go-jose RSA signer with JWKS): existing-identity login, SCIM auto-link by external_id, no_account when auto_provision off, email-collision guard, state mismatch, expired cookie, nonce mismatch, linking flow - TestHandleCallback_AutoProvisionsNewUserWithDefaultGroup is t.Skip-ped with a pointer to V2-273 (PR #27). The auto-provision path calls groupSvc.AddMember(..., 0) and AddMember on master writes 0 directly to a FK-to-users column. V2-273 maps 0 → SQL NULL via nullableAddedBy(); once it merges, drop the skip. Chunks B/C/D - B: GET /api/v2/auth/oidc/{providers,authorize/{id},callback} + link/unlink handlers - C: LoginView.vue SSO buttons, profile connected accounts, admin auto-provision toggle - D: Dex as CI service container, Playwright E2E Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nic-dorman
added a commit
that referenced
this pull request
May 19, 2026
… B) (#32) * feat(sso): OIDC login service foundation — state cookie, callback flow, identity matching (V2-272 chunk A) First of four PRs implementing the SSO login flow. V2-272 was ~60% done on master (oidc_providers/identities tables + admin CRUD); this lands the missing service layer that handles authorize → IdP → callback + identity matching. Chunk B will mount HTTP handlers on top. Deps - github.com/coreos/go-oidc/v3 — OIDC discovery + ID-token verification - golang.org/x/oauth2 — code exchange with PKCE-S256 Schema (004_oidc_provisioning.sql for sqlite + postgres) - oidc_providers.default_group_id (FK → groups, nullable) - oidc_providers.auto_provision (bool, default false — off by default so a misconfigured provider can't silently grow the user table) - Reflected on OIDCProvider struct + scanned in all SELECTs. New service: internal/services/oidc_login.go - BuildAuthorizeURL(providerID, opts) → returns the IdP redirect URL + the opaque encrypted-cookie value the caller must set on the response. Generates state (32 random bytes hex), nonce (same), PKCE verifier via oauth2.GenerateVerifier(), runs OIDC discovery, builds the authorize URL with S256 challenge. - HandleCallback(cookieValue, queryState, code) → completes the exchange, validates the ID token (signature via JWKS, issuer, audience, nonce, exp), and resolves the identity. Returns a CallbackOutcome that distinguishes login vs linking flows. - Identity matching tree (per V2-272 decisions): 1. (provider_id, sub) hit → log in as that user 2. users.external_id == sub → auto-link (SCIM correlation) + log in 3. auto_provision=false → ErrOIDCNoAccount 4. auto_provision=true + email already used → ErrOIDCEmailCollision (the explicit email-confusion guard — never auto-link by email) 5. auto_provision=true + email free → create user via CreateFromSCIM + LinkIdentity + AddMember(default_group_id), log in - LinkIdentity / UnlinkIdentity / ListIdentitiesForUser for the profile "connected accounts" flow. UnlinkIdentity refuses to leave the user with no login method (no password AND only one identity). State cookie - JSON payload {provider_id, state, nonce, code_verifier, redirect_url, link_to_user_id?, exp} AES-256-GCM encrypted via the existing internal/crypto package (reuses wallet_encryption_key — no separate secret to provision), then base64url-encoded for the cookie value. - 10-minute TTL. Tampering and wrong-key decryption both fail closed via the GCM auth tag; both covered in tests. OIDCProviderService.SetAutoProvision(id, enabled, groupID) - Separate method so the admin UI toggle (Chunk C) doesn't need to re-supply every other field. Tests (19 active + 1 skipped) in internal/services/oidc_login_test.go - Cookie round-trip, tampered cookie rejected, wrong-key rejected - splitScopes (auto-prepends openid), splitName fallbacks - LinkIdentity idempotent + rejects-different-user - UnlinkIdentity: rejects-last, allows-with-password, allows-multiple-identities, rejects-foreign - HandleCallback against an in-memory fake IdP (httptest + go-jose RSA signer with JWKS): existing-identity login, SCIM auto-link by external_id, no_account when auto_provision off, email-collision guard, state mismatch, expired cookie, nonce mismatch, linking flow - TestHandleCallback_AutoProvisionsNewUserWithDefaultGroup is t.Skip-ped with a pointer to V2-273 (PR #27). The auto-provision path calls groupSvc.AddMember(..., 0) and AddMember on master writes 0 directly to a FK-to-users column. V2-273 maps 0 → SQL NULL via nullableAddedBy(); once it merges, drop the skip. Chunks B/C/D - B: GET /api/v2/auth/oidc/{providers,authorize/{id},callback} + link/unlink handlers - C: LoginView.vue SSO buttons, profile connected accounts, admin auto-provision toggle - D: Dex as CI service container, Playwright E2E Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sso): OIDC authorize + callback + linking handlers (V2-272 chunk B) Mounts the OIDC service from chunk A on the router and adds the public auth + authenticated linking endpoints. Stack: this PR is on top of nic/v2-272-sso-foundation (chunk A) — merge that first. Public routes - GET /api/v2/auth/oidc/providers Lists enabled providers as {id, name, display_name}. Deliberately leaves out client_id and the encrypted secret — the login page just needs labels. - GET /api/v2/auth/oidc/authorize/{providerId} Calls BuildAuthorizeURL, sets the encrypted state cookie (HttpOnly + Secure + SameSite=Lax + Path=/api/v2/auth/oidc), and 302's to the IdP. Cookie path is scoped so it doesn't leak to other routes. - GET /api/v2/auth/oidc/callback Clears the state cookie up-front (success OR failure), calls HandleCallback, issues a session JWT via the same path Login uses (jwt_expiry_hours setting, session cookie with the same flags), and 302's to / on success or /login?error=<code> on failure. IdP-side errors (?error=access_denied etc.) pass through to /login. Authenticated routes - POST /api/v2/auth/oidc/link/{providerId} Same flow as authorize but with LinkToUserID set to the current user. Returns {authorize_url} as JSON so the frontend can do window.location.href — POST→302 doesn't work cleanly with fetch. - DELETE /api/v2/auth/oidc/identities/{identityId} Calls UnlinkIdentity. 409 + last_login_method code when removing would leave the user with no way to log in. - GET /api/v2/me/oidc/identities Returns the user's linked identities with provider_name resolved for the profile "connected accounts" view. Admin route - PUT /api/v2/admin/oidc/providers/{id}/auto-provision {auto_provision, default_group_id} — separate from the main Update endpoint so the UI toggle (chunk C) can flip it without re-supplying every other field. default_group_id=0 clears the group assignment. Error code mapping (oidcErrorCode): - ErrOIDCNoAccount → no_account - ErrOIDCEmailCollision → email_exists - state/nonce/cookie → session_expired - ErrOIDCMissingEmail → missing_email - ErrOIDCProviderDisabled→ provider_disabled - everything else → internal oidcCallbackURL prefers cfg.BaseURL when set (production), falls back to deriving scheme + host from the incoming request (dev / single-host). Tests: 10 in internal/handlers/oidc_auth_test.go - ListOIDCProviders only shows enabled, doesn't leak client_id - Authorize sets HttpOnly+Lax cookie, 302's to IdP, URL includes PKCE S256 challenge - Authorize → 404 for unknown provider - Full round-trip: authorize → fake IdP /authorize → callback with pre-linked identity → session cookie set, 302 to / - Callback with auto_provision=off + unknown sub → /login?error=no_account - Callback with IdP ?error=access_denied → /login?error=access_denied - Unlink requires auth (401) - Unlink last-login-method returns 409 with last_login_method code - /me/oidc/identities lists user's identities with provider_name resolved - Admin set auto_provision persists The handler tests reuse a fake IdP (httptest + go-jose RS256 JWKS) that auto-redirects /authorize back to the callback with code+state, so the full authorize → IdP → callback chain runs in-process. Chunk D adds Dex on top of this for browser-driven verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(swagger): regenerate docs to match handlers Lint job on master enforces swagger freshness; the cumulative handler changes on this branch hadn't been reflected. Generated with: swag init -g cmd/indelible/main.go -o docs/ --parseDependency Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nic-dorman
added a commit
that referenced
this pull request
May 19, 2026
…n auto-provision UI (V2-272 chunk C) (#33) * feat(sso): OIDC login service foundation — state cookie, callback flow, identity matching (V2-272 chunk A) First of four PRs implementing the SSO login flow. V2-272 was ~60% done on master (oidc_providers/identities tables + admin CRUD); this lands the missing service layer that handles authorize → IdP → callback + identity matching. Chunk B will mount HTTP handlers on top. Deps - github.com/coreos/go-oidc/v3 — OIDC discovery + ID-token verification - golang.org/x/oauth2 — code exchange with PKCE-S256 Schema (004_oidc_provisioning.sql for sqlite + postgres) - oidc_providers.default_group_id (FK → groups, nullable) - oidc_providers.auto_provision (bool, default false — off by default so a misconfigured provider can't silently grow the user table) - Reflected on OIDCProvider struct + scanned in all SELECTs. New service: internal/services/oidc_login.go - BuildAuthorizeURL(providerID, opts) → returns the IdP redirect URL + the opaque encrypted-cookie value the caller must set on the response. Generates state (32 random bytes hex), nonce (same), PKCE verifier via oauth2.GenerateVerifier(), runs OIDC discovery, builds the authorize URL with S256 challenge. - HandleCallback(cookieValue, queryState, code) → completes the exchange, validates the ID token (signature via JWKS, issuer, audience, nonce, exp), and resolves the identity. Returns a CallbackOutcome that distinguishes login vs linking flows. - Identity matching tree (per V2-272 decisions): 1. (provider_id, sub) hit → log in as that user 2. users.external_id == sub → auto-link (SCIM correlation) + log in 3. auto_provision=false → ErrOIDCNoAccount 4. auto_provision=true + email already used → ErrOIDCEmailCollision (the explicit email-confusion guard — never auto-link by email) 5. auto_provision=true + email free → create user via CreateFromSCIM + LinkIdentity + AddMember(default_group_id), log in - LinkIdentity / UnlinkIdentity / ListIdentitiesForUser for the profile "connected accounts" flow. UnlinkIdentity refuses to leave the user with no login method (no password AND only one identity). State cookie - JSON payload {provider_id, state, nonce, code_verifier, redirect_url, link_to_user_id?, exp} AES-256-GCM encrypted via the existing internal/crypto package (reuses wallet_encryption_key — no separate secret to provision), then base64url-encoded for the cookie value. - 10-minute TTL. Tampering and wrong-key decryption both fail closed via the GCM auth tag; both covered in tests. OIDCProviderService.SetAutoProvision(id, enabled, groupID) - Separate method so the admin UI toggle (Chunk C) doesn't need to re-supply every other field. Tests (19 active + 1 skipped) in internal/services/oidc_login_test.go - Cookie round-trip, tampered cookie rejected, wrong-key rejected - splitScopes (auto-prepends openid), splitName fallbacks - LinkIdentity idempotent + rejects-different-user - UnlinkIdentity: rejects-last, allows-with-password, allows-multiple-identities, rejects-foreign - HandleCallback against an in-memory fake IdP (httptest + go-jose RSA signer with JWKS): existing-identity login, SCIM auto-link by external_id, no_account when auto_provision off, email-collision guard, state mismatch, expired cookie, nonce mismatch, linking flow - TestHandleCallback_AutoProvisionsNewUserWithDefaultGroup is t.Skip-ped with a pointer to V2-273 (PR #27). The auto-provision path calls groupSvc.AddMember(..., 0) and AddMember on master writes 0 directly to a FK-to-users column. V2-273 maps 0 → SQL NULL via nullableAddedBy(); once it merges, drop the skip. Chunks B/C/D - B: GET /api/v2/auth/oidc/{providers,authorize/{id},callback} + link/unlink handlers - C: LoginView.vue SSO buttons, profile connected accounts, admin auto-provision toggle - D: Dex as CI service container, Playwright E2E Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sso): OIDC authorize + callback + linking handlers (V2-272 chunk B) Mounts the OIDC service from chunk A on the router and adds the public auth + authenticated linking endpoints. Stack: this PR is on top of nic/v2-272-sso-foundation (chunk A) — merge that first. Public routes - GET /api/v2/auth/oidc/providers Lists enabled providers as {id, name, display_name}. Deliberately leaves out client_id and the encrypted secret — the login page just needs labels. - GET /api/v2/auth/oidc/authorize/{providerId} Calls BuildAuthorizeURL, sets the encrypted state cookie (HttpOnly + Secure + SameSite=Lax + Path=/api/v2/auth/oidc), and 302's to the IdP. Cookie path is scoped so it doesn't leak to other routes. - GET /api/v2/auth/oidc/callback Clears the state cookie up-front (success OR failure), calls HandleCallback, issues a session JWT via the same path Login uses (jwt_expiry_hours setting, session cookie with the same flags), and 302's to / on success or /login?error=<code> on failure. IdP-side errors (?error=access_denied etc.) pass through to /login. Authenticated routes - POST /api/v2/auth/oidc/link/{providerId} Same flow as authorize but with LinkToUserID set to the current user. Returns {authorize_url} as JSON so the frontend can do window.location.href — POST→302 doesn't work cleanly with fetch. - DELETE /api/v2/auth/oidc/identities/{identityId} Calls UnlinkIdentity. 409 + last_login_method code when removing would leave the user with no way to log in. - GET /api/v2/me/oidc/identities Returns the user's linked identities with provider_name resolved for the profile "connected accounts" view. Admin route - PUT /api/v2/admin/oidc/providers/{id}/auto-provision {auto_provision, default_group_id} — separate from the main Update endpoint so the UI toggle (chunk C) can flip it without re-supplying every other field. default_group_id=0 clears the group assignment. Error code mapping (oidcErrorCode): - ErrOIDCNoAccount → no_account - ErrOIDCEmailCollision → email_exists - state/nonce/cookie → session_expired - ErrOIDCMissingEmail → missing_email - ErrOIDCProviderDisabled→ provider_disabled - everything else → internal oidcCallbackURL prefers cfg.BaseURL when set (production), falls back to deriving scheme + host from the incoming request (dev / single-host). Tests: 10 in internal/handlers/oidc_auth_test.go - ListOIDCProviders only shows enabled, doesn't leak client_id - Authorize sets HttpOnly+Lax cookie, 302's to IdP, URL includes PKCE S256 challenge - Authorize → 404 for unknown provider - Full round-trip: authorize → fake IdP /authorize → callback with pre-linked identity → session cookie set, 302 to / - Callback with auto_provision=off + unknown sub → /login?error=no_account - Callback with IdP ?error=access_denied → /login?error=access_denied - Unlink requires auth (401) - Unlink last-login-method returns 409 with last_login_method code - /me/oidc/identities lists user's identities with provider_name resolved - Admin set auto_provision persists The handler tests reuse a fake IdP (httptest + go-jose RS256 JWKS) that auto-redirects /authorize back to the callback with code+state, so the full authorize → IdP → callback chain runs in-process. Chunk D adds Dex on top of this for browser-driven verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sso): login page SSO buttons + profile connected accounts + admin auto-provision UI (V2-272 chunk C) Third of four PRs for V2-272. Stack: on top of chunk B (nic/v2-272-sso-handlers). LoginView.vue - Fetches /api/v2/auth/oidc/providers on mount, renders "Sign in with X" buttons below the password form, separated by an "or" divider that only appears when at least one provider is enabled. - Click → window.location.href = authorize URL (full-page navigation; fetch() would strand the state cookie). - Reads ?error=<code> on mount and renders a human-friendly message for each of the error codes the callback emits: no_account, email_exists, session_expired, missing_email, provider_disabled, access_denied, internal. Fallback message includes the raw code so unknown errors don't disappear silently. ProfileView.vue — new "Connected Accounts" card - Lists OIDC identities from GET /me/oidc/identities with provider name, subject, and link date. Each row has an "Unlink" button. - Unlink shows a native confirm() then DELETEs /auth/oidc/identities/{id}. The 409 last_login_method case gets a specific warn toast explaining the user needs to set a password or link another provider first. - "Link another provider" section below the list, showing only providers the user doesn't already have linked (computed). Click → POSTs /auth/oidc/link/{id}, follows the returned authorize_url via full-page nav, completing the flow at /callback?...&link=1. - Whole card is hidden when there's nothing linkable AND nothing linked, so password-only deployments don't see an empty section. SettingsView.vue — admin auto-provision toggle - Each OIDC provider in the SSO tab now renders editable controls: * ToggleSwitch for auto_provision * Select dropdown for default_group_id (with "None" sentinel = 0) * Save button that PUTs to /admin/oidc/providers/{id}/auto-provision and toasts on success - Re-fetches on save failure so the UI reflects backend truth. - Inline explanation calls out that email-collision is always blocked — never auto-link by email alone, regardless of toggle state. - Groups fetched from /admin/groups on mount alongside providers. Backend (admin_oidc.go) — response shape extension - AdminListOIDCProviders now includes auto_provision (bool) and default_group_id (nullable int) in each provider row so the new UI has state to render. No new endpoint — the existing PUT .../{id}/auto-provision from chunk B handles the write side. Test plan - go test ./internal/handlers — green, no regressions - npx vue-tsc --noEmit — clean Chunk D - Dex as CI service container running the full authorize flow through a real OIDC server - Playwright E2E walks login button → Dex consent → callback → home Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(swagger): regenerate docs to match handlers Lint job on master enforces swagger freshness; the cumulative handler changes on this branch hadn't been reflected. Generated with: swag init -g cmd/indelible/main.go -o docs/ --parseDependency Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Added 22 SCIM 2.0 handler integration tests exercising the real chi router via
httptest. The tests immediately surfaced three latent production bugs that any real Okta or Azure AD integration would have hit on the first request — all fixed in the same PR.The audit's optimistic "SCIM is fully implemented" framing was wrong: SCIM has never actually worked end-to-end. The existing token-service tests passed because they don't exercise the router.
Bugs fixed
1. Every SCIM request 404'd (mount prefix)
router.gomounted asr.Route(\"/scim/v2\", func(r){ r.Mount(\"/\", scimServer) }).chi.Mountdoes not rewriter.URL.Path— it only adjusts the chi-internalRouteContext.RoutePath. The elimity-com/scim library does its ownTrimPrefix(path, \"/v2\")internally and matches against/Users,/Groups. So the server saw/scim/v2/Users, couldn't strip/v2, and 404'd with{\"detail\":\"Specified endpoint does not exist.\"}.Fix: wrap with
http.StripPrefix(\"/scim/v2\", scimServer). The lib's own example uses the same pattern.2. Group membership silently dropped (FK violation)
The SCIM handler called
AddMember(group.ID, uid, 0)with0asaddedBy.group_members.added_byisINTEGER REFERENCES users(id)(nullable) — no user has id=0, so the INSERT failed. The handler swallowed the error with_ =, so members were silently never added. PUT (atomic replace) surfaced a visible 500 becauseReplaceMembersdoesn't swallow.Fix in
internal/services/group.go: newnullableAddedBy(int64) sql.NullInt64helper that maps0→ SQL NULL. Used in bothAddMemberandReplaceMembers. Public signatures unchanged; existing admin callers pass real user IDs and keep working.3. Group
Createreturned staleexternalIdAfter
groupSvc.Create(), the handler calledSetExternalID(group.ID, externalID)but then passed the pre-updategroupstruct togroupToResource. The response always showedexternalId: nulleven though the DB was correct — Okta would see "set externalId" succeed but every read would show null.Fix: re-fetch via
GetByIDbetween theSetExternalIDand the response.Test coverage
ServiceProviderConfig,Schemas,ResourceTypesreturn valid SCIM 2.0 payloads withapplication/scim+jsonfilter=userName eq(Azure AD existence-check shape), paginated list, PUT, PATCHactive, PATCHOp: Replace(Okta capitalization quirk), DELETE (soft), full lifecyclemembers[value eq \"X\"]filtered path), atomic PUT replace, delete, listAll 22 pass green on SQLite. Will also run under the Postgres matrix added in V2-270.
Test plan
go test ./internal/handlers/ -run TestSCIM_— all 22 passgo test ./internal/handlers/ ./internal/services/— no regressionsgo build ./...cleanOut of scope (still TODO under V2-273)
Op: Replacetest is a best-guess at the Okta quirk; fixtures from a real run would replace it with ground truth.cloudflared tunnel --url http://localhost:8080. Documented as a pre-release ritual in the ticket.🤖 Generated with Claude Code