Skip to content

Phase 7 SCIM provisioning + review hardening#57

Merged
scarson merged 57 commits intomainfrom
dev
Mar 26, 2026
Merged

Phase 7 SCIM provisioning + review hardening#57
scarson merged 57 commits intomainfrom
dev

Conversation

@scarson
Copy link
Copy Markdown
Owner

@scarson scarson commented Mar 20, 2026

Summary

  • Phase 7 SCIM provisioning — complete implementation (~6,500 lines, ~138 tests)
  • Post-implementation hardening — security review, pitfall check, plan compliance fixes
  • Phase 10 MSRC CSAF fix plan — documentation for upcoming fixture corpus work

Phase 7: SCIM 2.0 Provisioning

Full SCIM 2.0 implementation for enterprise IdP integration (Entra ID, Okta):

  • 4 migrations: org member deactivation, scim_managed flag, scim_configs, scim_groups/members
  • SCIM bearer token auth middleware with constant-time comparison + security events
  • User CRUD: 3-step identity matching (externalId → email → create), deactivation/reactivation, scim_exempt protection, sole-owner guard
  • Group CRUD: member diff, role recomputation (highest mapped_role wins), notification group sync
  • Admin endpoints: config CRUD, token rotation, group mapping with immediate effect
  • Discovery endpoints: ServiceProviderConfig, Schemas, ResourceTypes
  • Per-org rate limiter, enterprise tier gating, Entra ID/Okta compatibility (pathless PATCH, string booleans)
  • 10 security event types, comprehensive audit logging

Hardening (PR #56, merged to dev)

  • SCIM group store methods: withBypassTxwithOrgTx + AND org_id in all SQL queries
  • GetGroupIfActive + RemoveSCIMManagedGroupMember + AddGroupMemberSCIMManaged: same treatment
  • X-Forwarded-Proto validated (only http/https) via shared scimScheme() helper
  • N+1 identity lookup → batch query with ANY($2::uuid[])
  • SCIM audit actor_id: uuid.Nilnil (SQL NULL)
  • Unsupported group filter attributes now log warnings
  • Entry-level slog with org_id/scim_config_id on all 11 handlers
  • Dead redundant group.OrgID != orgID checks removed

Test plan

  • internal/auth SCIM tests: 3 PASS
  • internal/store SCIM tests: 21 PASS (testcontainers)
  • internal/api SCIM tests: 55+ PASS (e2e with testcontainers)
  • golangci-lint run: 0 issues
  • /pitfall-check: clean
  • /security-review: all actionable findings fixed
  • /plan-check against design doc: all gaps addressed

🤖 Generated with Claude Code

scarson and others added 30 commits March 19, 2026 01:19
… plan (v2)

Reviewed the original Phase 7 SCIM plans (2026-03-01) against the current
codebase and found significant drift after 18 days of active development.
Key revisions: drop unviable marcelom97/scimgateway library (0 importers)
in favor of direct chi handler implementation, renumber migrations
000042-000045, add security events pipeline integration, change
scim_configs.sso_connection_id from ON DELETE CASCADE to ON DELETE RESTRICT,
document three-level access denial hierarchy, and add configurable SCIM
rate limiter. Three rounds of subagent-readiness review applied 22 fixes
for ambiguity, context gaps, and pitfall alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add SCIM RFC specification references (7643, 7644) with section links
- Add mandatory TDD discipline section: preamble, completion check, and
  3-round review loop requirements for every task and wave
- Add testing-pitfalls inline warnings (§1 TOCTOU, §3 error format, §4
  PATCH validation, §7 event emission, §10 RLS AppStore, §11 auth tokens,
  §16 setup errors and CSRF)
- Improve assertion specificity for Tasks 12-14, 22-23 — each test case
  now specifies exact assertion (what value to check, not just "verify")
- Add implementation-pitfall warnings to Tasks 17, 19, 24 (API-2 pointer
  types, AUTH-10 constant-time compare, DB-17 transaction helper selection)
- Add explicit 3-round review checkpoints with verification criteria to
  all 7 execution waves

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add critical note to both v1 documents pointing to the v2 revisions.
The originals are preserved for historical reference only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
General deactivation feature (admin-settable, SCIM-automatable).
Partial index on (org_id) WHERE deactivated_at IS NULL for active member queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tracks whether notification group membership was SCIM-synced.
SCIM removal only deletes scim_managed=true rows; manual memberships preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SCIM provisioning config (1:1 with orgs via sso_connections).
ON DELETE RESTRICT on sso_connection_id prevents silent cascade.
Bearer token stored as sha256 hash with display prefix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
IdP groups with optional role and notification group mappings.
scim_groups references organizations directly (survives scim_config deletion).
scim_group_members has denormalized org_id for RLS protection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 new event types for SCIM auth failures, token lifecycle,
provisioning operations, and rate limiting. Severity mappings
and exhaustiveness test updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ctxSCIMConfigID context key for SCIM auth middleware.
SCIMRateLimit config field (default 50 req/sec per org).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New types: ScimConfig, ScimGroup, ScimGroupMember.
Updated: OrgMember (deactivated_at, scim_exempt), GroupMember (scim_managed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add org_members queries: DeactivateOrgMember, ReactivateOrgMember,
GetOrgMemberFull, CountActiveOrgMembers, CountActiveOrgOwners,
UpdateOrgMemberSCIMExempt.

Add group_members queries: AddGroupMemberSCIMManaged,
RemoveSCIMManagedGroupMember, IsGroupMemberSCIMManaged, GetGroupIfActive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add store methods: DeactivateOrgMember, ReactivateOrgMember,
GetOrgMemberFull, CountActiveOrgMembers, CountActiveOrgOwners,
UpdateOrgMemberSCIMExempt. All use withOrgTx for RLS isolation.

Tests cover: deactivation, reactivation, active member/owner counting,
SCIM exempt flag toggling, RLS isolation across orgs, and not-found
returning nil,nil.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add migration 042 creating scim_configs table with RLS policy and
sqlc queries for SCIM config CRUD (create, get by org/token/SSO conn,
update, rotate token, delete).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Store methods wrapping sqlc queries with correct transaction helpers:
- withOrgTx for org-scoped CRUD (create, get, update, rotate, delete)
- withBypassTx for token hash and SSO connection lookups (pre-org auth)
- sql.ErrNoRows returns nil, nil (not error)

9 integration tests covering all methods, duplicate constraint, and
RLS isolation between orgs via AppStore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ged, configs, groups)

Copied from Wave 1 worktree — these migrations are prerequisites for
SCIM group sqlc queries and store methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 queries for SCIM group CRUD, membership management, and mapping
count operations. All org-scoped via RLS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 store methods covering SCIM group CRUD, membership management,
mapping updates, and cross-group mapping counts. 11 integration tests
including RLS isolation, cascade deletes, idempotent adds, and member
count aggregation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts:
#	internal/store/generated/models.go
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add GetOrgMemberRoleAndStatus sqlc query that returns both role and
deactivated_at. RequireOrgRole middleware now checks deactivation
status before role check — deactivated members receive 403 with
a specific message regardless of their role level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add SCIM wire protocol types (SCIMUser, SCIMGroup, SCIMListResponse,
SCIMPatchOp, SCIMError, SCIMMeta) and helper functions for writing
RFC 7644-compliant error responses with application/scim+json content
type. Includes parseSCIMBool for Entra ID string boolean compat and
parseSCIMFilter for minimal filter grammar (eq operator only).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements recomputeSCIMRole which recalculates a user's org role
based on SCIM group mapped_role values. Owner protection prevents
downgrade, SCIM-exempt users are skipped, and when no mapped roles
exist the defaultRole is applied. Uses role hierarchy: admin > member > viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Expand PATCH /members/{user_id} to accept optional active and
scim_exempt fields alongside the existing role field. All fields
are now pointer types for proper PATCH semantics.

- active=false: deactivates member (sole owner protected with 400)
- active=true: reactivates member
- scim_exempt=true/false: sets SCIM exemption flag
- GET /members response now includes active, deactivated_at, scim_exempt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…security events

Adds scim_configs table (1:1 with orgs via sso_connections), sqlc queries
for CRUD + token hash lookup, store method LookupSCIMConfigByTokenHash
with withBypassTx (pre-org-context), and SCIM security event constants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements requireSCIMAuth per design doc §2: extract org_id from URL,
validate Bearer token via sha256 hash lookup + subtle.ConstantTimeCompare,
verify org match and enabled status. Fires security events on auth
failures. All error responses use RFC 7644 §3.12 SCIM error format
(application/scim+json).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements syncNotifGroupAdd and syncNotifGroupRemove for propagating
SCIM group membership changes to notification groups. Respects
soft-deleted target groups (no-op), manual membership precedence
(ON CONFLICT DO NOTHING), multi-mapping edge case (keeps membership
if another SCIM group maps same), and scim_managed flag (only removes
SCIM-managed memberships).

Also adds store wrapper methods for GetGroupIfActive,
AddGroupMemberSCIMManaged, and RemoveSCIMManagedGroupMember.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
scarson and others added 27 commits March 19, 2026 02:03
Add pre-flight check in deleteSSOHandler that looks up any SCIM config
linked to the SSO connection. If found, returns 409 Conflict with a
message directing the admin to remove SCIM config first. This prevents
accidental destruction of SCIM provisioning state and provides a clear
error instead of the FK RESTRICT 500.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per-org rate limiter for SCIM endpoints, separate from the API rate
limiter. Default 50 req/sec, configurable via SCIM_RATE_LIMIT env var.
Returns RFC 7644 SCIM error format on 429. Fires security event on
rate limit exceeded.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts:
#	internal/api/context.go
#	internal/secure/events.go
#	internal/store/generated/models.go
#	internal/store/generated/scim_config.sql.go
#	internal/store/queries/scim_config.sql
#	internal/store/scim_config.go
Both Lane A (middleware) and Lane C (types) independently implemented
writeSCIMError. Keep the canonical version in scim_types.go.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix wrong plan filename in Task 6 agent instructions
- Fix merge.Ingest signature (merge.Store interface, not *store.Store)
- Add NVD cursor requirement for Task 11 SeedCorpus
- Replace raw SQL seeding with merge pipeline in EPSS test
- Add Subagent Execution Protocol (TDD preamble, completion checks, review loops)
- Tighten assertions in Tasks 10A-10G with exact field names and nil checks
- Add phase review loop markers with specific verification checklists
- Fix discarded errors in test code (testing-pitfalls §16)
- Add FEED-5 warning to Task 7 ZIP iteration
- Scope test runs to changed packages only (Docker overload)
- Add autonomous decision log appendix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements all six SCIM 2.0 Group operations per RFC 7644:
- POST /Groups: create with optional initial members
- GET /Groups/{id}: read with member list
- GET /Groups: list with displayName/externalId/id eq filter
- PUT /Groups/{id}: full replace with member diff
- PATCH /Groups/{id}: add/remove members, replace displayName
- DELETE /Groups/{id}: cascade delete with role recomputation

Supports both standard and Entra ID PATCH remove formats
(path filter vs value array). Case-insensitive op handling.
Audit logging with SCIM metadata on all mutations.

Also adds GetSCIMGroupByDisplayName store wrapper and wires
SCIM group routes in server.go with requireSCIMAuth + rate limiting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement all 6 SCIM 2.0 User resource handlers per RFC 7643/7644:
- POST /Users: provision with 3-step identity matching (externalId, email, new)
- GET /Users/{id}: lookup with org-scoped RLS isolation
- GET /Users: list with filter support (userName, externalId, id, active) and pagination
- PUT /Users/{id}: full resource replacement (Okta's primary update path)
- PATCH /Users/{id}: partial update with case-insensitive ops and string boolean coercion
- DELETE /Users/{id}: soft-deactivate with sole-owner protection

Key behaviors:
- Transaction splitting: bypass RLS for users/identities, org-scoped for memberships
- SCIM-exempt users return success without modification (prevents IdP retry storms)
- Tier member limit enforcement on new provisioning
- Entra ID quirks: case-insensitive op names, string booleans, pathless PATCH ops

Store additions:
- UpdateUserEmail, UpdateUserDisplayName, UpdateUserProfile
- GetIdentityByProviderAndUser

23 integration tests covering provisioning, read/list, update, and deprovision paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… effect

Task 27: Admin SCIM config CRUD — POST/GET/PATCH/DELETE for SCIM config,
token rotation, SCIM group listing. Enterprise-tier-gated, owner-only for
mutations, admin+ for reads. Standard auth (cookie-based), RFC 9457 errors.
Security events fired on token create/rotate.

Task 28: PATCH group mapping with immediate recomputation — sets mapped_role
and/or mapped_group_id on SCIM groups. When mapping changes, all current
group members get immediate role recomputation and notification group sync.
Cross-org and soft-deleted group validation on mapped_group_id.

15 integration tests covering: config CRUD, SSO prerequisite, enterprise
tier gating, duplicate prevention, token masking, enable/disable, delete
idempotency, group survival on config delete, token rotation with hash
verification, RBAC matrix (viewer/admin/owner), role mapping with immediate
effect, notification group sync, mapping clear with default role fallback,
cross-org group rejection, soft-deleted group rejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add ServiceProviderConfig, Schemas, ResourceTypes static discovery
endpoints per RFC 7643/7644. Mount User CRUD handlers alongside
existing Group handlers under /api/v1/orgs/{org_id}/scim/v2/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add missing security event emissions for 4 SCIM event constants that
were defined but never emitted:
- EventSCIMUserProvisioned: fired on successful user creation/reactivation
- EventSCIMUserDeprovisioned: fired on successful user deactivation
- EventSCIMSoleOwnerProtected: fired when sole-owner protection blocks deactivation
- EventSCIMExemptSuppressed: fired when SCIM-exempt flag suppresses an operation

Add suppressed audit log entries for exempt user operations with
metadata: {"source": "scim", "suppressed": true, "reason": "scim_exempt"}

Add 8 integration tests verifying audit and security event correctness:
- UserProvisionAuditEntry: POST /Users creates audit with entity_type=member, action=create
- UserDeprovisionAuditEntry: DELETE /Users creates audit with action=update
- AuthFailureSecurityEvent: invalid token creates scim.auth_failed event
- TokenCreateSecurityEvent: POST /sso/scim creates scim.token_created event
- UserProvisionedSecurityEvent: POST /Users creates scim.user_provisioned event
- UserDeprovisionedSecurityEvent: DELETE /Users creates scim.user_deprovisioned event
- ExemptSuppressedSecurityEvent: PATCH exempt user creates scim.exempt_suppressed event
- SoleOwnerProtectedSecurityEvent: DELETE sole owner creates scim.sole_owner_protected event

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5 of 10 SCIM security event constants were defined but never emitted
(testing-pitfalls §7). Added fireSCIMEvent calls for:
- EventSCIMUserProvisioned (POST /Users new user creation)
- EventSCIMUserDeprovisioned (DELETE /Users deactivation)
- EventSCIMSoleOwnerProtected (all sole-owner protection paths)
- EventSCIMExemptSuppressed (all exempt-user skip paths)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts:
#	internal/api/scim_users.go
Seven e2e tests covering real IdP provisioning workflows:
- Full lifecycle (create → verify → deactivate → reactivate → delete)
- Group role mapping (add/remove member, verify role recomputation)
- Entra ID compatibility (capitalized ops, string booleans, value-array removal)
- Test connection patterns (Entra ID filter + Okta pagination)
- Cross-org isolation (user in org A invisible from org B)
- Token rotation (old token rejected, new token accepted)
- Error content-type (all errors return application/scim+json)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Documents 5 decisions made during overnight agent team execution:
config type choice, bypassTx for group methods, writeSCIMError duplicate
resolution, worktree isolation failure handling, suppressed audit entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Post-implementation review of Phase 7 SCIM found 6 actionable issues.
This commit addresses all of them:

S1: SCIM group store methods now accept orgID and use withOrgTx instead
    of withBypassTx, adding AND org_id to all SQL queries for defense-
    in-depth tenant isolation. All callers and tests updated.

S2: Extract shared scimScheme() helper that validates X-Forwarded-Proto
    (only "http"/"https" accepted). Fixes inconsistency between
    scimUserLocation and scimBaseURL.

S5: Replace N+1 per-member GetIdentityByProviderAndUser in list users
    with batch ListIdentitiesByProviderAndUsers using ANY($2::uuid[]).

P2: SCIM audit log actor_id now nil (SQL NULL) instead of uuid.Nil
    (all-zeros UUID), matching design doc and enabling IS NULL queries.

P3: matchesSCIMGroupFilter now logs slog.Warn for unsupported filter
    attributes instead of silently returning false.

P4: Entry-level slog.InfoContext with org_id/scim_config_id added to
    all 11 SCIM handlers for consistent observability.

All SCIM tests passing (auth: 3, store: 21, api: 55+). Lint clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GetSCIMGroup now queries with AND org_id, so groups from other orgs
return nil. The nil check already handles this — the manual
group.OrgID != orgID comparisons were dead code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same pattern as the SCIM group store refactor: withBypassTx → withOrgTx,
AND org_id added to SQL query. Removes redundant manual group.OrgID
check in patchSCIMGroupMappingHandler (now dead code — query returns
nil for cross-org groups).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The MSRC adapter's /cvrf/v3.0/csaf/{id} endpoint never existed on
Microsoft's API. Plan covers switching to the real CSAF 2.0 static
file distribution at msrc.microsoft.com/csaf/advisories/, capturing
golden fixtures, writing EPSS/MSRC golden tests, adding MSRC to
SeedCorpus, and completing Phase 10 verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
RemoveSCIMManagedGroupMember was missing orgID entirely — SQL had no
AND org_id clause and used withBypassTx, bypassing RLS. Added orgID
parameter and AND org_id = $3 to the DELETE query.

AddGroupMemberSCIMManaged already had orgID in the INSERT but used
withBypassTx. Switched to withOrgTx for dual-layer isolation.

Both methods now follow the same withOrgTx + AND org_id pattern as
all other org-scoped store methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…/pitfall/plan review) from scarson/fix/scim-review-hardening

fix(scim): harden Phase 7 SCIM after security/pitfall/plan review
Documents CVE-2026-3909CVE-2026-32194 substitution made during
overnight execution when the original CVE had no CSAF file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@scarson scarson merged commit e73d07b into main Mar 26, 2026
17 checks passed
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.

1 participant