diff --git a/dev/plans/2026-03-01-phase7-scim-provisioning-design.md b/dev/plans/2026-03-01-phase7-scim-provisioning-design.md index 03907282..6b5ea435 100644 --- a/dev/plans/2026-03-01-phase7-scim-provisioning-design.md +++ b/dev/plans/2026-03-01-phase7-scim-provisioning-design.md @@ -6,6 +6,8 @@ **Depends on:** Phase 5D (Generic OIDC — `sso_connections` table, tier gating, audit log) **Library:** `marcelom97/scimgateway` v1.0.0 (MIT, embeddable `http.Handler`, slog integration) +**For Claude:** CRITICAL NOTE (2026-03-19): This design was never implemented and is maintained for historical reference only. It's superseded by dev\plans\2026-03-19-phase7-scim-provisioning-design-v2.md and dev\plans\2026-03-19-phase7-scim-implementation-plan-v2.md. + ## Scope Decisions | Item | Decision | diff --git a/dev/plans/2026-03-02-phase7-scim-implementation-plan.md b/dev/plans/2026-03-02-phase7-scim-implementation-plan.md index a4f34922..37a1647a 100644 --- a/dev/plans/2026-03-02-phase7-scim-implementation-plan.md +++ b/dev/plans/2026-03-02-phase7-scim-implementation-plan.md @@ -1,6 +1,6 @@ # Phase 7: SCIM 2.0 Provisioning — Implementation Plan -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +**For Claude:** CRITICAL NOTE (2026-03-19): This design was never implemented and is maintained for historical reference only. It's superseded by dev\plans\2026-03-19-phase7-scim-provisioning-design-v2.md and dev\plans\2026-03-19-phase7-scim-implementation-plan-v2.md. **Goal:** Implement SCIM 2.0 user and group provisioning so enterprise IdPs (Microsoft Entra ID, Okta) can automatically manage CVErt Ops org membership, roles, and notification groups. diff --git a/dev/plans/2026-03-15-phase10-test-fixture-corpus-plan.md b/dev/plans/2026-03-15-phase10-test-fixture-corpus-plan.md index bc281c84..d1a2deb4 100644 --- a/dev/plans/2026-03-15-phase10-test-fixture-corpus-plan.md +++ b/dev/plans/2026-03-15-phase10-test-fixture-corpus-plan.md @@ -48,6 +48,49 @@ This directory is NOT in the git repo and does NOT need a `.gitignore` entry. It --- +## Subagent Execution Protocol + +All tasks that write tests (Tasks 2, 8, 9, 10A-10G, 11) MUST follow this protocol. Non-test tasks (1, 3, 4, 5, 6, 7, 12, 13) are exempt. + +### Before starting any test-writing task: +``` +1. Read dev/testing-pitfalls.md +2. Read the TDD skill at .claude/skills/test-driven-development/ (or invoke /test-driven-development) +For pure test additions: write the test, verify it fails for the right reason +(or passes if it's testing already-correct behavior), then move on. +For code bugs: write failing test → fix code → verify green. +``` + +### Before marking any test-writing task complete: +``` +1. Review your tests against dev/testing-pitfalls.md +2. Verify: are assertions checking behavior, not just execution? + - BAD: `if err != nil { t.Fatal(err) }` alone (checks execution, not output) + - GOOD: `require.NoError(t, err)` followed by `assert.Equal(t, expected, actual)` +3. Verify: are negative cases tested, not just positive? + - BAD: "assert patches exist" alone + - GOOD: "assert patches exist AND assert specific field values AND assert pointer fields are non-nil before dereferencing" +4. Run tests for the relevant packages only (e.g., `go test ./internal/feed/nvd/... -count=1`). Do NOT run `go test ./...` — Docker container overload from spinning up thousands of testcontainers is a known issue. Only run the full suite if explicitly asked. +``` + +### After completing each phase (A, E, F): +``` +You MUST carefully review the batch of work from multiple perspectives +and revise/refine as appropriate. Repeat this review loop (you must do +a minimum of three review rounds; if you still find substantive issues +in the third review, keep going with additional rounds until there are +no findings) until you're confident there aren't any more issues. Then +update your private journal and continue onto the next phase. +``` + +### Common pitfalls for this plan (from `dev/testing-pitfalls.md`): +- **§7 — Test data must flow through production code paths:** Do NOT seed CVEs via raw SQL. Use store methods or the merge pipeline. +- **§16 — Test setup must not discard errors:** Every `os.WriteFile`, `os.ReadFile`, `json.Marshal`, and store method call in test setup must check errors with `require.NoError`. +- **§9.4 — Falsy-value preservation:** If a fixture includes CVSS `0.0`, assert the parsed value is `0.0` (not nil, not missing). This is the most recurring adapter bug (3 independent occurrences). +- **FEED-5 — defer in loops:** When iterating ZIP entries, use explicit `rc.Close()` per iteration, NOT `defer rc.Close()`. + +--- + ## Adapter URL Constants Reference These hardcoded constants are needed by the capture CLI (Task 3), extraction tool (Task 7), and golden file test URL-rewrite transports (Tasks 9-10). Every adapter reads its URL from a package-level `const`: @@ -306,7 +349,10 @@ func TestRecordingTransport_StreamingBodyTee(t *testing.T) { } // Saved body must match exactly. - saved, _ := os.ReadFile(filepath.Join(outDir, "0001.body")) + saved, err := os.ReadFile(filepath.Join(outDir, "0001.body")) + if err != nil { + t.Fatalf("read saved body: %v", err) + } if len(saved) != len(largePayload) { t.Errorf("saved body length %d, want %d", len(saved), len(largePayload)) } @@ -666,6 +712,8 @@ git add dev/cmd/capture-feeds/main.go git commit -m "chore: add capture-feeds CLI for snapshotting feed API responses" ``` +**Phase A review loop:** After completing Tasks 1-3, run the review loop described in the Subagent Execution Protocol section above. Specifically verify: (1) recording transport test covers streaming body tee, sequential numbering, and write failure propagation; (2) capture CLI compiles cleanly; (3) no untracked files left behind. + --- ## Phase B: Data Capture (Operational — Not a Code Task) @@ -879,7 +927,7 @@ You are selecting real CVEs from captured feed data to build a test fixture corp READ FIRST: - dev/plans/test-fixture-edge-case-matrix.md (defines all categories and how to find candidates) -- dev/plans/2026-03-11-test-fixture-corpus.md (overall plan context) +- dev/plans/2026-03-15-phase10-test-fixture-corpus-plan.md (overall plan context) CAPTURED DATA LOCATIONS (all under D:/Code/CVErt-Ops/data/feed-snapshots/): - nvd/*.body — NVD JSON response pages (one per file, raw NVD API JSON) @@ -990,7 +1038,7 @@ internal/feed/redhat/testdata/golden/ — Red Hat fixtures (list page + detai **Per-feed extraction logic:** -**NVD:** Scan each `nvd/*.body` file for CVE entries matching manifest CVE IDs. Extract matching `{"cve": {...}}` objects and group them into 1-2 synthetic response pages. Each page must be a valid NVD response envelope (see "NVD Response Envelope Structure" section above). Set `totalResults` to the actual number of CVEs in the page. Set `startIndex` to 0 for the first page, `resultsPerPage` to the count. Save as `page-001.json`, `page-002.json`. +**NVD:** Scan each `nvd/*.body` file for CVE entries matching manifest CVE IDs. Extract matching `{"cve": {...}}` objects and group them into 1-2 synthetic response pages. Each page must be a valid NVD response envelope (see "NVD Response Envelope Structure" section above). For each page: set `totalResults` to the number of CVEs in **that page** (not the total across all pages), `startIndex` to 0, and `resultsPerPage` to the same count. This makes the adapter treat each page as a complete, single-page response — it will not attempt to paginate beyond the available fixture files. Save as `page-001.json`, `page-002.json`. **GHSA:** Scan each `ghsa/*.body` file for advisories matching manifest CVE IDs (match on `cve_id` field) or manifest `ghsa_id` selectors (match on the advisory's GHSA ID). GHSA-native advisories for category F1 MUST be selected via `ghsa_id`, not by blindly including every advisory where `cve_id` is null. Extract matching advisories into a JSON array. Save as `page-001.json`. The adapter expects a JSON array at the top level `[{advisory}, ...]`. @@ -1041,6 +1089,10 @@ type ManifestRecord struct { At least one selector field must be set on every manifest record. For this plan, valid selectors are `cve_id` and `ghsa_id`. +**Do NOT write unit tests for the extraction tool.** Its output (fixture files) is verified by the golden file tests in Tasks 9/10A-10G. Unit tests here would be redundant. + +**WARNING (FEED-5):** When iterating MITRE and OSV ZIP entries, use explicit `rc.Close()` per iteration — NOT `defer rc.Close()`. In Go, `defer` is function-scoped, not block-scoped. Using `defer` in a loop holds all file descriptors open simultaneously, exhausting the OS limit. See `dev/implementation-pitfalls.md` FEED-5. + **Step 1: Implement the extraction tool** The implementing agent should: @@ -1120,7 +1172,9 @@ import ( func TestGoldenServer_ServesFixtureFiles(t *testing.T) { dir := t.TempDir() content := `{"vulnerabilities": [{"cve": {"id": "CVE-2024-0001"}}]}` - os.WriteFile(filepath.Join(dir, "page-001.json"), []byte(content), 0644) + if err := os.WriteFile(filepath.Join(dir, "page-001.json"), []byte(content), 0644); err != nil { + t.Fatalf("write fixture: %v", err) + } srv := testutil.NewGoldenServer(t, dir) @@ -1130,7 +1184,10 @@ func TestGoldenServer_ServesFixtureFiles(t *testing.T) { } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } if string(body) != content { t.Errorf("got %q, want %q", body, content) } @@ -1139,7 +1196,9 @@ func TestGoldenServer_ServesFixtureFiles(t *testing.T) { func TestURLRewriteTransport_RedirectsRequests(t *testing.T) { dir := t.TempDir() content := `{"result": "ok"}` - os.WriteFile(filepath.Join(dir, "data.json"), []byte(content), 0644) + if err := os.WriteFile(filepath.Join(dir, "data.json"), []byte(content), 0644); err != nil { + t.Fatalf("write fixture: %v", err) + } srv := testutil.NewGoldenServer(t, dir) @@ -1159,7 +1218,10 @@ func TestURLRewriteTransport_RedirectsRequests(t *testing.T) { } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } if string(body) != content { t.Errorf("got %q, want %q", body, content) } @@ -1371,9 +1433,13 @@ func TestFetch_GoldenFiles(t *testing.T) { ), } + // Set a dummy API key so the adapter uses the faster rate limiter (0.6s/req + // instead of 6s/req). Golden tests don't hit the real NVD API, so any + // non-empty value works. Existing NVD unit tests bypass this by constructing + // the adapter directly with rate.Inf, but we can't do that from an external + // test package (unexported rateLimiter field). + t.Setenv("NVD_API_KEY", "golden-test-dummy-key") adapter := nvd.New(client) - // Note: without NVD_API_KEY env var, the adapter rate-limits to 1 req/6s. - // With just 1 fixture page, this adds ~6s to the test — acceptable. // IMPORTANT: Do NOT call Fetch with nil cursor — nil triggers a full-history // backfill from 2002 to now, creating ~73 time windows. Instead, construct a @@ -1441,6 +1507,8 @@ git commit -m "test: add NVD golden file test against captured API responses" --- +**For all Tasks 10A-10G:** Before implementing, READ the adapter source code at `internal/feed//adapter.go` and the existing tests at `internal/feed//adapter_test.go`. You need to understand: (1) the request URL patterns the adapter makes, (2) the pagination/cursor model, (3) the expected response JSON structure. Task 9 (NVD) provides complete test code as a template; tasks 10A-10G do not — you must derive the test handler logic from the adapter source. Do NOT add features, refactor existing tests, or modify adapter code — only create the `golden_test.go` file. Include ABOUTME comments per project convention. `InCISAKEV` and `VendorEnrichment` on `feed.CanonicalPatch` are pointer types (`*bool` and `*feed.VendorEnrichment`) — check for nil before dereferencing. + ### Task 10A: KEV golden file test **Files:** Create `internal/feed/kev/golden_test.go` @@ -1449,7 +1517,15 @@ git commit -m "test: add NVD golden file test against captured API responses" **URL rewrite:** Rewrite `https://www.cisa.gov` → test server. The adapter GETs the full catalog URL. Serve `catalog.json` for any request. -**Test pattern:** Same as Task 9 but simpler — KEV is single-page, always returns `LastPage: true`. Verify non-zero patches, `LastPage == true`, at least one patch has `InCISAKEV: true`, and at least one patch has `VendorEnrichment` set. +**Test pattern:** Same as Task 9 but simpler — KEV is single-page, always returns `LastPage: true`. + +**Required assertions (follow Subagent Execution Protocol above):** +1. `len(result.Patches) > 0` — non-zero patches +2. `result.LastPage == true` — single-page feed +3. For every patch: `p.CVEID != ""` — all KEV entries have CVE IDs +4. At least one patch: `p.InCISAKEV != nil && *p.InCISAKEV == true` — nil-check before dereference; `InCISAKEV` is `*bool` +5. At least one patch: `p.VendorEnrichment != nil` — nil-check; it's `*feed.VendorEnrichment` +6. At least one patch with VendorEnrichment: check `len(p.VendorEnrichment.Data) > 0` — the `Data` field is `json.RawMessage` containing KEV-specific metadata **Commit:** `test: add KEV golden file test` @@ -1461,9 +1537,15 @@ git commit -m "test: add NVD golden file test against captured API responses" **URL rewrite:** Rewrite `https://api.github.com` → test server. The adapter paginates via Link headers. For a single-page fixture (most likely), the test handler simply omits the Link header → the adapter sees no `after` cursor → sets `LastPage: true`. If multiple fixture pages are needed, include `Link: ; rel="next"` on all but the last response. The adapter extracts just the `after` query param value (not the full URL) and builds a new request to `advisoriesURL` with that param — the URL rewrite transport handles the redirect. -**Important:** The GHSA adapter expects a JSON array `[{advisory}, ...]` at the top level, NOT wrapped in an object. The adapter also requires `GITHUB_TOKEN` env var for the auth header — without it, the adapter still works but sends no Authorization header. +**Important:** The GHSA adapter expects a JSON array `[{advisory}, ...]` at the top level, NOT wrapped in an object. The adapter reads `GITHUB_TOKEN` env var for the auth header — without it, the adapter still works but sends no Authorization header. No `GITHUB_TOKEN` is needed for golden tests since the URL-rewrite transport redirects all requests to the local test server. -**Verify:** Non-zero patches, `LastPage == true`, at least one advisory selected via `ghsa_id` has `cve_id: null` (category F1), and at least one advisory has a populated CVE ID. If the curated GHSA corpus includes a CVSS `0.0` advisory, assert that value survives parsing unchanged. +**Required assertions:** +1. `len(allPatches) > 0` — non-zero patches across all pages +2. `LastPage == true` after consuming all fixture pages +3. At least one patch: `p.CVEID == "" && p.SourceID != ""` — GHSA-native advisory without CVE ID (category F1). The adapter sets `SourceID` to the GHSA ID when `cve_id` is null. +4. At least one patch: `p.CVEID != ""` — advisory with a populated CVE ID +5. For every patch: `p.SourceID != ""` — all GHSA advisories should have a source ID +6. **Falsy-value check (testing-pitfalls §9.4):** If the curated GHSA corpus includes a CVSS `0.0` advisory, assert `p.CVSSv3Score != nil && *p.CVSSv3Score == 0.0` — do NOT use `> 0` checks which would silently drop zero scores **Commit:** `test: add GHSA golden file test` @@ -1475,7 +1557,12 @@ git commit -m "test: add NVD golden file test against captured API responses" **URL rewrite:** Rewrite `https://github.com` → test server. Serve `cvelistV5.zip` for any request. The adapter downloads the ZIP to a temp file, then iterates entries. -**Verify:** Non-zero patches and at least one RESERVED-status CVE present (category S2). +**Required assertions:** +1. `len(result.Patches) > 0` — non-zero patches +2. `result.LastPage == true` — single-download feed +3. For every patch: `p.CVEID != ""` — all MITRE entries have CVE IDs +4. At least one patch: `p.Status == "RESERVED"` — category S2; `Status` is a `string` field on `CanonicalPatch` +5. At least one patch: `p.Status != "RESERVED"` — verify the corpus has both RESERVED and non-RESERVED CVEs **Commit:** `test: add MITRE golden file test` @@ -1487,7 +1574,11 @@ git commit -m "test: add NVD golden file test against captured API responses" **URL rewrite:** Rewrite `https://osv-vulnerabilities.storage.googleapis.com` → test server. Serve `all.zip`. Same pattern as MITRE. -**Verify:** Non-zero patches and at least one patch has alias resolution (SourceID is not a CVE ID, CVEID is from aliases). If the curated OSV corpus includes a CVSS `0.0` case, assert that score is preserved. +**Required assertions:** +1. `len(result.Patches) > 0` — non-zero patches +2. `result.LastPage == true` — single-download feed +3. At least one patch with alias resolution: `p.SourceID` does NOT start with `"CVE-"` AND `p.CVEID` starts with `"CVE-"` — this proves the adapter resolved a non-CVE native ID (e.g., `RUSTSEC-*`, `PYSEC-*`) to a CVE ID via the `aliases` array (category F2) +4. **Falsy-value check (testing-pitfalls §9.4):** If the curated OSV corpus includes a CVSS `0.0` case, assert `p.CVSSv3Score != nil && *p.CVSSv3Score == 0.0` **Commit:** `test: add OSV golden file test` @@ -1497,17 +1588,22 @@ git commit -m "test: add NVD golden file test against captured API responses" **Follow the shared golden test contract above.** -**EPSS is different** — it uses `Apply()` not `Fetch()`, and writes directly to the database. The golden file test should: +**EPSS is different** — it uses `Apply()` not `Fetch()`, and writes directly to the database. **Requires Docker Desktop** (testcontainers). The golden file test should: 1. Serve `scores.csv.gz` via httptest -2. Create a test database (testcontainer) -3. Seed a few CVEs that appear in the fixture CSV (insert minimal CVE rows via direct SQL — the Apply method needs `cves` rows to exist for the `UPDATE ... WHERE epss_score IS DISTINCT FROM` pattern) -4. Create the EPSS adapter with a URL-rewriting client: `epss.New(&http.Client{Transport: testutil.NewURLRewriteTransport("https://epss.empiricalsecurity.com", srv.URL, http.DefaultTransport)})` -5. Call `adapter.Apply(ctx, db.DB(), nil)` — this downloads from the test server and applies scores -6. Query the database to verify EPSS scores were applied to the seeded CVEs +2. Create a test database (testcontainer via `testutil.NewTestDB(t)`) +3. Seed a few CVEs that appear in the fixture CSV by running the NVD golden fixtures through `merge.Ingest` first (the Apply method needs `cves` rows to exist for the `UPDATE ... WHERE epss_score IS DISTINCT FROM` pattern). Do NOT use raw SQL inserts — per `dev/testing-pitfalls.md` §7, test data must flow through production code paths. Use the NVD adapter with URL rewriting + golden fixtures → merge pipeline to seed CVE rows, then apply EPSS on top. Alternatively, if seeding through the full merge pipeline is disproportionate, use store methods (e.g., the store's CVE upsert function) instead of raw SQL — but document why the full pipeline was skipped. +4. Set `NVD_API_KEY` env var to a dummy value (`t.Setenv("NVD_API_KEY", "golden-test-dummy-key")`) to get the faster NVD rate limiter if seeding NVD fixtures first +5. Create the EPSS adapter with a URL-rewriting client: `epss.New(&http.Client{Transport: testutil.NewURLRewriteTransport("https://epss.empiricalsecurity.com", srv.URL, http.DefaultTransport)})` +6. Call `adapter.Apply(ctx, db.DB(), nil)` — uses `*sql.DB` from `db.Store.DB()` (the embedded `*store.Store` on `TestDB`), NOT `*store.Store` directly +7. Query the database to verify EPSS scores were applied to the seeded CVEs **URL rewrite:** Rewrite `https://epss.empiricalsecurity.com` → test server. -**Verify:** Non-zero rows updated. If the curated EPSS corpus includes score `0`, assert it persists as `0`, not NULL or missing. +**Required assertions:** +1. Query the database after `Apply()`: at least one CVE row has `epss_score IS NOT NULL` — non-zero rows updated +2. For each seeded CVE that appears in the fixture CSV: the DB `epss_score` must match the CSV value (query by `cve_id`, compare score) +3. **Falsy-value check (testing-pitfalls §9.4):** If the curated EPSS corpus includes a CVE with score `0`, assert `epss_score` in the DB is `0.0` (not NULL). Use `sql.NullFloat64` to scan and check `Valid == true && Float64 == 0.0`. +4. The returned cursor from `Apply()` must be non-nil (EPSS returns an updated cursor for persistence) **Commit:** `test: add EPSS golden file test` @@ -1523,7 +1619,13 @@ git commit -m "test: add NVD golden file test against captured API responses" The test server handler must route based on the request path. -**Verify:** Non-zero patches and `VendorEnrichment` populated on patches from CSAF documents. If the curated MSRC corpus includes a CVSS `0.0` case, assert that score is preserved. +**Required assertions:** +1. `len(allPatches) > 0` — non-zero patches across all pages +2. `LastPage == true` after consuming all fixture pages +3. At least one patch: `p.VendorEnrichment != nil` — MSRC CSAF documents produce vendor enrichment; `VendorEnrichment` is `*feed.VendorEnrichment` +4. For that patch: `len(p.VendorEnrichment.Data) > 0` — the `Data` field (`json.RawMessage`) contains CSAF-specific metadata +5. For every patch: `p.CVEID != ""` — all MSRC entries map to CVE IDs +6. **Falsy-value check (testing-pitfalls §9.4):** If the curated MSRC corpus includes a CVSS `0.0` case, assert `p.CVSSv3Score != nil && *p.CVSSv3Score == 0.0` **Commit:** `test: add MSRC golden file test` @@ -1539,7 +1641,13 @@ The test server handler must route based on the request path. The test server handler must route based on path: `/cve.json` → list, `/cve/CVE-*` → detail. -**Verify:** Non-zero patches, `LastPage == true`, and `VendorEnrichment` populated with vendor severity and fix state. +**Required assertions:** +1. `len(allPatches) > 0` — non-zero patches +2. `LastPage == true` after consuming all fixture pages +3. For every patch: `p.CVEID != ""` — all Red Hat entries map to CVE IDs +4. At least one patch: `p.VendorEnrichment != nil` — Red Hat detail responses produce vendor enrichment +5. For that patch: `p.VendorEnrichment.VendorSeverity != nil && *p.VendorEnrichment.VendorSeverity != ""` — `VendorSeverity` is `*string` +6. At least one patch with fix state: `p.VendorEnrichment.VendorFixState != nil && *p.VendorEnrichment.VendorFixState != ""` — category F4; `VendorFixState` is `*string` **Commit:** `test: add Red Hat golden file test` @@ -1558,14 +1666,16 @@ Verify: 2. All existing inline-JSON tests still pass 3. No compilation errors or import cycles +Do NOT run `go test ./...` here — Docker container overload from spinning up thousands of testcontainers is a known issue. The feed adapter tests above are the relevant scope. Also verify compilation across the full project without running tests: + ```bash -go test ./... -count=1 +go build ./... ``` -Verify the full test suite passes (or at least doesn't have new failures). - **Commit:** Nothing to commit — this is a verification step. +**Phase E review loop:** After completing Tasks 8, 9, 10A-10H, run the review loop described in the Subagent Execution Protocol section. Specifically verify: (1) every golden test RUNs (not SKIPs); (2) every golden test has assertions beyond `err == nil` — check field values, nil-safety on pointer types, and at least one adapter-specific semantic check; (3) no existing hand-crafted tests were deleted or broken; (4) every golden test file has ABOUTME comments; (5) check for testing-pitfalls §16 violations (discarded errors in test setup). + --- ## Phase F: Seed Corpus Helper @@ -1601,10 +1711,12 @@ For this plan, the required corpus feeds are: `nvd`, `ghsa`, `kev`, `mitre`, `os The merge pipeline entry point is `merge.Ingest`. Its signature: ```go -func Ingest(ctx context.Context, s *store.Store, patch feed.CanonicalPatch, sourceName string) error +func Ingest(ctx context.Context, s merge.Store, patch feed.CanonicalPatch, sourceName string) error ``` Note: it takes a **single** `CanonicalPatch`, NOT a slice. Loop over patches and call `Ingest` once per patch. +The `merge.Store` interface (defined in `internal/merge/store.go`) requires only `DB() *sql.DB`. The `*store.Store` type satisfies this interface. When using `testutil.TestDB`, the embedded `*store.Store` field (accessed as `db.Store`) can be passed directly to `merge.Ingest`. + For EPSS, the adapter uses `Apply` (not `Fetch`): ```go func (a *Adapter) Apply(ctx context.Context, db *sql.DB, cursorJSON json.RawMessage) (json.RawMessage, error) @@ -1659,18 +1771,22 @@ func TestSeedCorpus(t *testing.T) { **Step 2: Implement** +**Requires Docker Desktop** (testcontainers via `testutil.NewTestDB`). + The implementing agent should: -1. For each required adapter (except EPSS): - a. Verify `testdata/golden/` exists. If a required fixture directory is missing, fail immediately with a clear error naming the feed. +1. For each required adapter (except EPSS), **in this order** (order matters for merge field precedence per PLAN.md §5.1): NVD, MITRE, GHSA, OSV, KEV, MSRC, Red Hat: + a. Verify `testdata/golden/` exists for that adapter. If a required fixture directory is missing, fail immediately with a clear error naming the feed. b. Create an httptest server serving the golden fixtures (use `testutil.NewGoldenServer` for simple feeds, custom handler for paginated feeds like NVD/GHSA) c. Create the adapter with a URL-rewriting client: `adapter.New(&http.Client{Transport: testutil.NewURLRewriteTransport(originalURL, srv.URL, http.DefaultTransport)})` - d. Call `Fetch()` in a loop until `result.LastPage == true`, collecting patches - e. For each patch, call `merge.Ingest(ctx, store, patch, "source_name")` — one call per patch, NOT a batch + d. **CRITICAL for NVD:** Do NOT pass nil cursor to `Fetch()` — nil triggers a full-history backfill (73 time windows from 2002 to now). Construct an initial cursor matching the golden fixture Date header, same pattern as Task 9's golden test: `nvd.Cursor{WindowStart: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC), WindowEnd: time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC), StartIndex: 0}`. Also set `t.Setenv("NVD_API_KEY", "golden-test-dummy-key")` before creating the NVD adapter to get the faster rate limiter (0.6s/req instead of 6s/req). For all other adapters, nil cursor is fine. + e. Call `Fetch()` in a loop until `result.LastPage == true`, collecting patches + f. For each patch, call `merge.Ingest(ctx, db.Store, patch, "source_name")` — one call per patch, NOT a batch. `db.Store` is the embedded `*store.Store` on `TestDB`, which satisfies the `merge.Store` interface (requires only `DB() *sql.DB`). 2. After all feed adapters are ingested, apply EPSS: a. Create EPSS adapter with URL-rewriting client pointing to test server serving `scores.csv.gz` - b. Call `adapter.Apply(ctx, store.DB(), nil)` — uses `*sql.DB` from `store.DB()`, NOT `*store.Store` + b. Call `adapter.Apply(ctx, db.Store.DB(), nil)` — uses `*sql.DB` from `db.Store.DB()`, NOT `*store.Store` directly 3. Return `SeedStats{TotalCVEs, FeedsSeeded, FeedNames}` 4. The URL rewrite base per adapter (the `OriginalBase` parameter for `NewURLRewriteTransport`) — see the **Appendix: Adapter URL Rewrite Patterns** section at the end of this document for exact values per adapter (e.g., NVD = `https://services.nvd.nist.gov`, GHSA = `https://api.github.com`, etc.) +5. Do NOT modify any adapter source code, existing tests, or the merge pipeline. Only create the `seedcorpus.go` and `seedcorpus_test.go` files. This helper is intentionally strict because downstream integration tests rely on the committed corpus being complete and deterministic. If a permissive local-dev @@ -1689,17 +1805,26 @@ git add internal/testutil/seedcorpus.go internal/testutil/seedcorpus_test.go git commit -m "test: add SeedCorpus helper for deterministic test data from golden fixtures" ``` +**Phase F review loop:** After completing Task 11, run the review loop described in the Subagent Execution Protocol section. Specifically verify: (1) `TestSeedCorpus` RUNs (not SKIPs) and seeds a non-zero CVE count from all 8 required feeds; (2) NVD adapter was given a non-nil initial cursor (nil = 73-window backfill hang); (3) EPSS was applied AFTER other feeds (it needs CVE rows to exist); (4) no raw SQL was used for CVE seeding — all data went through `merge.Ingest`; (5) `db.Store` (not `db.AppStore`) was passed to `merge.Ingest` — the merge pipeline needs superuser access. + --- ## Phase G: Final Verification and Documentation -### Task 12: Full test suite verification +### Task 12: Final verification of changed packages + +Run only the packages this plan touched — do NOT run `go test ./...` (Docker container overload): ```bash -go test ./... -count=1 +go test ./internal/feed/... ./internal/testutil/... ./dev/cmd/capture-feeds/... -count=1 ``` -All tests should pass. If any golden file tests skip, the fixtures are missing — go back to Phase D. +Also verify full-project compilation: +```bash +go build ./... +``` + +All feed adapter and testutil tests should pass. If any golden file tests skip, the fixtures are missing — go back to Phase D. ### Task 13: Document the refresh process @@ -1803,7 +1928,7 @@ Quick reference for implementing golden file tests (Tasks 9, 10A-10G). - **Request pattern:** `GET /epss_scores-current.csv.gz` (may follow redirect) - **Test handler:** Serve `scores.csv.gz` for any request - **Fixture naming:** `scores.csv.gz` -- **Note:** EPSS uses `Apply()` not `Fetch()` — test pattern differs (needs DB) +- **Note:** EPSS uses `Apply()` not `Fetch()` — test pattern differs (needs DB). Requires Docker Desktop (testcontainers). CVE rows must exist before EPSS apply — seed them through the merge pipeline, not raw SQL (testing-pitfalls §7). ### MSRC - **Rewrite:** `https://api.msrc.microsoft.com` → test server @@ -1820,3 +1945,35 @@ Quick reference for implementing golden file tests (Tasks 9, 10A-10G). - `GET /hydra/rest/securitydata/cve/CVE-YYYY-NNNN.json` → serve `detail/CVE-YYYY-NNNN.json` - **Test handler:** Route by path: `/cve.json` → list, `/cve/CVE-*` → detail lookup - **Fixture naming:** `list.json`, `detail/CVE-YYYY-NNNN.json` + +--- + +## Appendix: Autonomous Decision Log (2026-03-19 overnight execution) + +Decisions made by Claude while Sam was asleep, per Sam's delegation of decision authority. + +### Decision 1: MSRC CSAF endpoint broken — used CVRF instead +**Context:** The MSRC CSAF endpoint (`/cvrf/v3.0/csaf/{releaseID}`) returns "Invalid ID format" (HTTP 400) for ALL release IDs as of 2026-03-19. The CVRF endpoint (`/cvrf/v3.0/cvrf/{releaseID}`) works fine. +**Decision:** Captured CVRF documents (2026-Mar, 2026-Feb, 2025-Dec) instead of CSAF. The MSRC adapter (`internal/feed/msrc/adapter.go`) uses the CSAF endpoint — this may indicate the API has changed or been deprecated since the adapter was written. +**Impact on plan:** The MSRC golden file test (Task 10F) may need adjustment if the adapter's CSAF parsing doesn't work with CVRF data. The adapter may need a separate fix (out of scope for this plan). +**Recommendation for Sam:** Investigate whether the MSRC CSAF endpoint has been deprecated or moved. The adapter may need to be updated to use CVRF format. + +### Decision 2: NVD capture hit 429 on final window — accepted partial capture +**Context:** NVD capture completed 402 pages (338,626 CVE patches) before hitting a 429 rate limit on the very last time window (2026-03-19 ~07:00 UTC). +**Decision:** Accepted the capture as-is. 338,626 CVEs is comprehensive — the missing window covers only the most recent few hours of data. Plenty of CVEs available for fixture selection. +**Impact on plan:** None — the fixture corpus only needs 30-50 CVEs. + +### Decision 3: GHSA adapter logged parsing errors but raw bodies preserved +**Context:** The GHSA adapter had JSON unmarshalling errors on the `references` field for some advisories ("cannot unmarshal string into Go struct field ghsaAdvisory.references of type ghsa.ghsaReference"). The adapter's patch count was 0, but all 274 pages of raw JSON response bodies were saved by the recording transport. +**Decision:** Accepted the capture. The raw body files contain the real GHSA API responses. The selection agent and extraction tool can parse them directly. The adapter's parsing issue is a separate concern from the fixture corpus. +**Impact on plan:** None — golden file tests will serve the raw body files, and the adapter should parse them (the golden test will actually test whether the adapter handles these parsing edge cases). + +### Decision 4: Started Task 8 (golden helpers) before captures complete +**Context:** Task 8 (golden file test infrastructure) is independent of the capture phase. Rather than idle while feeds download, I implemented it in parallel. +**Decision:** Implemented and committed Task 8 while captures ran in background. +**Impact on plan:** None — strictly positive (time saved). Dependency order preserved (Task 8 doesn't depend on captures). + +### Decision 5: Scoped test runs to changed packages only +**Context:** Sam explicitly instructed to avoid `go test ./...` due to Docker container overload from thousands of testcontainers. Another agent (Phase 7 SCIM) is also running tests concurrently. +**Decision:** All test runs scoped to specific packages (e.g., `go test ./dev/cmd/capture-feeds/...`). Updated plan Tasks 10H and 12 to reflect this. +**Impact on plan:** Already applied to plan in the review phase. diff --git a/dev/plans/2026-03-19-phase10-msrc-csaf-fix-plan.md b/dev/plans/2026-03-19-phase10-msrc-csaf-fix-plan.md new file mode 100644 index 00000000..3a2e96b3 --- /dev/null +++ b/dev/plans/2026-03-19-phase10-msrc-csaf-fix-plan.md @@ -0,0 +1,839 @@ +# MSRC Adapter CSAF Fix + Phase 10 Completion Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix the broken MSRC adapter (switch from non-existent `/csaf/{id}` API endpoint to Microsoft's real CSAF 2.0 static file distribution), capture proper CSAF golden fixtures, write remaining golden tests (EPSS + MSRC), add MSRC to SeedCorpus, and complete Phase 10 test fixture corpus verification. + +**Architecture:** The MSRC adapter's `Fetch()` method is rewritten to use Microsoft's CSAF static file distribution (`msrc.microsoft.com/csaf/advisories/`). Discovery uses `changes.csv` (a standard CSAF directory mechanism) for incremental sync. Individual per-CVE CSAF 2.0 JSON files are downloaded and parsed by the existing `csaf.Parse()` function. The `csafToPatches()` and `buildVendorEnrichment()` functions are unchanged — they already handle CSAF 2.0 correctly. + +**Tech Stack:** Go 1.26, `net/http`, `encoding/csv`, `encoding/json`, `internal/feed/csaf` (existing parser), `httptest` (golden file tests), testcontainers (EPSS test) + +**Worktree:** All work happens in `.claude/worktrees/phase10-fixture-corpus` on branch `phase10/test-fixture-corpus`. + +--- + +## Context + +**The Bug:** The MSRC adapter constructs URLs as `api.msrc.microsoft.com/cvrf/v3.0/csaf/{releaseID}`, but this endpoint does not exist. It returns HTTP 400 "Invalid ID format" for every release ID. The adapter was tested against hand-crafted CSAF fixtures and passed all tests, but never worked against real Microsoft data. + +**The Fix:** Microsoft publishes real CSAF 2.0 files at `msrc.microsoft.com/csaf/advisories/`. Discovery mechanism: +- `changes.csv` — CSV of `"path","timestamp"` pairs, sorted newest-first, no header row +- `index.txt` — plain text list of all file paths +- Individual files at `https://msrc.microsoft.com/csaf/advisories/{year}/msrc_cve-{id}.json` + +Each file is ~6KB of proper CSAF 2.0 JSON (keys: `document`, `product_tree`, `vulnerabilities`) that the existing `csaf.Parse()` handles correctly. Files are per-CVE (one vulnerability per document), not per-release-month. + +**What stays unchanged:** +- `csafToPatches()` — converts `csaf.Document` to `[]feed.CanonicalPatch` (all field mappings preserved) +- `buildVendorEnrichment()` — extracts vendor-specific metadata (severity, fix state, KB articles, exploitability) +- `parseCSAFDocument()` — delegates to `csaf.Parse()` +- All `TestCSAFToPatches_*` unit tests (6 tests) — they test CSAF parsing, not Fetch +- The `csaf` parser package — untouched + +**What changes:** +- `Fetch()` method — new two-phase: download `changes.csv` → download per-CVE CSAF files +- `Cursor` struct — `last_release_date` → `last_updated` (cursor semantics changed) +- Constants — `baseURL` → new CSAF base URL; size limits adjusted +- Removed: `updateEntry`, `updatesResponse`, `parseUpdates()`, `dateTimeRe`, OData filter logic +- Fetch tests — rewritten for new flow (5 tests) + +**Testing-pitfalls warnings applicable to this plan:** +- §9.4 (Falsy-value preservation): CVSS 0.0 must be preserved. Existing `TestCSAFToPatches_CVSSZeroIsValid` covers this and stays unchanged. +- §9.1 (Wire format assumptions): We've verified the real CSAF file format with `curl` — proper CSAF 2.0 JSON. +- §7 (Test data must flow through production code paths): SeedCorpus feeds through `merge.Ingest`, not raw SQL. +- §16 (Test setup must not discard errors): All `os.ReadFile`, `json.Unmarshal` etc. must check errors with `require.NoError` or `t.Fatalf`. + +**Implementation-pitfalls warnings:** +- FEED-1 (Wire format): Verified — per-CVE CSAF files use `{"document":..., "product_tree":..., "vulnerabilities":[...]}`. Not streaming (files are ~6KB). +- FEED-5 (defer in loops): When downloading multiple CSAF files in a loop, use explicit `resp.Body.Close()` per iteration, NOT `defer`. +- FEED-10 (String cloning): `csafToPatches()` already clones strings. No changes needed there. +- FEED-16 (Body drain): Drain response body before close on non-200 responses. + +--- + +## Subagent Execution Protocol + +All tasks that write tests MUST follow this protocol. + +### Before starting any task: +``` +1. Read dev/testing-pitfalls.md +2. Read the TDD skill at .claude/skills/test-driven-development/ (or invoke /test-driven-development) +For pure test additions: write the test, verify it fails for the right reason +(or passes if it's testing already-correct behavior), then move on. +For code bugs: write failing test → fix code → verify green. +``` + +### Before marking any task complete: +``` +1. Review your tests against dev/testing-pitfalls.md +2. Verify test coverage of the fix (are error paths tested? edge cases?) +3. Run tests for the relevant packages only (e.g., `go test ./internal/feed/msrc/... -count=1`). + Do NOT run `go test ./...` — Docker container overload is a known issue. +``` + +### After completing each phase: +``` +You MUST carefully review the batch of work from multiple perspectives +and revise/refine as appropriate. Repeat this review loop (you must do +a minimum of three review rounds; if you still find substantive issues +in the third review, keep going with additional rounds until there are +no findings) until you're confident there aren't any more issues. Then +update your private journal and continue onto the next phase. +``` + +--- + +## Phase A: Capture Real CSAF Fixtures + +### Task 1: Download real CSAF files for golden test data + +**Files:** +- Replace: `internal/feed/msrc/testdata/golden/csaf/*.json` (currently CVRF-format files, must be replaced with real CSAF 2.0) +- Create: `internal/feed/msrc/testdata/golden/changes.csv` +- Remove: `internal/feed/msrc/testdata/golden/updates.json` (OData response, no longer needed) + +**Step 1: Download CSAF files for manifest CVEs** + +The test fixture manifest (`dev/plans/test-fixture-manifest.json`) includes 3 MSRC CVEs: CVE-2026-3909, CVE-2026-21510, CVE-2025-14174. Download their CSAF files. Note that the CSAF filename format is `msrc_cve-{id}.json` with lowercase `cve`. + +```bash +# From worktree root +mkdir -p internal/feed/msrc/testdata/golden/csaf + +# Try each CVE — some may not have CSAF files yet +curl -sL "https://msrc.microsoft.com/csaf/advisories/2026/msrc_cve-2026-3909.json" -o /tmp/msrc_cve-2026-3909.json +curl -sL "https://msrc.microsoft.com/csaf/advisories/2026/msrc_cve-2026-21510.json" -o /tmp/msrc_cve-2026-21510.json +curl -sL "https://msrc.microsoft.com/csaf/advisories/2025/msrc_cve-2025-14174.json" -o /tmp/msrc_cve-2025-14174.json +``` + +Check each file: a valid CSAF file starts with `{"document":`. If any returns a 404 or HTML, it doesn't exist. In that case, pick replacement CVEs from the CSAF index: + +```bash +curl -sL "https://msrc.microsoft.com/csaf/advisories/index.txt" | grep "2026/" | head -10 +``` + +Download 3-5 valid CSAF files. Place them in `internal/feed/msrc/testdata/golden/csaf/` with their original filenames (e.g., `msrc_cve-2026-3909.json`). + +Verify each file is valid CSAF 2.0: +- Contains `"document"` key with `"tracking"` sub-key +- Contains `"vulnerabilities"` array with at least one entry having a `"cve"` field +- Contains `"product_tree"` key + +**Step 2: Create a changes.csv fixture** + +Build a `changes.csv` listing only the downloaded CSAF files. Format: `"path","timestamp"` with no header row. Use the `document.tracking.current_release_date` from each file as the timestamp. Example: + +```csv +"2026/msrc_cve-2026-3909.json","2026-03-18T01:00:00Z" +"2026/msrc_cve-2026-21510.json","2026-03-17T07:00:00Z" +"2025/msrc_cve-2025-14174.json","2026-03-12T07:00:00Z" +``` + +Save to `internal/feed/msrc/testdata/golden/changes.csv`. + +**Step 3: Remove old CVRF fixtures** + +Delete: +- `internal/feed/msrc/testdata/golden/updates.json` (OData response, no longer used) +- All files in `internal/feed/msrc/testdata/golden/csaf/` that are CVRF format (check: CVRF files have `"DocumentTitle"` key; CSAF files have `"document"` key) + +**Step 4: Commit** + +```bash +git add internal/feed/msrc/testdata/golden/ +git commit -m "test: replace CVRF fixtures with real CSAF 2.0 files from msrc.microsoft.com" +``` + +--- + +## Phase B: Fix MSRC Adapter + +### Task 2: Rewrite MSRC adapter Fetch method for CSAF static files + +**Files:** +- Modify: `internal/feed/msrc/adapter.go` + +**Current behavior:** Fetches from `api.msrc.microsoft.com/cvrf/v3.0/updates` (OData) then `api.msrc.microsoft.com/cvrf/v3.0/csaf/{releaseID}` (broken endpoint). Returns patches grouped by monthly release. + +**Desired behavior:** Fetches `changes.csv` from `msrc.microsoft.com/csaf/advisories/changes.csv`, filters by cursor timestamp, downloads individual per-CVE CSAF files, parses them with the existing CSAF parser. Returns patches — one per CVE file. + +**Step 1: Update constants and imports** + +Replace the constants section: + +```go +const ( + // SourceName is the canonical feed name stored in cve_sources. + SourceName = "msrc" + + // baseURL is the MSRC CSAF advisory distribution base. + baseURL = "https://msrc.microsoft.com/csaf/advisories/" + + // maxChangesSize caps the changes.csv response to prevent OOM. + maxChangesSize = 10 << 20 // 10 MB + + // maxCSAFDocSize caps individual CSAF file response to prevent OOM. + // Per-CVE files are typically ~6KB; 1MB is generous. + maxCSAFDocSize = 1 << 20 // 1 MB +) +``` + +Remove these imports (no longer needed): `"net/url"`, `"regexp"`. +Add this import: `"encoding/csv"`. + +**Step 2: Update Cursor struct** + +```go +// Cursor is the JSON-serializable sync state for the MSRC adapter. +type Cursor struct { + LastUpdated string `json:"last_updated"` +} +``` + +**Step 3: Remove dead code** + +Delete these types and functions entirely: +- `dateTimeRe` (regexp for OData injection prevention — no longer needed) +- `updateEntry` struct +- `updatesResponse` struct +- `parseUpdates()` function + +Keep these functions unchanged: +- `parseCSAFDocument()` — still used +- `csafToPatches()` — still used +- `buildVendorEnrichment()` — still used + +**Step 4: Add changes.csv parser** + +```go +// changeEntry represents a single row from the CSAF changes.csv file. +type changeEntry struct { + Path string // e.g., "2026/msrc_cve-2026-3909.json" + Timestamp string // e.g., "2026-03-18T01:00:00Z" +} + +// parseChangesCSV parses the CSAF changes.csv file. The CSV has no header row; +// each row is "path","timestamp". Returns entries sorted by the CSV's natural +// order (newest first). +func parseChangesCSV(r io.Reader) ([]changeEntry, error) { + cr := csv.NewReader(r) + cr.FieldsPerRecord = 2 + cr.ReuseRecord = true + + var entries []changeEntry + for { + record, err := cr.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("msrc: parse changes.csv: %w", err) + } + entries = append(entries, changeEntry{ + Path: strings.Clone(record[0]), + Timestamp: strings.Clone(record[1]), + }) + } + return entries, nil +} +``` + +**Step 5: Rewrite Fetch method** + +Replace the entire `Fetch` method with: + +```go +// Fetch implements feed.Adapter. Two-phase: +// 1. Download changes.csv to discover updated CSAF advisory files +// 2. Download and parse each changed per-CVE CSAF file +func (a *Adapter) Fetch(ctx context.Context, cursorJSON json.RawMessage) (*feed.FetchResult, error) { + var cur Cursor + if len(cursorJSON) > 0 { + if err := json.Unmarshal(cursorJSON, &cur); err != nil { + return nil, fmt.Errorf("msrc: parse cursor: %w", err) + } + } + + // Phase 1: download changes.csv + if err := a.rateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("msrc: rate limit: %w", err) + } + + changesURL := baseURL + "changes.csv" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, changesURL, nil) + if err != nil { + return nil, fmt.Errorf("msrc: build changes request: %w", err) + } + + resp, err := a.client.Do(req) //nolint:gosec // URL constructed from constant base + if err != nil { + return nil, fmt.Errorf("msrc: fetch changes.csv: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) //nolint:errcheck,gosec // drain for connection reuse + return nil, fmt.Errorf("msrc: changes.csv HTTP %d", resp.StatusCode) + } + + entries, err := parseChangesCSV(io.LimitReader(resp.Body, maxChangesSize)) + if err != nil { + return nil, err + } + + // Filter to entries newer than cursor + var pending []changeEntry + var latestTimestamp string + for _, e := range entries { + if e.Timestamp > latestTimestamp { + latestTimestamp = e.Timestamp + } + if cur.LastUpdated != "" && e.Timestamp <= cur.LastUpdated { + continue + } + pending = append(pending, e) + } + + // Short-circuit: no new changes + if len(pending) == 0 { + effectiveTS := cur.LastUpdated + if latestTimestamp > effectiveTS { + effectiveTS = latestTimestamp + } + nextCursor := Cursor{LastUpdated: effectiveTS} + nextCursorJSON, marshalErr := json.Marshal(nextCursor) + if marshalErr != nil { + return nil, fmt.Errorf("msrc: marshal cursor: %w", marshalErr) + } + return &feed.FetchResult{ + SourceMeta: feed.SourceMeta{ + SourceName: SourceName, + FetchedAt: time.Now().UTC(), + }, + NextCursor: nextCursorJSON, + LastPage: true, + }, nil + } + + // Phase 2: download and parse each changed CSAF file + fetchedAt := time.Now().UTC() + var allPatches []feed.CanonicalPatch + + for _, entry := range pending { + if err := a.rateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("msrc: rate limit: %w", err) + } + + fileURL := baseURL + entry.Path + fileReq, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) + if reqErr != nil { + return nil, fmt.Errorf("msrc: build request for %s: %w", entry.Path, reqErr) + } + fileReq.Header.Set("Accept", "application/json") + + fileResp, doErr := a.client.Do(fileReq) //nolint:gosec // URL constructed from constant base + CSV path + if doErr != nil { + return nil, fmt.Errorf("msrc: fetch %s: %w", entry.Path, doErr) + } + + if fileResp.StatusCode != http.StatusOK { + io.Copy(io.Discard, fileResp.Body) //nolint:errcheck,gosec // drain for connection reuse + fileResp.Body.Close() //nolint:errcheck,gosec + return nil, fmt.Errorf("msrc: %s HTTP %d", entry.Path, fileResp.StatusCode) + } + + body, readErr := io.ReadAll(io.LimitReader(fileResp.Body, maxCSAFDocSize)) + fileResp.Body.Close() //nolint:errcheck,gosec + if readErr != nil { + return nil, fmt.Errorf("msrc: read %s: %w", entry.Path, readErr) + } + + doc, parseErr := parseCSAFDocument(body) + if parseErr != nil { + return nil, fmt.Errorf("msrc: parse %s: %w", entry.Path, parseErr) + } + + patches := csafToPatches(doc) + allPatches = append(allPatches, patches...) + } + + // Update cursor to latest timestamp seen + effectiveTS := cur.LastUpdated + if latestTimestamp > effectiveTS { + effectiveTS = latestTimestamp + } + nextCursor := Cursor{LastUpdated: effectiveTS} + nextCursorJSON, err := json.Marshal(nextCursor) + if err != nil { + return nil, fmt.Errorf("msrc: marshal cursor: %w", err) + } + + return &feed.FetchResult{ + Patches: allPatches, + SourceMeta: feed.SourceMeta{ + SourceName: SourceName, + FetchedAt: fetchedAt, + }, + NextCursor: nextCursorJSON, + LastPage: true, + }, nil +} +``` + +**Step 6: Run existing CSAF parsing tests** + +```bash +cd && go test ./internal/feed/msrc/... -run TestCSAFToPatches -v -count=1 +``` + +Expected: All 6 `TestCSAFToPatches_*` tests PASS (these test `csafToPatches()` which is unchanged). + +The `TestFetch_*` and `TestParseUpdates` tests will fail — that's expected and fixed in Task 3. + +**Step 7: Commit** + +```bash +git add internal/feed/msrc/adapter.go +git commit -m "fix(msrc): switch to real CSAF 2.0 static file distribution + +The previous /cvrf/v3.0/csaf/{id} endpoint never existed on Microsoft's +API — it returned HTTP 400 for all release IDs. The adapter now uses +Microsoft's CSAF static file distribution at msrc.microsoft.com/csaf/ +advisories/, which serves proper CSAF 2.0 JSON per-CVE files. + +Discovery uses changes.csv (standard CSAF directory mechanism) for +incremental sync. The csafToPatches() and buildVendorEnrichment() +functions are unchanged." +``` + +--- + +### Task 3: Update MSRC adapter unit tests for new Fetch flow + +**Files:** +- Modify: `internal/feed/msrc/adapter_test.go` + +**Depends on:** Task 2 (adapter rewrite) + +The test file has two sections: +1. **CSAF parsing tests** (lines 66-776) — `TestCSAFToPatches_*` and `csafToPatchesFromJSON` helper. These are UNCHANGED. +2. **Fetch tests** (lines 433-651) — `TestFetch_*`, `TestParseUpdates`, and `redirectTransport`. These must be REWRITTEN. + +**Step 1: Remove dead test code** + +Delete: +- `TestParseUpdates` function (tests removed `parseUpdates()`) +- `redirectTransport` struct and its `RoundTrip` method (replace with `testutil.NewURLRewriteTransport`) + +**Step 2: Add `parseChangesCSV` unit test** + +```go +func TestParseChangesCSV(t *testing.T) { + t.Parallel() + + body := `"2026/msrc_cve-2026-3909.json","2026-03-18T01:00:00Z" +"2026/msrc_cve-2026-21510.json","2026-03-17T07:00:00Z" +"2025/msrc_cve-2025-14174.json","2026-03-12T07:00:00Z" +` + + entries, err := parseChangesCSV(strings.NewReader(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 3 { + t.Fatalf("len(entries) = %d, want 3", len(entries)) + } + if entries[0].Path != "2026/msrc_cve-2026-3909.json" { + t.Errorf("entries[0].Path = %q, want %q", entries[0].Path, "2026/msrc_cve-2026-3909.json") + } + if entries[0].Timestamp != "2026-03-18T01:00:00Z" { + t.Errorf("entries[0].Timestamp = %q, want %q", entries[0].Timestamp, "2026-03-18T01:00:00Z") + } +} +``` + +**Step 3: Rewrite Fetch tests** + +Replace `TestFetch_Success` with a test that: +1. Creates an httptest server with two route handlers: + - `/csaf/advisories/changes.csv` → serves a 1-entry CSV fixture + - `/csaf/advisories/2026/msrc_cve-2026-21001.json` → serves `minimalCSAFDoc` (the existing test constant) +2. Uses `testutil.NewURLRewriteTransport("https://msrc.microsoft.com", srv.URL, http.DefaultTransport)` — import `"github.com/scarson/cvert-ops/internal/testutil"` +3. Calls `adapter.Fetch(ctx, nil)` with nil cursor +4. Asserts: 1 patch returned, CVEID = "CVE-2026-21001", SourceName = "msrc", cursor.LastUpdated is set, LastPage = true + +Replace `TestFetch_ShortCircuit` with a test that: +1. Creates server serving a 1-entry changes.csv with timestamp `"2026-03-12T08:00:00Z"` +2. Provides a cursor with `LastUpdated: "2026-03-12T08:00:00Z"` (same as CSV) +3. Asserts: 0 patches, only 1 HTTP request (changes.csv only, no CSAF file requests) + +Replace `TestFetch_HTTPError` with a test that: +1. Creates server returning 500 on `/changes.csv` +2. Asserts: error contains "HTTP 500" + +Replace `TestFetch_CSAFHTTPError` with a test that: +1. Creates server returning valid changes.csv but 500 on the CSAF file request +2. Asserts: error contains "HTTP 500" + +Remove `TestFetch_InvalidCursorDate` entirely — the OData injection test is no longer relevant (no OData filter is constructed). The cursor is just a timestamp string compared locally. + +**Step 4: Run all MSRC tests** + +```bash +cd && go test ./internal/feed/msrc/... -v -count=1 +``` + +Expected: ALL tests PASS — both the unchanged CSAF parsing tests and the new Fetch tests. + +**Step 5: Commit** + +```bash +git add internal/feed/msrc/adapter_test.go +git commit -m "test(msrc): update Fetch tests for CSAF static file distribution" +``` + +**Phase B review loop:** After completing Tasks 2-3, run the review loop described in the Subagent Execution Protocol. Specifically verify: (1) all 6 `TestCSAFToPatches_*` tests still pass unchanged; (2) new Fetch tests cover success, short-circuit, and both error paths; (3) no existing test was deleted without replacement; (4) `parseChangesCSV` has its own unit test; (5) no `url` or `regexp` imports remain (removed dead code); (6) ABOUTME comments are updated if needed. + +--- + +## Phase C: Golden File Tests + +### Task 4: Write MSRC golden file test + +**Files:** +- Create: `internal/feed/msrc/golden_test.go` + +**Depends on:** Tasks 1 (fixtures) and 2 (adapter fix) + +This test is in the `msrc_test` package (external test package). It serves the golden fixtures via httptest, runs the adapter's `Fetch()`, and verifies the real CSAF data parses correctly. + +**Step 1: Write the test** + +```go +// ABOUTME: Golden file test for the MSRC adapter using real CSAF 2.0 files. +// ABOUTME: Verifies vendor enrichment, CVSS extraction, and CSAF parsing from real Microsoft data. +package msrc_test +``` + +The test must: +1. Read `testdata/golden/changes.csv` fixture +2. Read all `testdata/golden/csaf/*.json` fixture files into a map keyed by path +3. Create an httptest server that routes: + - Requests ending in `/changes.csv` → serve the CSV fixture + - Requests containing path segments matching CSAF filenames → serve the corresponding fixture +4. Use `testutil.NewURLRewriteTransport("https://msrc.microsoft.com", srv.URL, http.DefaultTransport)` +5. Create adapter with `msrc.New(client)`, call `Fetch(ctx, nil)` +6. Loop until `LastPage == true`, collecting all patches + +**Required assertions (from Phase 10 plan Task 10F):** +1. `len(allPatches) > 0` — non-zero patches +2. Every patch: `p.CVEID != ""` — all entries map to CVE IDs +3. At least one patch: `p.VendorEnrichment != nil` with `len(p.VendorEnrichment.Data) > 0` +4. At least one patch: `p.CVSSv3Score != nil` — CVSS data extracted +5. Every patch: `p.CVEID` starts with `"CVE-"` — proper CVE format +6. **Falsy-value check (testing-pitfalls §9.4):** If any patch has `CVSSv3Score == 0.0`, log it as correctly preserved + +**Step 2: Run and verify** + +```bash +cd && go test ./internal/feed/msrc/... -run TestFetch_GoldenFiles -v -count=1 +``` + +Expected: PASS + +**Step 3: Commit** + +```bash +git add internal/feed/msrc/golden_test.go +git commit -m "test: add MSRC golden file test against real CSAF 2.0 advisories" +``` + +--- + +### Task 5: Run EPSS golden file test + +**Files:** `internal/feed/epss/golden_test.go` (already created in worktree) + +**Depends on:** Docker Desktop must be running (testcontainers) + +The EPSS golden test was written in a previous session but never executed (computer shut down). Run it now. + +**Step 1: Verify Docker is available** + +```bash +docker info >/dev/null 2>&1 && echo "Docker available" || echo "Docker NOT available" +``` + +If Docker is NOT available, this is a **HARD BLOCKER**. Stop and report. + +**Step 2: Run the test** + +```bash +cd && go test ./internal/feed/epss/... -run TestApply_GoldenFiles -v -count=1 -timeout=300s +``` + +Expected: PASS — seeds NVD CVEs via merge pipeline, applies EPSS scores, verifies DB values. + +If the test FAILS, debug and fix. Common issues: +- NVD fixture path resolution (`../nvd/testdata/golden/` relative to EPSS package) +- EPSS rate limiter blocking (24h limiter — should succeed on first call) +- Testcontainer startup timeout + +**Step 3: If test passes, no commit needed** (test file was already committed by previous session) + +If test required fixes, commit the fixes: +```bash +git add internal/feed/epss/golden_test.go +git commit -m "fix: correct EPSS golden test [describe what was fixed]" +``` + +--- + +## Phase D: SeedCorpus Integration + +### Task 6: Add MSRC to SeedCorpus helper + +**Files:** +- Modify: `internal/testutil/seedcorpus.go` + +**Depends on:** Tasks 1-2 (fixtures + adapter fix) + +The `SeedCorpus` function currently seeds 6 feeds: NVD, MITRE, GHSA, OSV, KEV, Red Hat. MSRC is missing. Add it between KEV and Red Hat (matching source precedence order from PLAN.md §5.1). + +**Step 1: Add MSRC import** + +Add to the import block: +```go +"github.com/scarson/cvert-ops/internal/feed/msrc" +``` + +**Step 2: Add MSRC to feeds list** + +In the `feeds` slice (around line 58-65), add MSRC between KEV and Red Hat: + +```go +feeds := []feedDef{ + {"nvd", "nvd", fetchNVDGolden}, + {"mitre", "mitre", fetchMITREGolden}, + {"ghsa", "ghsa", fetchGHSAGolden}, + {"osv", "osv", fetchOSVGolden}, + {"kev", "kev", fetchKEVGolden}, + {"msrc", "msrc", fetchMSRCGolden}, // ADD THIS LINE + {"redhat", "redhat", fetchRedHatGolden}, +} +``` + +**Step 3: Add `fetchMSRCGolden` function** + +Add this function after `fetchKEVGolden` and before `fetchRedHatGolden`: + +```go +func fetchMSRCGolden(t *testing.T, projectRoot string) []feed.CanonicalPatch { + t.Helper() + goldenDir := filepath.Join(projectRoot, "internal", "feed", "msrc", "testdata", "golden") + + // Read changes.csv fixture + changesData, err := os.ReadFile(filepath.Join(goldenDir, "changes.csv")) + if err != nil { + t.Fatalf("MSRC changes.csv fixture missing: %v", err) + } + + // Read all CSAF fixture files into a map + csafDir := filepath.Join(goldenDir, "csaf") + csafEntries, err := os.ReadDir(csafDir) + if err != nil { + t.Fatalf("MSRC CSAF fixtures missing: %v", err) + } + + csafByName := make(map[string][]byte) + for _, e := range csafEntries { + if filepath.Ext(e.Name()) != ".json" { + continue + } + data, readErr := os.ReadFile(filepath.Join(csafDir, e.Name())) + if readErr != nil { + t.Fatalf("read MSRC CSAF fixture %s: %v", e.Name(), readErr) + } + csafByName[e.Name()] = data + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + + if strings.HasSuffix(path, "/changes.csv") { + w.Header().Set("Content-Type", "text/csv") + w.Write(changesData) //nolint:errcheck + return + } + + // Serve CSAF files by filename + for name, data := range csafByName { + if strings.HasSuffix(path, "/"+name) { + w.Write(data) //nolint:errcheck + return + } + } + + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + + client := &http.Client{ + Transport: NewURLRewriteTransport("https://msrc.microsoft.com", srv.URL, http.DefaultTransport), + } + + return fetchAllPatches(t, msrc.New(client), nil) +} +``` + +**Step 4: Run SeedCorpus test** + +```bash +cd && go test ./internal/testutil/... -run TestSeedCorpus -v -count=1 -timeout=600s +``` + +Expected: PASS — now seeds 8 feeds (was 7 before, added MSRC). The test asserts `FeedsSeeded == len(requiredFeeds)` where `requiredFeeds` includes "msrc". + +Wait — check the existing test's `requiredFeeds` list. If it doesn't already include "msrc", this will fail correctly. If it does include "msrc", the test was already expecting MSRC and was previously failing (or the test counted differently). Read `internal/testutil/seedcorpus_test.go` to verify, and update `requiredFeeds` if needed. + +**Step 5: Commit** + +```bash +git add internal/testutil/seedcorpus.go +git commit -m "test: add MSRC to SeedCorpus golden fixture helper" +``` + +**Phase D review loop:** After completing Task 6, run the review loop. Verify: (1) MSRC is positioned correctly in source precedence order; (2) the test server handles both changes.csv and per-CVE CSAF file routes; (3) `fetchMSRCGolden` uses `NewURLRewriteTransport` consistently with other adapters; (4) `SeedCorpus` now reports 8 feeds seeded (not 7). + +--- + +## Phase E: Verification & Documentation + +### Task 7: Verify full feed test suite (Task 10H from Phase 10 plan) + +**Step 1: Run all feed adapter tests** + +```bash +cd && go test ./internal/feed/... -v -count=1 -timeout=300s +``` + +Verify: +1. All golden file tests RUN (not SKIP) for: NVD, KEV, GHSA, MITRE, OSV, Red Hat, MSRC +2. EPSS golden test may skip if Docker is not available (it uses testcontainers) — this is acceptable for feed-only verification +3. All existing inline-JSON tests still pass +4. No compilation errors or import cycles + +**Step 2: Verify full-project compilation** + +```bash +cd && go build ./... +``` + +Expected: clean build, no errors. + +**Step 3: No commit** — this is a verification step. + +--- + +### Task 8: Final verification of changed packages (Task 12 from Phase 10 plan) + +```bash +cd && go test ./internal/feed/... ./internal/testutil/... -count=1 -timeout=600s +``` + +Also verify compilation: +```bash +cd && go build ./... +``` + +If EPSS test needs Docker and it's available, also run: +```bash +cd && go test ./internal/feed/epss/... -run TestApply_GoldenFiles -v -count=1 -timeout=300s +``` + +All tests must pass. If any golden file tests skip, go back and fix the missing fixtures. + +--- + +### Task 9: Document the refresh process (Task 13 from Phase 10 plan) + +**Files:** +- Modify: `dev/plans/2026-03-15-phase10-test-fixture-corpus-plan.md` + +Add a `## Refresh Process` section at the end of the document (before the dependency graph if one exists): + +```markdown +## Refresh Process + +1. Re-run the capture: `go run ./dev/cmd/capture-feeds/... all` +2. Re-run the selection agent (Task 6 instructions) against new captures +3. Review and commit the updated canonical manifest at `dev/plans/test-fixture-manifest.json` +4. Re-run extraction: `go run ./dev/cmd/extract-fixtures/...` +5. For MSRC: download updated CSAF files from `https://msrc.microsoft.com/csaf/advisories/` and rebuild `changes.csv` +6. Run all adapter tests: `go test ./internal/feed/...` +7. If tests pass, commit the updated manifest and fixtures together +8. If tests fail, investigate — the upstream schema may have changed + +**When to refresh:** +- When an adapter test breaks in a way suggesting upstream schema change +- When adding a new edge case category to the matrix +- When adding a new feed adapter + +**Adding a new feed adapter:** +1. Add a capture case to `dev/cmd/capture-feeds/main.go` +2. Add extraction logic to `dev/cmd/extract-fixtures/main.go` +3. Add a `golden_test.go` to the new adapter package +4. Re-run capture and extraction to populate fixtures +``` + +**Commit:** + +```bash +git add dev/plans/2026-03-15-phase10-test-fixture-corpus-plan.md +git commit -m "docs: add fixture corpus refresh process documentation" +``` + +--- + +## Dependency Graph + +``` +Task 1 (capture fixtures) ──┐ + ├──→ Task 2 (fix adapter) ──→ Task 3 (fix tests) ──→ Task 4 (MSRC golden test) + │ │ + └──────────────────────────────────────────────────→ Task 6 (SeedCorpus) + │ +Task 5 (EPSS golden test) ─────────────────────────────────────────────────────────────→ │ + ↓ + Task 7 (verify feed suite) + ↓ + Task 8 (final verification) + ↓ + Task 9 (documentation) +``` + +Tasks 1-4 and Task 5 are independent and can run in parallel. Tasks 7-9 are sequential and depend on all prior tasks. + +--- + +## Execution Notes + +- **All work in the worktree** at `.claude/worktrees/phase10-fixture-corpus` +- **Do NOT run `go test ./...`** — Docker container overload. Only run relevant package subsets. +- **MSRC rate limiter:** The adapter uses 1 req/sec. For golden tests with 3-5 fixture files, this adds ~3-5 seconds of delay. Acceptable. +- **EPSS test requires Docker Desktop** — if unavailable, this is a hard blocker for Task 5 only. Other tasks can proceed. +- **Worktree already has dev merged** — SCIM code is present. No further merges needed. + +--- + +## Appendix: Autonomous Decisions + +Decisions made without Sam's explicit input during overnight execution. Flagged for review. + +### D1: CVE-2026-3909 replacement with CVE-2026-32194 + +**Context:** The manifest specified 3 MSRC CVEs (CVE-2026-3909, CVE-2026-21510, CVE-2025-14174). CVE-2026-3909 has no CSAF file in Microsoft's CSAF distribution (`index.txt` search returned no match). +**Decision:** Replaced with CVE-2026-32194, a recent (2026-03-19) advisory with full CSAF data including product tree, CVSS scores, and vendor enrichment fields. +**Risk:** Low — the replacement CVE provides equivalent test coverage. The manifest's category coverage (X3: NVD+MSRC overlap) is maintained since CVE-2026-32194 is also a Microsoft CVE. diff --git a/dev/plans/2026-03-19-phase7-scim-implementation-plan-v2.md b/dev/plans/2026-03-19-phase7-scim-implementation-plan-v2.md new file mode 100644 index 00000000..2509c52e --- /dev/null +++ b/dev/plans/2026-03-19-phase7-scim-implementation-plan-v2.md @@ -0,0 +1,1526 @@ +# Phase 7: SCIM 2.0 Provisioning — Implementation Plan (v2) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement SCIM 2.0 user and group provisioning so enterprise IdPs (Microsoft Entra ID, Okta) can automatically manage CVErt Ops org membership, roles, and notification groups. + +**Architecture:** SCIM endpoints are implemented as chi handlers directly in `internal/api/` — no third-party SCIM library. A custom `requireSCIMAuth` middleware authenticates SCIM bearer tokens (separate from API keys/JWTs). SCIM handlers produce RFC 7644 JSON responses with `Content-Type: application/scim+json`. Admin management endpoints (SCIM config CRUD, group mapping) use the standard chi handler pattern with `RequireOrgRole` + `requireEnterpriseTier`. User deactivation (`org_members.deactivated_at`) is a general feature, not SCIM-exclusive. + +**Tech Stack:** Go 1.26, chi, sqlc, testcontainers-go + +**Design doc:** `dev/plans/2026-03-19-phase7-scim-provisioning-design-v2.md` + +**SCIM Specification References:** +- RFC 7643 — SCIM Core Schema: https://www.rfc-editor.org/rfc/rfc7643 +- RFC 7644 — SCIM Protocol: https://www.rfc-editor.org/rfc/rfc7644 +- RFC 7644 §3.12 — Error Handling (our error format): https://www.rfc-editor.org/rfc/rfc7644#section-3.12 +- RFC 7644 §3.4.2.2 — Filtering: https://www.rfc-editor.org/rfc/rfc7644#section-3.4.2.2 +- RFC 7644 §3.5.2 — Modifying with PATCH: https://www.rfc-editor.org/rfc/rfc7644#section-3.5.2 +- RFC 7643 §8.7.1 — User Schema: https://www.rfc-editor.org/rfc/rfc7643#section-8.7.1 +- RFC 7643 §8.7.2 — Group Schema: https://www.rfc-editor.org/rfc/rfc7643#section-8.7.2 + +When in doubt about wire format, response structure, or error semantics, check the relevant RFC section rather than guessing. + +**Prerequisites:** Phase 5D complete (SSO — `sso_connections` table, tier gating, audit log). Phase 6B complete (MFA tables exist). + +**Context for subagents:** +- Chi handlers use `http.Error(w, msg, status)` + `return`, NOT huma error returns +- Transaction helpers: `withBypassTx` for auth lookups and global-table writes, `withOrgTx` for org-scoped queries from handlers. See `dev/implementation-pitfalls.md` §DB-17 for when to use which. +- Integration tests use `testutil.NewTestDB(t)` with testcontainers Postgres — NOT Docker Compose +- RLS isolation tests MUST use `s.AppStore` (NOBYPASSRLS) — `s.Store` (superuser) bypasses RLS. See `dev/testing-pitfalls.md` §10. +- TDD is mandatory: RED → verify fail → GREEN → verify pass → refactor → commit +- Run `sqlc generate` after any `.sql` file changes, before `go build` +- Run `golangci-lint run` before committing +- `requireEnterpriseTier(w, r)` is the existing enterprise gate helper in `internal/api/sso.go` (returns false + writes HTTP error if tier check fails) +- SCIM error format is RFC 7644 §3.12 JSON (NOT RFC 9457 Problem Details) — use `writeSCIMError()` helper +- Pointer types for PATCH fields (`*bool`, `*string`) — see pitfalls §API-2 +- SCIM bearer tokens are sha256-hashed, compared via `subtle.ConstantTimeCompare` — same security pattern as API keys +- Never hold open DB tx during outbound HTTP — see pitfalls §AUTH-12 +- Every migration file with `CREATE INDEX CONCURRENTLY` needs `-- migrate:no-transaction` as FIRST line of BOTH up and down files +- Every org-scoped table: denormalized `org_id` + BTREE index + ENABLE/FORCE RLS + dual-escape policy (bypass_rls OR org_id match) +- No semicolons in SQL comments — see pitfalls §DB-16 (breaks golang-migrate statement splitting) +- Context keys are defined in `internal/api/context.go` using iota — new keys must be added there, not defined locally +- To extract SCIM context in handlers: `orgID := r.Context().Value(ctxOrgID).(uuid.UUID)` and `scimConfigID := r.Context().Value(ctxSCIMConfigID).(uuid.UUID)` — same pattern as `ctxUserID` extraction in existing handlers +- `user_identities` queries already exist in `internal/store/queries/auth.sql` — reuse `UpsertUserIdentity`, `GetUserByProviderID`, `GetUserByEmail` for SCIM identity matching. Do NOT create new identity queries. +- `SCIMPatchOperation.Value` should be typed as `json.RawMessage` so handlers can type-assert based on the attribute path (booleans for `active`, strings for `userName`, arrays for `members`) +- `audit_log.entity_type` has no CHECK constraint (only `action` does) — no migration needed for new entity types, but new `action` values DO need a CHECK constraint migration +- Test setup calls MUST check errors with `require.NoError(t, err)` — never `_, _ :=`. See `dev/testing-pitfalls.md` §16. +- Every test POST/PUT/PATCH/DELETE through full HTTP stack needs `X-Requested-By: CVErt-Ops` header for CSRF. SCIM endpoints are exempt (Bearer token auth, no cookies). See `dev/testing-pitfalls.md` §16. +- Security events: use `secure.EventWriter.Write()` (async, non-blocking). New event constants go in `internal/secure/events.go` with severity mappings. See `dev/testing-pitfalls.md` §7 for "defined constants must be emitted" rule. + +**DO NOT:** +- Do NOT use any third-party SCIM library. All SCIM JSON serialization/deserialization is hand-written against RFC 7643/7644. +- Do NOT use huma for SCIM endpoints. SCIM wire protocol is incompatible with huma conventions. +- Do NOT add features or filter operators beyond what the design doc specifies. +- Do NOT create an `internal/scim/` package. All SCIM handlers live in `internal/api/` alongside existing handlers. +- Do NOT modify the `groups` or `group_members` table structure beyond adding the `scim_managed` column to `group_members`. + +--- + +## Mandatory Discipline (applies to ALL tasks with tests) + +**BEFORE starting any task that includes tests:** +1. Read `dev/testing-pitfalls.md` +2. Read the TDD skill at `.claude/skills/test-driven-development/` (or invoke `/test-driven-development`) +3. For pure test additions: write the test, verify it fails for the right reason (or passes if testing already-correct behavior), then move on. +4. For code + tests: write failing test → implement code → verify green. + +**BEFORE marking any task complete:** +1. Review your tests against `dev/testing-pitfalls.md` +2. Verify: are assertions checking *behavior*, not just *execution*? (e.g., assert the returned config has `enabled=true`, not just `err == nil`) +3. Verify: are negative cases tested, not just positive? (e.g., cross-org RLS isolation, not just same-org access) +4. Verify: do tests use `require.NoError(t, err)` for setup calls, never `_, _ :=`? +5. Run `go test ./...` (or relevant subset) and confirm green +6. Run `golangci-lint run` on modified packages + +**After completing each wave (all tasks in the wave):** +You MUST carefully review the batch of work from multiple perspectives and revise/refine as appropriate. Repeat this review loop (minimum three rounds; if you still find substantive issues in the third review, keep going) until you're confident there aren't any more issues. Specifically check: +- Are all RLS isolation tests using `AppStore` (not `Store`)? +- Are SCIM error responses using `application/scim+json` content type (not `text/plain` or `application/json`)? +- Are admin endpoint error responses using RFC 9457 format (not SCIM format)? +- Do store tests verify returned values, not just `err == nil`? +- Are security events verified in the `security_events` table, not just assumed to fire? +Then update your private journal and continue to the next wave. + +**Testing pitfall warnings (applies throughout):** +- ⚠️ §16: Test setup calls MUST use `require.NoError(t, err)` — never `_, _ :=` (nil pointer panics hide real failures) +- ⚠️ §16: Every POST/PUT/PATCH/DELETE through full HTTP stack needs `X-Requested-By: CVErt-Ops` for CSRF — EXCEPT SCIM endpoints (Bearer token auth) +- ⚠️ §10: RLS isolation tests MUST query through `db.AppStore` — `db.Store` bypasses RLS and gives false confidence +- ⚠️ §7: Every security event constant defined in `events.go` must be emitted by at least one code path — grep to verify +- ⚠️ §3: Error response format must be consistent — SCIM endpoints use `application/scim+json`, admin endpoints use `application/problem+json` +- ⚠️ §4: PATCH validation must match POST validation — if POST rejects empty `userName`, PATCH must too +- ⚠️ §1: Idempotent operations (POST /Users for existing user) must be tested under concurrent access + +--- + +## Task 1: Migration — `org_members` deactivation + SCIM exemption columns + +User deactivation is a general feature (not SCIM-exclusive). Admin-settable via member PATCH. `RequireOrgRole` middleware will check `deactivated_at IS NULL`. This migration adds the columns first so subsequent tasks can use them. + +**Files:** +- Create: `migrations/000042_org_members_deactivation.up.sql` +- Create: `migrations/000042_org_members_deactivation.down.sql` + +**Step 1: Write the up migration** + +```sql +-- migrate:no-transaction +-- ABOUTME: Adds deactivation and SCIM exemption to org_members. +-- ABOUTME: deactivated_at is a general feature (admin-settable), scim_exempt prevents SCIM from modifying state. + +ALTER TABLE org_members ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMPTZ; +ALTER TABLE org_members ADD COLUMN IF NOT EXISTS scim_exempt BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS org_members_active_idx + ON org_members (org_id) WHERE deactivated_at IS NULL; +``` + +**Step 2: Write the down migration** + +```sql +-- migrate:no-transaction + +DROP INDEX CONCURRENTLY IF EXISTS org_members_active_idx; +ALTER TABLE org_members DROP COLUMN IF EXISTS scim_exempt; +ALTER TABLE org_members DROP COLUMN IF EXISTS deactivated_at; +``` + +**Step 3: Run migration** + +```bash +go run ./cmd/cvert-ops migrate +``` + +**Step 4: Regenerate sqlc** + +```bash +sqlc generate +``` + +**Step 5: Verify it compiles** + +```bash +go build ./... +``` + +**Step 6: Commit** + +```bash +git add migrations/000042_* internal/store/generated/ +git commit -m "migration(042): add org_members.deactivated_at + scim_exempt columns" +``` + +--- + +## Task 2: Migration — `group_members.scim_managed` column + +Tracks whether a notification group membership was SCIM-synced. SCIM removal only deletes `scim_managed = true` rows. + +**Files:** +- Create: `migrations/000043_group_members_scim_managed.up.sql` +- Create: `migrations/000043_group_members_scim_managed.down.sql` + +**Step 1: Write the up migration** + +```sql +-- ABOUTME: Adds scim_managed flag to group_members for SCIM notification sync tracking. +-- ABOUTME: SCIM removal only deletes scim_managed=true rows. Manual memberships preserved. + +ALTER TABLE group_members ADD COLUMN IF NOT EXISTS scim_managed BOOLEAN NOT NULL DEFAULT false; +``` + +No `CREATE INDEX CONCURRENTLY` → no `-- migrate:no-transaction` needed. + +**Step 2: Write the down migration** + +```sql +ALTER TABLE group_members DROP COLUMN IF EXISTS scim_managed; +``` + +**Step 3: Run migration, regenerate sqlc, verify build** + +```bash +go run ./cmd/cvert-ops migrate && sqlc generate && go build ./... +``` + +**Step 4: Commit** + +```bash +git add migrations/000043_* internal/store/generated/ +git commit -m "migration(043): add group_members.scim_managed column" +``` + +--- + +## Task 3: Migration — `scim_configs` table + +SCIM provisioning config, 1:1 with organizations (via `sso_connections`). Token stored separately from API keys (different auth semantics). `ON DELETE RESTRICT` on `sso_connection_id` — prevents silent destruction when SSO is deleted. + +**Files:** +- Create: `migrations/000044_create_scim_configs.up.sql` +- Create: `migrations/000044_create_scim_configs.down.sql` + +**Step 1: Write the up migration** + +Use the exact SQL from design doc §1 for `scim_configs`, but with `ON DELETE RESTRICT` (not CASCADE) on `sso_connection_id`. The migration MUST have `-- migrate:no-transaction` as the first line because it contains `CREATE INDEX CONCURRENTLY`. + +**Step 2: Write the down migration** + +Drop in reverse order: policy → RLS → index → table. MUST have `-- migrate:no-transaction` as the first line because it contains `DROP INDEX CONCURRENTLY`. + +**Step 3: Run migration, regenerate sqlc, verify build** + +```bash +go run ./cmd/cvert-ops migrate && sqlc generate && go build ./... +``` + +**Step 4: Commit** + +```bash +git add migrations/000044_* internal/store/generated/ +git commit -m "migration(044): create scim_configs table with RLS and ON DELETE RESTRICT" +``` + +--- + +## Task 4: Migration — `scim_groups` + `scim_group_members` tables + +IdP groups with optional role + notification group mappings. `scim_groups` references `organizations(id)` directly (NOT `scim_configs`) so group mappings survive SCIM config deletion. + +**Files:** +- Create: `migrations/000045_create_scim_groups.up.sql` +- Create: `migrations/000045_create_scim_groups.down.sql` + +**Step 1: Write the up migration** + +Use the exact SQL from design doc §1 for `scim_groups` and `scim_group_members`. MUST have `-- migrate:no-transaction` as the first line (contains `CREATE INDEX CONCURRENTLY`). + +**Step 2: Write the down migration** + +Drop `scim_group_members` first (has FK to `scim_groups`), then `scim_groups`. MUST have `-- migrate:no-transaction` as the first line. + +**Step 3: Run migration, regenerate sqlc, verify build** + +```bash +go run ./cmd/cvert-ops migrate && sqlc generate && go build ./... +``` + +**Step 4: Commit** + +```bash +git add migrations/000045_* internal/store/generated/ +git commit -m "migration(045): create scim_groups + scim_group_members with RLS" +``` + +--- + +## Task 5: Verify `audit_log.action` CHECK constraint (no migration needed) + +The `audit_log.action` CHECK constraint (migration 000041) allows: `create`, `update`, `delete`, `revoke`, `add`, `remove`, `bind`, `unbind`, `update_domains`. + +SCIM operations will use: `create`, `update`, `delete` — all already covered. No migration is needed. + +The `entity_type` column has NO CHECK constraint — new entity types (`scim_config`, `scim_group`) can be used without a migration. + +**Action:** No files to create. Migration number 000046 is available for the next feature. Proceed to Task 6. + +--- + +## Task 6: Security event constants for SCIM + +Add SCIM-specific event type constants and severity mappings. + +**Files:** +- Modify: `internal/secure/events.go` +- Modify: `internal/secure/events_test.go` (update exhaustiveness test) + +**Step 1: Add constants** + +Add to `internal/secure/events.go` after the existing MFA constants: + +```go +// SCIM provisioning events. +EventSCIMAuthFailed = "scim.auth_failed" +EventSCIMAuthOrgMismatch = "scim.auth_org_mismatch" +EventSCIMAuthDisabled = "scim.auth_disabled" +EventSCIMTokenCreated = "scim.token_created" //nolint:gosec // G101 false positive: event type label, not a credential +EventSCIMTokenRotated = "scim.token_rotated" //nolint:gosec // G101 false positive: event type label, not a credential +EventSCIMUserProvisioned = "scim.user_provisioned" +EventSCIMUserDeprovisioned = "scim.user_deprovisioned" +EventSCIMSoleOwnerProtected = "scim.sole_owner_protected" +EventSCIMExemptSuppressed = "scim.exempt_suppressed" +EventSCIMRateLimited = "scim.rate_limited" +``` + +**Step 2: Add severity mappings** + +Add to `EventSeverity` map: +- Auth failures: `SeverityWarning` +- Token created/rotated: `SeverityInfo` +- User provisioned/deprovisioned: `SeverityInfo` +- Sole owner protected, exempt suppressed, rate limited: `SeverityWarning` + +**Step 3: Update exhaustiveness test** + +Add all new constants to the slice in `TestEventSeverityMapIsExhaustive`. + +**Step 4: Run tests** + +```bash +go test ./internal/secure/ -v -count=1 +``` + +**Step 5: Commit** + +```bash +git add internal/secure/events.go internal/secure/events_test.go +git commit -m "feat(secure): add SCIM security event type constants" +``` + +--- + +## Task 7: SCIM context key + config struct + +Add the SCIM config context key and config-related types. + +**Files:** +- Modify: `internal/api/context.go` — add `ctxSCIMConfigID` +- Modify: `internal/config/config.go` — add `SCIMRateLimit` field + +**Step 1: Add context key** + +Add `ctxSCIMConfigID` as the next iota value after `ctxTierResolver` in `internal/api/context.go`: + +```go +ctxSCIMConfigID // uuid.UUID — SCIM config ID (set by requireSCIMAuth) +``` + +**Step 2: Add config field** + +Add to `internal/config/config.go`: + +```go +SCIMRateLimit int `env:"SCIM_RATE_LIMIT" envDefault:"50"` // requests per second per org +``` + +**Step 3: Verify build** + +```bash +go build ./... +``` + +**Step 4: Commit** + +```bash +git add internal/api/context.go internal/config/config.go +git commit -m "feat: add SCIM context key and rate limit config" +``` + +--- + +## Task 8: sqlc queries — SCIM config + +**Files:** +- Create: `internal/store/queries/scim_config.sql` +- Modify: `internal/store/generated/` (regenerated) + +**Step 1: Write the sqlc queries** + +```sql +-- ABOUTME: sqlc queries for SCIM config CRUD. +-- ABOUTME: Token lookup uses withBypassTx (pre-org-context auth). Config CRUD uses withOrgTx. + +-- name: CreateSCIMConfig :one +INSERT INTO scim_configs (org_id, sso_connection_id, enabled, token_hash, token_prefix, default_role) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; + +-- name: GetSCIMConfigByOrgID :one +SELECT * FROM scim_configs WHERE org_id = $1; + +-- name: GetSCIMConfigByTokenHash :one +SELECT * FROM scim_configs WHERE token_hash = $1; + +-- name: GetSCIMConfigBySSOConnectionID :one +SELECT * FROM scim_configs WHERE sso_connection_id = $1; + +-- name: UpdateSCIMConfig :exec +UPDATE scim_configs SET enabled = $2, default_role = $3, updated_at = now() +WHERE org_id = $1; + +-- name: UpdateSCIMConfigToken :exec +UPDATE scim_configs SET token_hash = $2, token_prefix = $3, updated_at = now() +WHERE org_id = $1; + +-- name: DeleteSCIMConfig :exec +DELETE FROM scim_configs WHERE org_id = $1; +``` + +Note: `GetSCIMConfigBySSOConnectionID` is needed for the SSO delete pre-flight check (Task 20). + +**Step 2: Regenerate sqlc and verify build** + +```bash +sqlc generate && go build ./... +``` + +**Step 3: Commit** + +```bash +git add internal/store/queries/scim_config.sql internal/store/generated/ +git commit -m "sqlc: add SCIM config queries" +``` + +--- + +## Task 9: sqlc queries — SCIM groups + members + +**Files:** +- Create: `internal/store/queries/scim_groups.sql` +- Modify: `internal/store/generated/` (regenerated) + +**Step 1: Write the sqlc queries** + +Use the exact queries from the v1 implementation plan Task 7. Add one additional query: + +```sql +-- name: GetSCIMGroupByExternalID :one +SELECT * FROM scim_groups WHERE org_id = $1 AND external_id = $2; +``` + +This is needed for SCIM group operations that filter by `externalId`. + +**Step 2: Regenerate sqlc and verify build** + +```bash +sqlc generate && go build ./... +``` + +**Step 3: Commit** + +```bash +git add internal/store/queries/scim_groups.sql internal/store/generated/ +git commit -m "sqlc: add SCIM group and membership queries" +``` + +--- + +## Task 10: sqlc queries — org_members deactivation + member count + +Update existing org queries for deactivation support. + +**Files:** +- Modify: `internal/store/queries/org.sql` +- Modify: `internal/store/generated/` (regenerated) + +**Step 1: Add new queries to org.sql** + +Append these queries. Before writing, read `internal/store/queries/org.sql` to check for existing query names that might conflict — specifically `GetOrgOwnerCount` (already exists) and any `GetOrgMember` variants. + +```sql +-- name: DeactivateOrgMember :exec +UPDATE org_members SET deactivated_at = now(), updated_at = now() +WHERE org_id = $1 AND user_id = $2; + +-- name: ReactivateOrgMember :exec +UPDATE org_members SET deactivated_at = NULL, updated_at = now() +WHERE org_id = $1 AND user_id = $2; + +-- name: GetOrgMemberFull :one +SELECT om.org_id, om.user_id, om.role, om.created_at, om.updated_at, + om.deactivated_at, om.scim_exempt, + u.email, u.display_name +FROM org_members om +JOIN users u ON u.id = om.user_id +WHERE om.org_id = $1 AND om.user_id = $2; + +-- name: CountActiveOrgMembers :one +SELECT COUNT(*)::int FROM org_members +WHERE org_id = $1 AND deactivated_at IS NULL; + +-- name: CountActiveOrgOwners :one +SELECT COUNT(*)::int FROM org_members +WHERE org_id = $1 AND role = 'owner' AND deactivated_at IS NULL; + +-- name: UpdateOrgMemberSCIMExempt :exec +UPDATE org_members SET scim_exempt = $3, updated_at = now() +WHERE org_id = $1 AND user_id = $2; +``` + +**Important:** Check if `GetOrgOwnerCount` already exists. If it does and doesn't filter by `deactivated_at IS NULL`, you still need `CountActiveOrgOwners` as a separate query (different semantics). Do NOT modify the existing `GetOrgOwnerCount` — other code may depend on it counting ALL owners. + +**Step 2: Regenerate sqlc and verify build** + +```bash +sqlc generate && go build ./... +``` + +**Step 3: Commit** + +```bash +git add internal/store/queries/org.sql internal/store/generated/ +git commit -m "sqlc: add deactivation and active member count queries" +``` + +--- + +## Task 11: sqlc queries — group_members SCIM-managed operations + +**Files:** +- Modify: `internal/store/queries/groups.sql` +- Modify: `internal/store/generated/` (regenerated) + +**Step 1: Add queries for SCIM-managed group membership** + +Read `internal/store/queries/groups.sql` first. Append: + +```sql +-- name: AddGroupMemberSCIMManaged :exec +INSERT INTO group_members (group_id, user_id, org_id, scim_managed) +VALUES ($1, $2, $3, true) +ON CONFLICT (group_id, user_id) DO NOTHING; + +-- name: RemoveSCIMManagedGroupMember :exec +DELETE FROM group_members +WHERE group_id = $1 AND user_id = $2 AND scim_managed = true; + +-- name: IsGroupMemberSCIMManaged :one +SELECT scim_managed FROM group_members WHERE group_id = $1 AND user_id = $2; + +-- name: GetGroupIfActive :one +SELECT * FROM groups WHERE id = $1 AND deleted_at IS NULL; +``` + +The `GetGroupIfActive` query is needed for the soft-delete guard in notification group sync (design doc §3.7). The `IsGroupMemberSCIMManaged` query is needed to check before removing. + +**Step 2: Regenerate sqlc and verify build** + +```bash +sqlc generate && go build ./... +``` + +**Step 3: Commit** + +```bash +git add internal/store/queries/groups.sql internal/store/generated/ +git commit -m "sqlc: add SCIM-managed group member queries" +``` + +--- + +## Task 12: Store layer — SCIM config methods + +Wrap sqlc-generated queries in proper transaction helpers. + +**Files:** +- Create: `internal/store/scim_config.go` +- Create: `internal/store/scim_config_test.go` + +**Step 1: Write failing tests** + +Write tests for SCIM config CRUD using `testutil.NewTestDB(t)`. Test cases with exact assertions: +- `TestCreateSCIMConfig` — create config → assert returned row has matching `org_id`, `sso_connection_id`, `enabled`, `token_hash`, `token_prefix`, `default_role` fields; assert `created_at` is non-zero +- `TestCreateSCIMConfig_Duplicate` — create config, then create again with same org_id → assert error contains "unique" or is a constraint violation (not a nil error) +- `TestGetSCIMConfigByTokenHash` — create config with known token hash → lookup via `LookupSCIMConfigByTokenHash` → assert returned config has matching `org_id` and `enabled` fields. Also test with non-existent hash → assert returns `nil, nil` (not error) +- `TestGetSCIMConfigByOrgID` — create config → `GetSCIMConfig(ctx, orgID)` → assert returned config matches. Also test with random UUID → assert returns `nil, nil` +- `TestGetSCIMConfigBySSOConnectionID` — create config → `LookupSCIMConfigBySSOConnectionID(ctx, ssoConnID)` → assert match. Also test non-existent → `nil, nil` +- `TestUpdateSCIMConfig` — create config with `enabled=false, default_role="viewer"` → update to `enabled=true, default_role="member"` → re-read → assert both fields changed and `updated_at > created_at` +- `TestUpdateSCIMConfigToken` — create config → rotate token hash → re-read → assert `token_hash` changed, `token_prefix` changed, `updated_at` advanced +- `TestDeleteSCIMConfig` — create config → delete → `GetSCIMConfig` returns `nil, nil` +- `TestSCIMConfig_RLSIsolation` — create config in org A and org B via superuser store → query via `AppStore` scoped to org A → assert only org A's config is returned; query scoped to org B → assert only org B's. Cross-org `GetSCIMConfigByOrgID` must return no results, not an error + +Key test patterns: +- Setup: create org + SSO connection (prerequisites) via superuser store. Read `internal/store/queries/sso.sql` and `internal/store/sso.go` to find the `CreateSSOConnection` method and its required parameters (org_id, display_name, issuer_url, client_id, client_secret_enc, scopes, enabled). Use `require.NoError(t, err)` for every setup call. +- SCIM config CRUD via `AppStore` for RLS verification +- Token hash lookup via `withBypassTx` (pre-org-context, like the auth middleware will use) + +**Step 2: Run tests — verify they fail** + +```bash +go test ./internal/store/ -run TestSCIMConfig -v -count=1 +``` + +Expected: compilation errors (methods don't exist yet) + +**Step 3: Implement store methods** + +Follow the exact method signatures from v1 plan Task 9. Key implementation notes: +- `LookupSCIMConfigByTokenHash` uses `withBypassTx` (pre-org-context auth lookup) +- `GetSCIMConfig` uses `withOrgTx` +- `CreateSCIMConfig` uses `withOrgTx` +- Return `nil, nil` (not error) for `sql.ErrNoRows` on lookup methods +- Add `LookupSCIMConfigBySSOConnectionID` using `withBypassTx` (needed for SSO delete check, which runs before org context is established) + +**Step 4: Run tests — verify they pass** + +```bash +go test ./internal/store/ -run TestSCIMConfig -v -count=1 +``` + +**Step 5: Lint** + +```bash +golangci-lint run ./internal/store/... +``` + +**Step 6: Commit** + +```bash +git add internal/store/scim_config.go internal/store/scim_config_test.go +git commit -m "feat(store): SCIM config CRUD with RLS + bypass-tx token lookup" +``` + +--- + +## Task 13: Store layer — SCIM group methods + +**Files:** +- Create: `internal/store/scim_groups.go` +- Create: `internal/store/scim_groups_test.go` + +Follow the same TDD pattern as Task 12. Key methods: + +- `CreateSCIMGroup(ctx, orgID, externalID, displayName)` — uses `withOrgTx` +- `GetSCIMGroup(ctx, orgID, id)` — uses `withOrgTx` +- `GetSCIMGroupByExternalID(ctx, orgID, externalID)` — uses `withOrgTx` +- `ListSCIMGroups(ctx, orgID)` — uses `withOrgTx`, returns member counts +- `UpdateSCIMGroup(ctx, orgID, id, displayName, externalID)` — uses `withOrgTx` +- `UpdateSCIMGroupMapping(ctx, orgID, id, mappedRole, mappedGroupID)` — uses `withOrgTx` +- `DeleteSCIMGroup(ctx, orgID, id)` — uses `withOrgTx` +- `AddSCIMGroupMember(ctx, orgID, scimGroupID, userID)` — uses `withOrgTx` +- `RemoveSCIMGroupMember(ctx, orgID, scimGroupID, userID)` — uses `withOrgTx` +- `ListSCIMGroupMembers(ctx, orgID, scimGroupID)` — uses `withOrgTx` +- `ListUserSCIMGroups(ctx, orgID, userID)` — uses `withOrgTx` +- `CountOtherSCIMGroupsWithSameMapping(ctx, orgID, userID, mappedGroupID, excludeGroupID)` — uses `withOrgTx` + +**Test cases with exact assertions:** +- `TestCreateSCIMGroup` — create group → assert returned row has matching `org_id`, `display_name`, `external_id`; `mapped_role` and `mapped_group_id` are null +- `TestCreateSCIMGroup_DuplicateName` — create two groups with same `(org_id, display_name)` → assert unique constraint error +- `TestGetSCIMGroupByExternalID` — create group with external_id → lookup → assert match. Lookup non-existent → assert `sql.ErrNoRows` or nil +- `TestListSCIMGroups_WithMemberCounts` — create 2 groups, add 3 members to group A, 0 to group B → `ListSCIMGroups` → assert group A has `member_count=3`, group B has `member_count=0` +- `TestUpdateSCIMGroupMapping` — set `mapped_role="admin"` and `mapped_group_id=` → re-read → assert both fields updated +- `TestDeleteSCIMGroup_CascadesMembers` — create group with members → delete group → assert `scim_group_members` rows are gone (query directly) +- `TestAddSCIMGroupMember_Idempotent` — add same member twice → no error (ON CONFLICT DO NOTHING), member count still 1 +- `TestRemoveSCIMGroupMember` — add then remove → `ListSCIMGroupMembers` returns empty slice +- `TestListUserSCIMGroups` — user in 2 groups → assert both returned. User in 0 groups → assert empty slice (not nil, not error) +- `TestCountOtherSCIMGroupsWithSameMapping` — 2 groups both mapped to same notification group, user in both → count excluding group A → assert 1. Excluding group B → assert 1. User in only group A → excluding A → assert 0 +- `TestSCIMGroups_RLSIsolation` — create group in org A, query via AppStore scoped to org B → assert zero results. `ListSCIMGroups` for org B returns empty, not org A's groups + +TDD: write failing tests → implement → verify pass → lint → commit. + +```bash +git commit -m "feat(store): SCIM group and membership methods with RLS" +``` + +--- + +## Task 14: Store layer — deactivation + member count methods + +**Files:** +- Modify: `internal/store/org.go` — add deactivation methods +- Create: `internal/store/org_deactivation_test.go` + +Key methods to add to `org.go`: + +- `DeactivateOrgMember(ctx, orgID, userID)` — uses `withOrgTx` +- `ReactivateOrgMember(ctx, orgID, userID)` — uses `withOrgTx` +- `GetOrgMemberFull(ctx, orgID, userID)` — returns `org_members` joined with `users` (email, display_name, deactivated_at, scim_exempt) +- `CountActiveOrgMembers(ctx, orgID)` — uses `withOrgTx` +- `CountActiveOrgOwners(ctx, orgID)` — uses `withOrgTx` +- `UpdateOrgMemberSCIMExempt(ctx, orgID, userID, exempt bool)` — uses `withOrgTx` + +**Test cases with exact assertions:** +- `TestDeactivateOrgMember` — deactivate → call `GetOrgMemberFull` → assert `DeactivatedAt.Valid == true` and `DeactivatedAt.Time` is recent (within last minute) +- `TestReactivateOrgMember` — deactivate then reactivate → `GetOrgMemberFull` → assert `DeactivatedAt.Valid == false` +- `TestCountActiveOrgMembers` — create 3 members, deactivate 1 → `CountActiveOrgMembers` → assert returns 2 (not 3) +- `TestCountActiveOrgOwners_SoleOwner` — create 1 owner + 1 member, deactivate member → `CountActiveOrgOwners` → assert 1. Then deactivate owner → assert 0 +- `TestCountActiveOrgOwners_MultipleOwners` — create 2 owners, deactivate 1 → assert 1 remaining +- `TestUpdateOrgMemberSCIMExempt` — set exempt=true → `GetOrgMemberFull` → assert `ScimExempt == true`. Set back to false → assert false +- `TestDeactivation_RLSIsolation` — deactivate member in org A → query via AppStore scoped to org B → assert operation affects zero rows (not error) + +TDD: write failing tests → implement → verify pass → lint → commit. + +```bash +git commit -m "feat(store): org member deactivation + count methods" +``` + +--- + +## Task 15: SCIM token generation helper + +**Files:** +- Create: `internal/auth/scimtoken.go` +- Create: `internal/auth/scimtoken_test.go` + +**Step 1: Write failing tests** + +```go +func TestGenerateSCIMToken(t *testing.T) { + raw, hash, prefix, err := auth.GenerateSCIMToken() + // Verify: no error, raw starts with "cvert_scim_", hash is sha256 hex (64 chars), + // prefix is first 8 chars of raw, len(raw) == 75 (11 prefix + 64 hex from 32 bytes) +} + +func TestHashSCIMToken(t *testing.T) { + // Verify: deterministic sha256 hex +} + +func TestGenerateSCIMToken_Uniqueness(t *testing.T) { + // Generate two tokens, verify they differ +} +``` + +**Step 2: Implement** + +```go +// ABOUTME: SCIM bearer token generation and hashing. +// ABOUTME: Tokens use "cvert_scim_" prefix (distinct from API key "cvo_" prefix). +package auth + +const SCIMTokenPrefix = "cvert_scim_" + +func GenerateSCIMToken() (rawToken, tokenHash, tokenPrefix string, err error) { ... } +func HashSCIMToken(rawToken string) string { ... } +``` + +Use `crypto/rand` for token bytes, `crypto/sha256` for hashing, `encoding/hex` for encoding. Same pattern as `internal/auth/apikey.go`. + +**Step 3: Run tests, lint, commit** + +```bash +git commit -m "feat(auth): SCIM bearer token generation + hashing" +``` + +--- + +## Task 16: RequireOrgRole middleware — deactivation check + +Deactivated members should get 403. This is a modification to existing middleware. + +**Files:** +- Modify: `internal/api/middleware_rbac.go` +- Modify: `internal/api/middleware_rbac_test.go` + +**Step 1: Write failing test** + +Test that a deactivated org member gets 403 with message "Your membership in this organization has been deactivated." + +**Step 2: Run test — verify fail** + +**Step 3: Modify RequireOrgRole** + +First, read `internal/api/middleware_rbac.go` to understand the current implementation. Identify which sqlc query is called to get the member's role (likely `GetOrgMemberRole` in `internal/store/queries/org.sql`). The query needs to also return `deactivated_at`. Two options: + +**Option A (recommended):** Add a new sqlc query `GetOrgMemberRoleAndStatus` that returns `role` and `deactivated_at`. Use it in the middleware instead of the existing `GetOrgMemberRole`. + +**Option B:** Modify the existing `GetOrgMemberRole` query to also SELECT `deactivated_at`. This may break existing callers that don't expect the extra field — check before modifying. + +After getting the member data, add the deactivation check BEFORE the role check: + +```go +if member.DeactivatedAt.Valid { + http.Error(w, "Your membership in this organization has been deactivated.", http.StatusForbidden) + return +} +``` + +**Step 4: Run ALL middleware tests — verify they pass** (not just the new test) + +```bash +go test ./internal/api/ -run TestRequireOrgRole -v -count=1 +``` + +**Step 5: Lint + commit** + +```bash +git commit -m "feat(api): deactivated org members get 403 in RequireOrgRole" +``` + +--- + +## Task 17: Member PATCH — deactivation + scim_exempt fields + +Extend the existing member PATCH endpoint to support `active` (bool) and `scim_exempt` (bool) fields. + +**Files:** +- Modify: member handler file — read `internal/api/orgs.go` first, or grep for `members/{user_id}` or `patchMemberHandler` to locate the exact file +- Modify: corresponding test file + +**Step 1: Write failing tests** + +Test cases: +- `TestPatchMember_Deactivate` — PATCH `{"active": false}` sets `deactivated_at` +- `TestPatchMember_Reactivate` — PATCH `{"active": true}` clears `deactivated_at` +- `TestPatchMember_SoleOwnerProtection` — cannot deactivate sole active owner → 400 +- `TestPatchMember_SCIMExempt` — PATCH `{"scim_exempt": true}` sets flag +- `TestPatchMember_RequiresAdmin` — viewer/member get 403 + +Tests must include `X-Requested-By: CVErt-Ops` header on all requests (CSRF middleware). + +**Step 2: Run tests — verify fail** + +**Step 3: Implement** + +Add `Active *bool` and `SCIMExempt *bool` to the patch member request struct (pointer types — see pitfalls §API-2). Semantics: `nil` = field not sent (no change), `*false` = deactivate/disable, `*true` = reactivate/enable. In the handler: +- If `Active != nil && *Active == false`: check `CountActiveOrgOwners`, if sole owner + target is owner → 400. Otherwise call `DeactivateOrgMember`. +- If `Active != nil && *Active == true`: call `ReactivateOrgMember`. +- If `SCIMExempt != nil`: call `UpdateOrgMemberSCIMExempt`. +- Audit log the changes with entity_type `member`. + +**Step 4: Also update `GET /members` response** to include `active`, `deactivated_at`, `scim_exempt` fields. + +**⚠️ Pitfall warnings for this task:** +- API-2: `Active *bool` and `SCIMExempt *bool` MUST be pointer types. If you use `bool` with `omitempty`, sending `{"active": false}` will be silently ignored (Go's zero value for bool is false, omitempty drops it) +- API-9: The same validation rules that apply to admin manual deactivation (sole-owner check) must also apply when SCIM triggers deactivation later — this task establishes the handler logic that SCIM reuses +- Testing §4: Test PATCH with `{"active": false}` explicitly — this is the critical case that breaks with non-pointer types + +**Step 5: Run tests, lint, commit** + +```bash +git commit -m "feat(api): member PATCH supports active + scim_exempt fields" +``` + +--- + +## Task 18: SCIM error helper + response types + +Shared SCIM JSON helpers used by all SCIM handlers. + +**Files:** +- Create: `internal/api/scim_types.go` +- Create: `internal/api/scim_types_test.go` + +**Step 1: Implement SCIM response types** + +```go +// ABOUTME: SCIM 2.0 request/response types and JSON helpers (RFC 7643/7644). +// ABOUTME: Used by all SCIM chi handlers. Separate from huma types. +package api +``` + +Types to define: +- `SCIMError` struct: `Schemas []string`, `Status string`, `SCIMType string`, `Detail string` +- `SCIMUser` struct: `Schemas []string`, `ID string`, `ExternalID string`, `UserName string`, `DisplayName string`, `Active bool`, `Meta SCIMMeta` +- `SCIMGroup` struct: similar +- `SCIMListResponse` struct: `Schemas []string`, `TotalResults int`, `ItemsPerPage int`, `StartIndex int`, `Resources []any` +- `SCIMMeta` struct: `ResourceType string`, `Created string`, `LastModified string`, `Location string` +- `SCIMPatchOp` struct: `Schemas []string`, `Operations []SCIMPatchOperation` +- `SCIMPatchOperation` struct: `Op string`, `Path string`, `Value any` +- `writeSCIMError(w, status, scimType, detail)` helper +- `writeSCIMJSON(w, status, body)` helper — sets `Content-Type: application/scim+json` +- `parseSCIMBool(v any) (bool, error)` — handles both JSON booleans and string `"True"`/`"False"` +- `parseSCIMFilter(filter string) ([]SCIMFilterExpr, error)` — parses the minimal filter grammar: `attr SP "eq" SP quotedValue` and compound `expr SP "and" SP expr`. Split on ` and ` (space-surrounded) first, then parse each part as `attr SP op SP value`. Only `eq` operator is supported; any other operator returns error with `scimType: "invalidFilter"`. No nested expressions, no `or`, no parentheses, no `not`. + +**Step 2: Write tests** + +Test `writeSCIMError`, `parseSCIMBool` (with string booleans from Entra ID), `parseSCIMFilter` (eq, and, unsupported operators → error), and JSON serialization of response types. Verify `Content-Type: application/scim+json` header. + +**Step 3: Lint + commit** + +```bash +git commit -m "feat(api): SCIM 2.0 response types, error helper, filter parser" +``` + +--- + +## Task 19: SCIM auth middleware (`requireSCIMAuth`) + +**Files:** +- Create: `internal/api/middleware_scim.go` +- Create: `internal/api/middleware_scim_test.go` + +**Step 1: Write failing tests** + +Test cases (from design §2): +- `TestSCIMAuth_ValidToken` — bearer token accepted, org_id + scim_config_id in context +- `TestSCIMAuth_InvalidToken` — wrong token → 401, SCIM error JSON format +- `TestSCIMAuth_OrgMismatch` — token for org A used on org B → 401 +- `TestSCIMAuth_Disabled` — disabled config → 403 +- `TestSCIMAuth_MissingHeader` — no Authorization header → 401 +- `TestSCIMAuth_ErrorFormat` — all auth failures return `Content-Type: application/scim+json` with RFC 7644 error body +- `TestSCIMAuth_SecurityEvent` — auth failures fire security events (verify in `security_events` table) + +Tests must NOT include `X-Requested-By` header — SCIM endpoints use Bearer token auth, not cookies, so CSRF middleware should not apply. This also verifies that SCIM routes are properly exempt from CSRF middleware (if they weren't exempt, requests without this header would get 403). + +**Step 2: Implement** + +Follow design doc §2 auth flow steps 1-9 exactly. Use `writeSCIMError()` from Task 18 for error responses. Fire `secure.EventWriter.Write()` for auth failures: +- Invalid/missing token → `EventSCIMAuthFailed` +- Org mismatch → `EventSCIMAuthOrgMismatch` +- Disabled config → `EventSCIMAuthDisabled` + +The middleware needs access to `*store.Store` and `*secure.EventWriter`. Get them from the `*Server` receiver. + +**⚠️ Pitfall warnings for this task:** +- AUTH-10: Token hash comparison MUST use `subtle.ConstantTimeCompare` — never `==` or `bytes.Equal` +- Testing §3: Auth failure error responses MUST return `Content-Type: application/scim+json` — test that the Content-Type header is correct on ALL error paths (missing header, invalid token, org mismatch, disabled) +- Testing §11: Test with a well-formed-but-wrong token (same prefix, correct length, different random bytes) — not just `"invalid"` string + +**Step 3: Run tests, lint, commit** + +```bash +git commit -m "feat(api): SCIM bearer token auth middleware with security events" +``` + +--- + +## Task 20: SSO delete handler — SCIM pre-flight check + +**Depends on:** Task 12 (provides `LookupSCIMConfigBySSOConnectionID` store method). + +Modify the existing SSO delete handler to check for attached SCIM config. + +**Files:** +- Modify: `internal/api/sso.go` +- Modify: `internal/api/sso_test.go` + +**Step 1: Write failing test** + +- `TestSSODelete_BlockedBySCIM` — SSO delete when SCIM config exists → 409 with actionable message + +**Step 2: Implement** + +In the SSO delete handler, before deleting: +1. Get the SSO connection ID from the request +2. Call `store.LookupSCIMConfigBySSOConnectionID(ctx, ssoConnID)` (uses `withBypassTx`) +3. If config exists → return 409 with RFC 9457 error (this is an admin endpoint, not SCIM, so use standard error format): + ```go + writeProblem(w, http.StatusConflict, "SSO connection has active SCIM provisioning", + "Disable and delete the SCIM configuration before removing SSO, or update the SSO connection in place.") + ``` + +**Step 3: Run existing SSO tests to ensure no regressions** + +```bash +go test ./internal/api/ -run TestSSO -v -count=1 +``` + +**Step 4: Lint + commit** + +```bash +git commit -m "feat(api): block SSO delete when SCIM config exists (409)" +``` + +--- + +## Task 21: SCIM rate limiter + +Separate rate limiter for SCIM endpoints. + +**Files:** +- Create: `internal/api/scim_ratelimit.go` +- Create: `internal/api/scim_ratelimit_test.go` + +**Step 1: Write failing test** + +- `TestSCIMRateLimit_EnforcesLimit` — exceeding the limit returns 429 with SCIM error format +- `TestSCIMRateLimit_PerOrg` — different orgs have independent limits + +**Step 2: Implement** + +Create a `scimRateLimiter` using `golang.org/x/time/rate` (same dependency already in use for API rate limiting). The limiter is keyed by org_id (UUID string). Rate is configured from `srv.cfg.SCIMRateLimit`. Mount as middleware after `requireSCIMAuth`. + +On rate limit exceeded, return SCIM error format: +```go +writeSCIMError(w, http.StatusTooManyRequests, "", "Rate limit exceeded") +``` + +Also fire `EventSCIMRateLimited` security event. + +**Step 3: Lint + commit** + +```bash +git commit -m "feat(api): dedicated SCIM rate limiter (configurable per org)" +``` + +--- + +## Task 22: Role recomputation function + +**Depends on:** Task 14 (provides `GetOrgMemberFull`, `UpdateOrgMemberRole` store methods) and Task 13 (provides `ListUserSCIMGroups`). + +Shared function called from SCIM handlers and admin mapping endpoints. + +**Files:** +- Create: `internal/api/scim_roles.go` +- Create: `internal/api/scim_roles_test.go` + +**Step 1: Write failing tests** + +Test cases (from design §3.6) with exact assertions: +- `TestRoleRecompute_SingleGroup` — user in group with `mapped_role="admin"` → recompute → `GetOrgMemberFull` → assert `role == "admin"` +- `TestRoleRecompute_MultipleGroups_HighestWins` — user in group A (`mapped_role="member"`) and group B (`mapped_role="admin"`) → recompute → assert `role == "admin"` (not "member") +- `TestRoleRecompute_NoMappedGroups` — user in group with `mapped_role=NULL`, `default_role="viewer"` → recompute → assert `role == "viewer"` +- `TestRoleRecompute_NeverSetsOwner` — user in group with `mapped_role="admin"` → recompute → assert `role == "admin"` (never "owner", even if somehow mapped) +- `TestRoleRecompute_SCIMExempt_Skipped` — user has `scim_exempt=true`, current `role="viewer"`, in group with `mapped_role="admin"` → recompute → assert `role` is still "viewer" (unchanged) +- `TestRoleRecompute_OwnerNotDowngraded` — user with `role="owner"` in group with `mapped_role="viewer"` → recompute → assert `role` is still "owner" +- `TestRoleRecompute_RemovedFromAllGroups` — user was in a group with `mapped_role="admin"`, remove from group → recompute → assert `role` falls back to `default_role` (e.g., "viewer") + +These are integration tests using `testutil.NewTestDB(t)`. Each test must set up a complete environment: org (enterprise tier) + SSO connection + SCIM config + user + org_member + SCIM group + SCIM group membership. + +**Step 2: Implement** + +Follow design doc §3.6 exactly. The function signature: + +```go +func (srv *Server) recomputeSCIMRole(ctx context.Context, orgID, userID uuid.UUID, defaultRole string) error +``` + +Use `srv.store.GetOrgMemberFull()` to check current role and scim_exempt. Use `srv.store.ListUserSCIMGroups()` to get group mappings. Use `srv.store.UpdateOrgMemberRole()` to write the new role. + +Role hierarchy map: `map[string]int{"viewer": 1, "member": 2, "admin": 3}`. + +**Step 3: Run tests, lint, commit** + +```bash +git commit -m "feat(api): SCIM role recomputation from group mappings" +``` + +--- + +## Task 23: Notification group sync function + +**Files:** +- Create: `internal/api/scim_notif_sync.go` +- Create: `internal/api/scim_notif_sync_test.go` + +**Step 1: Write failing tests** + +Test cases (from design §3.7) with exact assertions: +- `TestNotifSync_Add_NewMember` — call `syncNotifGroupAdd` → query `group_members` → assert row exists with `scim_managed=true` +- `TestNotifSync_Add_AlreadyManualMember` — manually add member with `scim_managed=false` → call `syncNotifGroupAdd` → assert `scim_managed` is still `false` (ON CONFLICT DO NOTHING preserves manual flag) +- `TestNotifSync_Remove_SCIMManaged` — add member with `scim_managed=true` → call `syncNotifGroupRemove` → query `group_members` → assert row is gone +- `TestNotifSync_Remove_ManualMember` — add member with `scim_managed=false` → call `syncNotifGroupRemove` → assert row still exists (admin owns it) +- `TestNotifSync_Remove_MultiMapping` — user in SCIM group A and B, both mapped to same notification group → remove from group A → assert `group_members` row still exists (group B still maps). Then remove from group B → assert row is gone +- `TestNotifSync_GroupDelete_NoRemoval` — add via SCIM sync → delete the SCIM group → assert `group_members` row in notification group is STILL present +- `TestNotifSync_ExemptUser_Skipped` — user has `scim_exempt=true` → call `syncNotifGroupAdd` → assert NO row created in `group_members` +- `TestNotifSync_SoftDeletedTargetGroup` — soft-delete the notification group (`deleted_at=now()`) → call `syncNotifGroupAdd` → assert NO row created (no-op, no error) + +**Step 2: Implement** + +Two functions: +- `syncNotifGroupAdd(ctx, orgID, userID, mappedGroupID, scimGroupID)` — first calls `GetGroupIfActive` to verify target group exists and `deleted_at IS NULL`. If soft-deleted, return nil (no-op). Otherwise, call `AddGroupMemberSCIMManaged`. +- `syncNotifGroupRemove(ctx, orgID, userID, mappedGroupID, scimGroupID)` — calls `CountOtherSCIMGroupsWithSameMapping`. If count > 0, return nil. Otherwise, call `RemoveSCIMManagedGroupMember`. + +**Step 3: Run tests, lint, commit** + +```bash +git commit -m "feat(api): notification group sync from SCIM group mappings" +``` + +--- + +## Task 24: SCIM User handlers + +**Depends on:** Tasks 12, 14 (store methods), Task 15 (token generation), Task 18 (SCIM types), Task 19 (auth middleware). + +The core SCIM user CRUD handlers. + +**Files:** +- Create: `internal/api/scim_users.go` +- Create: `internal/api/scim_users_test.go` + +**Step 1: Write failing tests** + +Integration tests using `testutil.NewTestDB(t)` + `httptest.Server`. Test cases from design doc §7 — User Provisioning, User Read/List, User Update, User Deprovision sections. + +Key test patterns: +- Setup: create org (enterprise tier) + SSO connection + SCIM config via store. Create users as needed. +- Each test makes HTTP requests with Bearer token auth through the full middleware stack. +- Verify SCIM JSON response format (schemas array, Content-Type header). +- Verify audit log entries are created. +- Verify security events for provisioning operations. + +**Step 2: Implement handlers** + +Implement as chi `HandlerFunc` methods on `*Server`: +- `scimCreateUser(w, r)` — POST /Users (design doc §3.3) +- `scimGetUser(w, r)` — GET /Users/{id} +- `scimListUsers(w, r)` — GET /Users (with filter parsing) +- `scimReplaceUser(w, r)` — PUT /Users/{id} +- `scimPatchUser(w, r)` — PATCH /Users/{id} +- `scimDeleteUser(w, r)` — DELETE /Users/{id} + +Each handler: +1. Extracts `orgID` and `scimConfigID` from context (set by `requireSCIMAuth`) +2. Reads request body and parses JSON +3. Performs business logic using store methods +4. Writes SCIM JSON response using `writeSCIMJSON` +5. Fires audit log entry +6. Fires security events where applicable +7. Emits slog with standard attributes (`org_id`, `scim_config_id`, operation-specific fields) + +Identity matching in POST /Users follows design doc §3.2-3.3 exactly: +1. Check `user_identities` for `provider='scim:{config_id}'`, `provider_user_id=externalId` +2. If not found, check `users` by email +3. If not found, create new user + +Transaction splitting: `users`/`user_identities` writes use `withBypassTx` (global tables, no RLS). `org_members` writes use `withOrgTx` (org-scoped, RLS). This is NOT a pitfall violation (AUTH-12) — `withBypassTx` is correct here because `users` and `user_identities` are global tables that cannot be written through `withOrgTx`. See design doc §3.3 transaction note. + +**⚠️ Pitfall warnings for this task:** +- API-2: PATCH `active` field MUST be `*bool` pointer — `omitempty` on non-pointer bool silently drops `false` values +- API-9: PUT/PATCH userName validation must reject empty/whitespace strings the same way POST does +- Testing §4: Test PATCH with explicit `false` value for `active` — not just `true`. `false` via pointer is different from absent +- Testing §1: `TestSCIMCreateUser_ConcurrentDuplicate` must use barrier pattern (`close(ready)`) to ensure goroutines hit the critical section simultaneously +- Testing §3: Error responses for 404, 409, 400 must all return `Content-Type: application/scim+json` — assert Content-Type on EVERY error test case +- DB-17: Store method selection matters — read `dev/implementation-pitfalls.md` §DB-17 before choosing transaction helpers + +**Step 3: Run tests, lint, commit** + +```bash +git commit -m "feat(api): SCIM user handlers (create, get, list, put, patch, delete)" +``` + +--- + +## Task 25: SCIM Group handlers + +**Files:** +- Create: `internal/api/scim_groups.go` +- Create: `internal/api/scim_groups_test.go` + +**Step 1: Write failing tests** + +Test cases from design doc §7 — Group Operations section. + +**Step 2: Implement handlers** + +- `scimCreateGroup(w, r)` — POST /Groups +- `scimGetGroup(w, r)` — GET /Groups/{id} +- `scimListGroups(w, r)` — GET /Groups +- `scimReplaceGroup(w, r)` — PUT /Groups/{id} +- `scimPatchGroup(w, r)` — PATCH /Groups/{id} +- `scimDeleteGroup(w, r)` — DELETE /Groups/{id} + +**PATCH member removal must handle both formats** (design doc §4.2): +- Standard: `op: "remove", path: "members[value eq \"user-uuid\"]"` — extract user UUID from path filter using regex `members\[value eq "(.+)"\]` or string parsing +- Entra ID: `op: "remove", path: "members", value: [{value: "user-uuid"}]` — type-assert `Value` to `[]any`, then extract `value` field from each map element + +Each membership change triggers `recomputeSCIMRole()` + `syncNotifGroupAdd/Remove()` for the affected user (if not exempt and if mappings are configured). + +**Step 3: Run tests, lint, commit** + +```bash +git commit -m "feat(api): SCIM group handlers (create, get, list, put, patch, delete)" +``` + +--- + +## Task 26: SCIM route mounting + discovery endpoints + +Wire SCIM handlers into the chi router. + +**Files:** +- Modify: `internal/api/server.go` — add SCIM route mount +- Create: `internal/api/scim_discovery.go` — static discovery responses +- Create: `internal/api/scim_routes_test.go` + +**Step 1: Write failing tests** + +- `TestSCIMRoutes_UsersAccessible` — `GET /api/v1/orgs/{org_id}/scim/v2/Users` returns 401 (not 404) +- `TestSCIMRoutes_GroupsAccessible` — same for Groups +- `TestSCIMRoutes_ServiceProviderConfig` — returns correct JSON with `patch: true`, `bulk: false` +- `TestSCIMRoutes_Schemas` — returns User + Group schema definitions +- `TestSCIMRoutes_ResourceTypes` — returns User + Group resource type metadata + +**Step 2: Implement route mounting** + +In the server's route setup, add SCIM routes under the org path: + +```go +r.Route("/scim/v2", func(r chi.Router) { + r.Use(srv.requireSCIMAuth) + r.Use(srv.scimRateLimitMiddleware) + + // Discovery endpoints (no auth — but still behind requireSCIMAuth since + // they're org-scoped and IdPs use the same token for discovery) + r.Get("/ServiceProviderConfig", srv.scimServiceProviderConfig) + r.Get("/Schemas", srv.scimSchemas) + r.Get("/ResourceTypes", srv.scimResourceTypes) + + // User endpoints + r.Get("/Users", srv.scimListUsers) + r.Post("/Users", srv.scimCreateUser) + r.Get("/Users/{id}", srv.scimGetUser) + r.Put("/Users/{id}", srv.scimReplaceUser) + r.Patch("/Users/{id}", srv.scimPatchUser) + r.Delete("/Users/{id}", srv.scimDeleteUser) + + // Group endpoints + r.Get("/Groups", srv.scimListGroups) + r.Post("/Groups", srv.scimCreateGroup) + r.Get("/Groups/{id}", srv.scimGetGroup) + r.Put("/Groups/{id}", srv.scimReplaceGroup) + r.Patch("/Groups/{id}", srv.scimPatchGroup) + r.Delete("/Groups/{id}", srv.scimDeleteGroup) +}) +``` + +**Important:** Read `internal/api/server.go` to understand the route structure. Key considerations: +1. SCIM routes must be OUTSIDE the `RequireAuthenticated` middleware group — they use their own `requireSCIMAuth` middleware. Look at how SSO callback routes are mounted (likely in a separate route group without RequireAuthenticated) and follow the same pattern. +2. SCIM routes must be AFTER request ID middleware in the chain so slog output includes correlation IDs. +3. SCIM routes should be under the org path: `/api/v1/orgs/{org_id}/scim/v2/...` +4. The SCIM route group needs its own middleware stack: `requireSCIMAuth` → `scimRateLimitMiddleware` → handlers. +5. SCIM routes should also be exempt from CSRF middleware (Bearer token auth, not cookie auth). + +**Step 3: Implement discovery endpoints** + +Static JSON responses from design doc §3.5. Served with `Content-Type: application/scim+json`. + +**Step 4: Run tests, lint, commit** + +```bash +git commit -m "feat(api): mount SCIM routes + discovery endpoints" +``` + +--- + +## Task 27: Admin endpoints — SCIM config CRUD + +Standard chi handlers for managing SCIM config. Enterprise-only, owner-only. + +**Files:** +- Create: `internal/api/scim_admin.go` +- Create: `internal/api/scim_admin_test.go` + +**Step 1: Write failing tests** + +Test cases from design doc §7 — SCIM Config Management section. All requests need `X-Requested-By: CVErt-Ops` header (these are standard auth endpoints, not SCIM protocol). + +**Step 2: Implement handlers** + +Follow the SSO handler pattern in `internal/api/sso.go`: +- Extract `orgID` from context +- Call `requireEnterpriseTier(w, r)` — returns false if tier check fails +- Validate input +- Call store methods +- Return JSON response (NOT SCIM format — these are admin endpoints using standard RFC 9457 errors) +- Audit log with entity_type `scim_config` +- Fire security events for token create/rotate + +Register routes under the existing org route group: + +```go +r.Route("/sso/scim", func(r chi.Router) { + r.With(srv.RequireOrgRole(RoleOwner)).Post("/", srv.createSCIMConfigHandler) + r.With(srv.RequireOrgRole(RoleAdmin)).Get("/", srv.getSCIMConfigHandler) + r.With(srv.RequireOrgRole(RoleOwner)).Patch("/", srv.patchSCIMConfigHandler) + r.With(srv.RequireOrgRole(RoleOwner)).Delete("/", srv.deleteSCIMConfigHandler) + r.With(srv.RequireOrgRole(RoleOwner)).Post("/rotate-token", srv.rotateSCIMTokenHandler) + r.With(srv.RequireOrgRole(RoleAdmin)).Get("/groups", srv.listSCIMGroupsHandler) + r.Route("/groups/{id}/mapping", func(r chi.Router) { + r.With(srv.RequireOrgRole(RoleAdmin)).Patch("/", srv.patchSCIMGroupMappingHandler) + }) +}) +``` + +**Step 3: Run tests, lint, commit** + +```bash +git commit -m "feat(api): SCIM config admin endpoints (CRUD, token rotation, group mapping)" +``` + +--- + +## Task 28: Group mapping with immediate effect + +When a group's `mapped_role` or `mapped_group_id` changes, recompute roles / sync notification groups for all current members immediately. + +**Files:** +- Modify: `internal/api/scim_admin.go` (already created in Task 27) +- Add tests to `internal/api/scim_admin_test.go` + +**Step 1: Write failing tests** + +- `TestGroupMapping_SetRole` — mapped_role applied immediately to existing members +- `TestGroupMapping_SetNotificationGroup` — mapped_group_id triggers immediate sync +- `TestGroupMapping_ClearMapping` — null mapped_role → roles recomputed to default +- `TestGroupMapping_CrossOrgGroupId` — mapped_group_id from different org → 400 +- `TestGroupMapping_SoftDeletedGroupId` — mapped_group_id to soft-deleted group → 400 +- `TestGroupMapping_MappingChanged_OldGroupCleanedUp` — old notification group members cleaned up + +**Step 2: Implement** + +In `patchSCIMGroupMappingHandler`: +1. Update `scim_groups.mapped_role` and/or `mapped_group_id` +2. If `mapped_group_id` is set, validate it belongs to the same org AND `deleted_at IS NULL` (call `GetGroupIfActive` — FK bypasses RLS, soft-delete not visible to FK) +3. List all current members of the SCIM group +4. For each non-exempt member: `recomputeSCIMRole()` if mapped_role changed, `syncNotifGroupAdd/Remove()` if mapped_group_id changed +5. If mapped_group_id changed from a previous value: clean up scim_managed memberships in the old notification group + +**Step 3: Run tests, lint, commit** + +```bash +git commit -m "feat(api): SCIM group mapping with immediate role/notif recomputation" +``` + +--- + +## Task 29: End-to-end SCIM integration tests + +Full workflow tests simulating IdP provisioning flows. + +**Files:** +- Create: `internal/api/scim_e2e_test.go` + +Test scenarios: +1. **Full provisioning lifecycle:** Create SCIM config → provision user → deactivate → reactivate → deprovision +2. **Group role mapping:** Create group with mapping → add member → verify role applied → remove member → verify role reverted to default +3. **Notification group sync:** Create group with mapped_group_id → add member → verify notification group membership → remove → verify cleanup +4. **Entra ID compatibility:** Capitalized op names, string booleans, value-array member removal +5. **Okta compatibility:** PUT for attribute updates, PATCH only for activation +6. **Test connection patterns:** Entra ID `GET /Users?filter=id eq "{random-guid}"`, Okta `GET /Users?startIndex=1&count=1` +7. **Cross-org isolation:** Provision user in org A → verify invisible from org B's SCIM endpoint +8. **SCIM exempt user:** All operations return success but no modifications +9. **Sole-owner protection:** Attempt to deactivate sole owner via SCIM → verify 400 +10. **Token rotation:** Rotate token → old token rejected → new token works +11. **Error Content-Type consistency:** All error responses (auth, validation, not-found) return `Content-Type: application/scim+json` — never `text/plain` from middleware layers (testing-pitfalls §3) + +These tests use `testutil.NewTestDB(t)` and make HTTP requests through `httptest.Server` to test the full middleware → handler → store stack. + +```bash +git commit -m "test(scim): end-to-end SCIM provisioning integration tests" +``` + +--- + +## Task 30: Audit + security event verification pass + +Ensure all SCIM operations produce correct audit log entries and security events. + +**Files:** +- Modify: SCIM handler files (add any missing audit/security event calls) +- Modify: existing SCIM test files (add audit/security event assertions) + +**Step 1: Verify audit logging** + +For each SCIM operation, verify in existing tests that: +- Audit log entry is created with correct `entity_type` (`scim_config`, `scim_group`, or `member`) +- Audit log entry has correct `action` (`create`, `update`, `delete`) +- SCIM protocol operations have `metadata: {"source": "scim", "scim_config_id": ""}` +- Exempt user operations have `metadata: {"source": "scim", "suppressed": true, "reason": "scim_exempt"}` + +**Step 2: Verify security events** + +Verify that all event constants defined in Task 6 are actually emitted somewhere (testing-pitfalls §7). For each constant, grep for its usage outside `events.go`. If a constant is defined but never emitted, either add the emit call or remove the constant. + +**Step 3: Verify slog attributes** + +Verify that all SCIM log lines include `org_id` and `scim_config_id` standard attributes. + +```bash +git commit -m "feat(scim): complete audit logging + security event verification" +``` + +--- + +## Task 31: Final review and cleanup + +**Files:** +- All files created/modified in this phase + +**Step 1: Run full test suite** + +```bash +go test ./... -count=1 -timeout=600s +``` + +If Docker Desktop is unavailable, this is a HARD BLOCKER. Do not proceed. Escalate to Sam. + +**Step 2: Run with race detector** + +```bash +go test ./... -count=1 -race -timeout=600s +``` + +**Step 3: Lint** + +```bash +golangci-lint run +``` + +**Step 4: Verify ABOUTME comments** + +Every new Go file must start with two `// ABOUTME:` comment lines. + +**Step 5: Update implementation log** + +Invoke the `implementation-log` skill to add Phase 7 summary. + +**Step 6: Run /pitfall-check** + +Invoke the `pitfall-check` skill on the SCIM code. + +**Step 7: Run /security-review** + +Invoke the `security-review` skill on SCIM auth, token handling, and tenant isolation code. + +**Step 8: Run /plan-check** + +Invoke the `plan-check` skill against PLAN.md §7.2. + +**Step 9: Commit** + +```bash +git commit -m "chore(scim): Phase 7 final review, cleanup, and verification" +``` + +--- + +## Execution Waves (Subagent-Driven) + +Each wave completes fully (tests green, lint clean, committed) before the next wave starts. Within a wave, parallel lanes run as independent subagents in worktrees. **Review checkpoint** after each wave — merge results, verify integration, course-correct before proceeding. + +### Wave 1: Foundation (sequential — main agent) +**Tasks:** 1, 2, 3, 4, 5, 6, 7 +**Why sequential:** Migrations depend on each other. Security events and config must exist first. These are small, mechanical tasks. +**Deliverable:** All migrations applied, security event constants defined, sqlc regenerated, `go build ./...` passes. +**Review checkpoint:** Run 3-round review. Verify: all migrations have `-- migrate:no-transaction` where needed (CONCURRENTLY indexes). All RLS policies use dual-escape pattern. Security event exhaustiveness test passes. `go build ./...` clean. + +### Wave 2: Data Layer (3 parallel subagents) +| Lane A | Lane B | Lane C | +|--------|--------|--------| +| Task 8: sqlc — SCIM config queries | Task 9: sqlc — SCIM group queries | Task 10: sqlc — org deactivation queries | +| Task 12: Store — SCIM config methods + tests | Task 13: Store — SCIM group methods + tests | Task 11: sqlc — group_members SCIM queries | +| | | Task 14: Store — deactivation methods + tests | + +**Why parallel:** Each lane touches independent query files and store files. +**Deliverable:** Complete store layer with passing RLS isolation tests. +**Review checkpoint:** Run 3-round review. Verify: sqlc generation is clean after merging all three lanes. ALL store tests use `AppStore` for RLS verification (not `Store`). Every store method test verifies the returned VALUE, not just `err == nil`. `go test ./internal/store/ -count=1` passes. + +### Wave 3: Cross-Cutting Utilities (3 parallel subagents) +| Lane A | Lane B | Lane C | +|--------|--------|--------| +| Task 15: SCIM token generation | Task 16: RequireOrgRole deactivation check | Task 18: SCIM error helper + response types | +| Task 19: SCIM auth middleware | Task 17: Member PATCH deactivation + scim_exempt | Task 22: Role recomputation function | +| Task 21: SCIM rate limiter | Task 20: SSO delete SCIM pre-flight check | Task 23: Notification group sync function | + +**Lane A:** SCIM auth stack (token → middleware → rate limiter), sequential within. +**Lane B:** General deactivation feature (middleware → API → SSO guard), sequential within. +**Lane C:** SCIM types and business logic (types → roles → notif sync), sequential within. +**Deliverable:** SCIM auth tested, deactivation works, role recompute + notif sync tested. +**Review checkpoint:** Run 3-round review. Verify: SCIM error format (not RFC 9457). Deactivation returns 403 (not 401). Role hierarchy correct (admin > member > viewer). Auth middleware uses `subtle.ConstantTimeCompare`. Security events fire on auth failures. `go test ./internal/api/ ./internal/auth/ -count=1` passes. + +### Wave 4: SCIM Handlers + Admin (3 parallel subagents) +| Lane A | Lane B | Lane C | +|--------|--------|--------| +| Task 24: SCIM User handlers | Task 25: SCIM Group handlers | Task 27: Admin SCIM config endpoints | +| | | Task 28: Group mapping with immediate effect | + +**Lane A:** User CRUD — the largest single task. +**Lane B:** Group CRUD — depends on role recompute + notif sync from Wave 3. +**Lane C:** Admin endpoints — standard chi handlers. +**Deliverable:** All SCIM and admin handlers working. +**Review checkpoint:** Run 3-round review. Verify: Entra ID quirks handled (case-insensitive ops, string booleans, value-array member removal). PUT is a full implementation (not PATCH wrapper) — critical for Okta. Content-Type is `application/scim+json` on ALL responses. Admin endpoints use RFC 9457 errors (not SCIM format). SCIM `schemas` array is included in every response. `go test ./internal/api/ -count=1 -run SCIM` passes. + +### Wave 5: Integration (sequential — main agent) +**Task:** 26 +**Why sequential:** Route mounting is the riskiest integration point — tight feedback loop needed. +**Deliverable:** Full SCIM endpoint stack accessible at `/api/v1/orgs/{org_id}/scim/v2/*`. + +### Wave 6: Verification (2 parallel subagents) +| Lane A | Lane B | +|--------|--------| +| Task 29: E2E integration tests | Task 30: Audit + security event verification | + +**Deliverable:** All SCIM flows tested E2E. Audit + security events verified. +**Review checkpoint:** Run 3-round review. Verify: all 10 security event constants from Task 6 are emitted in at least one code path (grep). All audit log entries have correct `entity_type` and `metadata.source`. E2E tests cover both Entra ID and Okta compatibility scenarios. `go test ./internal/api/ -count=1 -timeout=300s` passes. + +### Wave 7: Final Review (sequential — main agent) +**Task:** 31 +Runs full test suite, race detector, lint, pitfall-check, security-review, plan-check. + +--- + +## Dependency Graph (reference) + +``` +Wave 1: [1] → [2] → [3] → [4] → [5] → [6] → [7] + ↓ +Wave 2: [8→12] || [9→13] || [10→11→14] + ↓ +Wave 3: [15→19→21] || [16→17→20] || [18→22→23] + ↓ +Wave 4: [24] || [25] || [27→28] + ↓ +Wave 5: [26] + ↓ +Wave 6: [29] || [30] + ↓ +Wave 7: [31] +``` + +--- + +## Appendix: Autonomous Decisions Made During Execution + +These decisions were made by Claude during overnight execution (Sam asleep) per the instruction "make a reasonable decision yourself and clearly document the decision point." + +### D1: `SCIMRateLimit` config type — `float64` vs `int` + +**Context:** Wave 1 defined `SCIMRateLimit int`, Wave 3 Lane A defined `SCIMRateLimit float64`. +**Decision:** Kept `float64` because `golang.org/x/time/rate.Limit` is typed `float64`. Using `int` would require a cast at every usage site. +**Risk:** None — both parse from the same env var, and the default `50` works for both types. + +### D2: `withBypassTx` for SCIM group methods without `orgID` parameter + +**Context:** Wave 2 Lane B noted that `GetSCIMGroup(id)`, `DeleteSCIMGroup(id)`, and similar methods don't take `orgID` as a parameter, making `withOrgTx` impossible. +**Decision:** Used `withBypassTx` for these methods, matching the existing `GetSSOConnectionByID` pattern. RLS still protects at the DB level since queries filter by `id` (UUID PK). +**Risk:** Low — the SCIM auth middleware verifies org ownership before these methods are called. + +### D3: `writeSCIMError` duplicate resolution + +**Context:** Wave 3 Lane A (middleware_scim.go) and Lane C (scim_types.go) independently implemented `writeSCIMError`. +**Decision:** Kept the scim_types.go version (canonical location for SCIM response helpers). Removed the duplicate from middleware_scim.go. +**Risk:** None — both implementations were identical. + +### D4: Wave 6 worktree isolation failure + +**Context:** Wave 6 agents were launched in worktrees that branched from `origin/dev`, which only had Wave 1 code. Waves 2-5 were on local `dev` but not pushed. +**Decision:** After Sam pushed dev to origin, relaunched the E2E test agent without worktree isolation (directly on dev). Handled Task 30 (audit verification) directly as orchestrator. +**Risk:** Non-worktree agents can't be rolled back as cleanly, but test-only changes are safe. + +### D5: Suppressed audit entries for exempt users + +**Context:** Wave 6 Lane B added `scimAuditLog` calls at exempt-user skip points with `metadata: {"source": "scim", "suppressed": true, "reason": "scim_exempt"}`. This wasn't in the original plan. +**Decision:** Accepted — provides audit trail visibility for suppressed operations, which is valuable for compliance. Design doc §2 already specified this metadata pattern. +**Risk:** None — purely additive audit entries. diff --git a/dev/plans/2026-03-19-phase7-scim-provisioning-design-v2.md b/dev/plans/2026-03-19-phase7-scim-provisioning-design-v2.md new file mode 100644 index 00000000..929f9b5c --- /dev/null +++ b/dev/plans/2026-03-19-phase7-scim-provisioning-design-v2.md @@ -0,0 +1,979 @@ +# Phase 7 — SCIM 2.0 User & Group Provisioning: Design (v2) + +**Date:** 2026-03-19 (revised from 2026-03-01 original) +**Scope:** SCIM 2.0 service provider for automated user/group provisioning from enterprise IdPs +**PLAN.md refs:** §7.2 (SSO), §6 (RLS/multi-tenancy), §7.3 (RBAC) +**Depends on:** Phase 5D (Generic OIDC — `sso_connections` table, tier gating, audit log), Phase 6B (MFA) + +## Revision Summary (v1 → v2) + +| # | Change | Reason | +|---|--------|--------| +| 1 | **Dropped `marcelom97/scimgateway`** — implement SCIM handlers directly as chi handlers | Library has 0 importers, 16 stars, 6 weeks old. Unacceptable supply chain risk for a security product's identity provisioning layer. | +| 2 | **Migration numbers 000042–000046** (was 000029–000032) | 13 migrations added since original plan. Latest is 000041. | +| 3 | **Added security events** (`secure.EventWriter`) for SCIM auth and provisioning | Original plan only covered audit log and slog. Security events pipeline (`internal/secure/`) was missing — critical for SOC monitoring and syslog forwarding. | +| 4 | **Added audit_log entity types** migration | `scim_config` and `scim_group` need to be added to the `action` CHECK constraint. Also need migration for `entity_type` values. | +| 5 | **Specified slog structured attributes** | Standard fields for all SCIM log lines: `org_id`, `scim_config_id`, `operation`, `resource_type`. Request ID coverage via existing `middleware_request_id.go`. | +| 6 | **MFA interaction: explicitly not applicable** | SCIM-provisioned users are federated SSO users. MFA enforcement is delegated to the IdP. | +| 7 | **Documented three-level access denial hierarchy** | `users.disabled_at` (auth middleware) → `users.locked_at` (auth middleware) → `org_members.deactivated_at` (RBAC middleware). | +| 8 | **Changed `ON DELETE CASCADE` to `ON DELETE RESTRICT`** on `scim_configs.sso_connection_id` | Prevents silent destruction of SCIM config when admin deletes SSO to reconfigure. SSO delete handler returns 409 when SCIM config exists. | +| 9 | **Added SCIM rate limiter config** | Configurable via `SCIM_RATE_LIMIT` env var (default 50 req/sec). | +| 10 | **SCIM endpoints use chi handlers** (not huma) | SCIM wire protocol (RFC 7644 error format, `application/scim+json`, SCIM PATCH ops, ListResponse envelope) is incompatible with huma's conventions. Same pattern as SSO callback handlers. | + +--- + +## Scope Decisions + +| Item | Decision | +|------|----------| +| Implementation approach | Direct chi handlers implementing RFC 7643/7644. No third-party SCIM library. SCIM is a REST API with specific JSON schemas — we already have chi for routing and our own store layer for business logic. | +| SCIM + invitations | SCIM bypasses invitation system. SCIM-provisioned users are auto-created and added to org directly. Invitations remain for non-SCIM orgs. | +| Deprovisioning | Soft-deactivate: `org_members.deactivated_at` timestamp. User can't access org but data associations preserved. Re-provisioning reactivates. | +| User deactivation scope | General feature, not SCIM-exclusive. Admins can manually deactivate/reactivate via member PATCH. SCIM automates it. | +| Group mapping | Separate `scim_groups` table with optional role mapping (`mapped_role`) and notification group mapping (`mapped_group_id`). IdP group structure preserved independently. | +| Role mapping model | Apply-on-write: role updated on `org_members.role` when SCIM group membership or role mapping changes. Not periodically re-evaluated. | +| Default SCIM role | `viewer` (least privilege). Configurable per org on `scim_configs.default_role`. | +| Owner protection | SCIM cannot set role to `owner`. Cannot deactivate sole active owner (returns SCIM 400 error). | +| SCIM exemption | `org_members.scim_exempt` flag. Exempt users' state is not modified by SCIM. Operations return success silently (prevents IdP retry storms). For break-glass accounts. | +| Notification group sync | Included. `group_members.scim_managed` flag tracks source. SCIM removal only affects SCIM-managed memberships. | +| User identity matching | `externalId` is the durable key (via `user_identities`). Email is a one-time initial matching heuristic only. | +| Tier gating | Enterprise-only (same gate as SSO). | +| MFA interaction | Not applicable. SCIM-provisioned users authenticate via federated SSO; MFA enforcement is delegated to the IdP. CVErt Ops MFA infrastructure (TOTP, email OTP) only applies to locally-authenticated users. | + +--- + +## 1. Schema + +### New Tables + +```sql +-- SCIM provisioning config, 1:1 with organizations (via sso_connections) +CREATE TABLE IF NOT EXISTS scim_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE, + sso_connection_id UUID NOT NULL UNIQUE REFERENCES sso_connections(id) ON DELETE RESTRICT, + enabled BOOLEAN NOT NULL DEFAULT false, + token_hash TEXT NOT NULL, -- sha256 of bearer token + token_prefix TEXT NOT NULL, -- first 8 chars for display/identification + default_role TEXT NOT NULL DEFAULT 'viewer' + CHECK (default_role IN ('viewer', 'member')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS scim_configs_token_hash_idx + ON scim_configs (token_hash); + +ALTER TABLE scim_configs ENABLE ROW LEVEL SECURITY; +ALTER TABLE scim_configs FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON scim_configs + USING (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid) + WITH CHECK (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid); + +GRANT SELECT, INSERT, UPDATE, DELETE ON scim_configs TO cvert_ops_app; + + +-- IdP groups with optional role + notification group mappings +CREATE TABLE IF NOT EXISTS scim_groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + external_id TEXT, -- IdP's externalId for the group + display_name TEXT NOT NULL, + mapped_role TEXT CHECK (mapped_role IN ('viewer', 'member', 'admin')), + mapped_group_id UUID REFERENCES groups(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (org_id, display_name) +); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS scim_groups_org_id_idx + ON scim_groups (org_id); +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS scim_groups_org_external_id_idx + ON scim_groups (org_id, external_id) WHERE external_id IS NOT NULL; +CREATE INDEX CONCURRENTLY IF NOT EXISTS scim_groups_mapped_group_id_idx + ON scim_groups (mapped_group_id); + +ALTER TABLE scim_groups ENABLE ROW LEVEL SECURITY; +ALTER TABLE scim_groups FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON scim_groups + USING (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid) + WITH CHECK (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid); + +GRANT SELECT, INSERT, UPDATE, DELETE ON scim_groups TO cvert_ops_app; + + +-- SCIM group membership (denormalized org_id for RLS) +CREATE TABLE IF NOT EXISTS scim_group_members ( + scim_group_id UUID NOT NULL REFERENCES scim_groups(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (scim_group_id, user_id) +); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS scim_group_members_org_id_idx + ON scim_group_members (org_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS scim_group_members_user_id_idx + ON scim_group_members (user_id); + +ALTER TABLE scim_group_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE scim_group_members FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON scim_group_members + USING (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid) + WITH CHECK (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid); + +GRANT SELECT, INSERT, DELETE ON scim_group_members TO cvert_ops_app; +``` + +### Existing Table Modifications + +```sql +-- org_members: deactivation + SCIM exemption +ALTER TABLE org_members ADD COLUMN deactivated_at TIMESTAMPTZ; +ALTER TABLE org_members ADD COLUMN scim_exempt BOOLEAN NOT NULL DEFAULT false; + +-- group_members: SCIM sync source tracking +ALTER TABLE group_members ADD COLUMN scim_managed BOOLEAN NOT NULL DEFAULT false; + +-- audit_log: expand entity_type CHECK to include SCIM types +-- (see migration 000044 for exact ALTER) +``` + +### Schema Design Notes + +- `scim_configs` is 1:1 with `sso_connections` — SCIM requires SSO. Token stored separately from API keys (different auth semantics: no user, no RBAC role, SCIM-specific middleware). +- **`ON DELETE RESTRICT`** on `sso_connection_id` — prevents silent destruction of SCIM config when SSO is deleted. SSO delete handler checks for attached SCIM config and returns 409 with actionable message: "Disable and delete the SCIM configuration first, or update the SSO connection in place." Admins should use PATCH to reconfigure SSO rather than delete+recreate. +- `scim_groups` references `organizations(id)` directly, NOT `scim_configs(id)`. If SCIM config is deleted, admin-configured group mappings survive for potential re-setup. +- `org_members.deactivated_at` is a general feature — admin-settable via member PATCH, SCIM-automatable. `RequireOrgRole` middleware checks `deactivated_at IS NULL`. +- `org_members.scim_exempt` prevents SCIM from modifying the user's membership state. Admin-managed only. +- `group_members.scim_managed` tracks whether a notification group membership was created by SCIM sync. SCIM removal only deletes `scim_managed = true` rows; manually-added memberships are preserved. +- `mapped_role` excludes `owner` — owner assignment is always manual. + +### Access Denial Hierarchy (three independent layers) + +| Layer | Column | Checked in | Scope | Effect | +|-------|--------|-----------|-------|--------| +| 1 | `users.disabled_at` | `RequireAuthenticated` middleware | All orgs | Blocks all auth attempts site-wide | +| 2 | `users.locked_at` | `RequireAuthenticated` middleware | All orgs | Temporary brute-force lockout | +| 3 | `org_members.deactivated_at` | `RequireOrgRole` middleware | Per-org | Blocks access to specific org | + +Each layer is independent. A user can be locked out (layer 2) but not deactivated in any org (layer 3). SCIM only interacts with layer 3. + +--- + +## 2. SCIM Authentication + +### Token Lifecycle + +1. Org owner creates SCIM config via `POST /api/v1/orgs/{org_id}/sso/scim` (Enterprise-only, tier-gated, requires active SSO connection). +2. Server generates token: `cvert_scim_` prefix + 32 random bytes (hex-encoded). Distinct prefix from API keys (`cvo_`) for human identification in logs and IdP config UIs. +3. `sha256(token)` stored in `scim_configs.token_hash`, first 8 chars in `token_prefix`. Raw token returned once, never stored. +4. Admin enters token + SCIM endpoint URL into IdP provisioning config. + +### SCIM Auth Middleware (`requireSCIMAuth`) + +Mounted on `/api/v1/orgs/{org_id}/scim/v2/*` only. Separate from `RequireAuthenticated` — SCIM has no human actor, no `ctxUserID`, no RBAC role. Machine-to-machine provisioning channel. + +``` +1. Extract org_id from URL path +2. Extract Bearer token from Authorization header (401 if missing) +3. Hash token with sha256 +4. Lookup scim_configs by token_hash (withBypassTx — pre-context) +5. subtle.ConstantTimeCompare on hash (401 if mismatch) +6. Verify config.org_id == URL org_id (401 — defense-in-depth) +7. Verify config.enabled == true (403 if disabled) +8. Fire security event on failure (scim.auth_failed / scim.auth_org_mismatch / scim.auth_disabled) +9. Inject org_id and scim_config_id into request context +``` + +Context keys: `ctxSCIMConfigID` added to `internal/api/context.go` (next iota value after `ctxTierResolver`). + +### Rate Limiting + +Separate SCIM rate limiter: configurable via `SCIM_RATE_LIMIT` env var, default 50 req/sec per org. Not shared with per-org API rate limiter (which is 1-17 req/sec depending on tier — below Entra ID's 25 req/sec gallery minimum). + +### Token Rotation + +`POST /api/v1/orgs/{org_id}/sso/scim/rotate-token` — overwrites `token_hash` immediately. Single active token, no grace period. IdP retries on 401; admin updates IdP config. + +### Audit Logging + +SCIM operations: `actor_id = NULL`, `actor_email = NULL` (system action). SCIM context in `metadata` JSONB: `{"source": "scim", "scim_config_id": ""}`. + +For exempt user suppression: `metadata` includes `{"source": "scim", "suppressed": true, "reason": "scim_exempt"}`. + +### SSO Delete Protection + +When admin attempts to delete an SSO connection via `DELETE /api/v1/orgs/{org_id}/sso`: +- If a `scim_configs` row exists for this SSO connection → **409 Conflict**: + ```json + { + "type": "https://cvert-ops.dev/errors/sso-has-scim", + "title": "SSO connection has active SCIM provisioning", + "detail": "This SSO connection is used by SCIM provisioning. Disable and delete the SCIM configuration before removing SSO, or update the SSO connection in place.", + "status": 409 + } + ``` +- This forces explicit ordering: disable SCIM → delete SCIM config → delete SSO. Or better: PATCH SSO to update in place. + +### Lifecycle Edge Cases + +- **SCIM disabled** (`enabled = false`): 403 on all SCIM endpoints. Users keep current roles/state. Re-enable restores sync with same token. +- **SSO connection deleted**: Blocked by FK RESTRICT if SCIM config exists. Admin must delete SCIM config first. +- **SCIM config deleted (admin explicit)**: Token invalidated. `scim_groups` survive (reference org directly). Users keep current roles. Group mappings preserved. +- **SCIM re-enabled after disable**: Same token works. IdP resumes sync on next cycle (~40 min). + +--- + +## 3. SCIM Operations Mapping + +### 3.1 Supported SCIM Attributes + +Our Schema endpoint declares exactly what we support. IdPs only send attributes listed in our schema — omitting attributes prevents "IdP sends, we discard, IdP resends" loops. + +**User:** + +| SCIM Attribute | Maps to | Mutability | Notes | +|---|---|---|---| +| `id` | `users.id` (UUID) | read-only | Server-assigned | +| `externalId` | `user_identities.provider_user_id` | read-write | IdP's stable identifier — durable key | +| `userName` | `users.email` | read-write | Must be valid email, globally unique | +| `displayName` | `users.display_name` | read-write | | +| `active` | `org_members.deactivated_at IS NULL` | read-write | | +| `meta` | computed | read-only | resourceType, created, lastModified, location | + +Not advertised (IdPs won't send): `name.givenName`, `name.familyName`, `emails[]`, `phoneNumbers[]`, Enterprise User extension. + +**Group:** + +| SCIM Attribute | Maps to | Mutability | +|---|---|---| +| `id` | `scim_groups.id` (UUID) | read-only | +| `externalId` | `scim_groups.external_id` | read-write | +| `displayName` | `scim_groups.display_name` | read-write | +| `members` | `scim_group_members` | read-write | +| `meta` | computed | read-only | + +### 3.2 Identity Matching + +Two distinct concerns: + +**Ongoing identity (all operations after initial provisioning):** `user_identities(provider='scim:{config_id}', provider_user_id=externalId)`. The `externalId` is the IdP's stable identifier (ObjectID, UPN, SID, employee number — whatever the IdP sends). Once linked, email is just a mutable attribute. + +**Initial matching (first SCIM sync for a user who may already have a CVErt Ops account):** Email is a one-time heuristic. See POST /Users flow below — email lookup only happens when no SCIM identity link exists yet. + +**Email change:** Arrives as PATCH to `userName`. User found by `externalId` (via `user_identities`), not by email. `users.email` updated. Identity link unaffected. + +**Email recycling (employee leaves, new hire gets same email):** Old user deactivated via SCIM DELETE. New user POST has different `externalId` → no identity match → falls through to email lookup → email conflict with deactivated user → 409. Admin resolves by updating old user's email or removing them. + +### 3.3 User Operations + +**`POST /Users` — Provision** + +``` +Input: externalId, userName (email), displayName, active + +1. Check user_identities for provider='scim:{config_id}', provider_user_id=externalId + → If found AND org member is active: + return existing user (200). Idempotent — initial sync hits this. + → If found AND org member is deactivated: + if scim_exempt → return existing (200), log suppression + reactivate membership (clear deactivated_at), return user (200) + → If not found, continue to step 2. + +2. Lookup users by email: + → If found AND already active member of this org: + link SCIM identity (withBypassTx: create user_identities record), return user (200) + → If found AND deactivated member: + if scim_exempt → return existing (200), log suppression + withBypassTx: link SCIM identity; withOrgTx: reactivate membership (clear deactivated_at), return user (200) + → If found but NOT a member of this org: + check tier member limit (count active members only) + withBypassTx: link SCIM identity; withOrgTx: create org_members (role = default_role), return user (201) + → If not found, continue to step 3. + +3. Create new user: + check tier member limit (count active members only) + Tx 1 (withBypassTx): create users record + user_identities record + Tx 2 (withOrgTx): create org_members (role = default_role) + Return user (201) +``` + +Transaction note: steps 2 and 3 split writes across two transactions because `users` and `user_identities` are global tables (no RLS) while `org_members` is org-scoped. If the org-scoped transaction fails after the bypass transaction, we have an orphaned user/identity record — harmless (no org access), consistent with existing registration flow. + +**`GET /Users/{id}` — Read** + +``` +1. Lookup org_members by user_id = {id} (withOrgTx, RLS-scoped) +2. Join users + user_identities (provider = 'scim:{config_id}') +3. Return SCIM User (including deactivated — active attribute shows state) +4. Not found → 404 +``` + +**`GET /Users` — List with filtering** + +``` +1. List org_members for org (withOrgTx), including deactivated +2. Apply SCIM filters — supported operators: eq, and + Supported filter attributes: + userName eq "email" → WHERE users.email = $1 + externalId eq "ext" → WHERE user_identities.provider_user_id = $1 + id eq "uuid" → WHERE org_members.user_id = $1 + active eq true/false → WHERE deactivated_at IS [NOT] NULL + Unsupported operator → 400: scimType "invalidFilter" +3. Index-based pagination (SQL OFFSET/LIMIT from startIndex + count) +4. Return SCIM ListResponse {totalResults, itemsPerPage, startIndex, Resources[]} +``` + +Index-based pagination is required by SCIM spec. Org member counts are typically < 10K; OFFSET/LIMIT is acceptable. + +**`PUT /Users/{id}` — Replace** + +PUT is a full resource replacement per SCIM spec. Omitted mutable attributes are reset to defaults. This is Okta's primary update path (Okta uses PUT for attribute updates, PATCH only for activation/deactivation). + +``` +1. Lookup org_members by user_id (withOrgTx) +2. Not found → 404 +3. If scim_exempt → return current state (200), log suppression +4. Update users: + - email: use provided userName (409 if uniqueness conflict) + - display_name: use provided displayName, or fall back to userName (email) if omitted +5. Update user_identities: externalId if changed (uniqueness check → 409 if already linked to different user) +6. Update org_members: deactivated_at based on active flag + → If deactivating: check sole-owner protection → 400 if sole active owner +7. Return updated SCIM User (200) +``` + +**Omitted attribute defaults:** `displayName` falls back to `userName` (email) if not provided — a human-readable default that avoids blank display names in the UI. `externalId` is preserved if omitted (not nulled — it's our identity link). `active` defaults to `true` if omitted. + +**`PATCH /Users/{id}` — Partial update** + +``` +1. Lookup org_members by user_id (withOrgTx) +2. Not found → 404 +3. If scim_exempt → return current state (200), log suppression +4. For each operation in Operations[] (single transaction — atomic): + op: case-insensitive (accept "Replace", "replace", etc.) + value: coerce string booleans ("False" → false, "True" → true) + + Attribute mapping: + "active" → org_members.deactivated_at + if deactivating: sole-owner check → 400 + "userName" → users.email (uniqueness check → 409) + "displayName" → users.display_name + "externalId" → user_identities.provider_user_id (uniqueness check → 409 if already linked to different user) + +5. Return updated SCIM User (200) +``` + +**`DELETE /Users/{id}` — Deprovision** + +``` +1. Lookup org_members by user_id (withOrgTx) +2. Not found → 204 (idempotent) +3. If scim_exempt → 204, log suppression +4. Sole-owner check → 400 if sole active owner +5. Set deactivated_at = now() +6. Return 204 +``` + +User record, user_identities, and org_members preserved. Re-provisioning via POST reactivates. + +### 3.4 Group Operations + +**`POST /Groups` — Create** + +``` +1. Create scim_groups record (org_id, display_name, external_id) +2. If members[] provided: + For each member: + INSERT scim_group_members + If user is NOT scim_exempt AND group has mapped_role: + recompute effective role (§3.6) + If group has mapped_group_id: + sync to notification group (§3.7) +3. Return SCIM Group (201) +``` + +**`GET /Groups/{id}` — Read** + +``` +1. Lookup scim_groups by id (withOrgTx) +2. Load scim_group_members with user references +3. Map to SCIM Group schema +4. Not found → 404 +``` + +**`GET /Groups` — List with filtering** + +``` +Supported filters: displayName eq, externalId eq, id eq +Unsupported → 400: scimType "invalidFilter" +Index-based pagination. +``` + +**`PATCH /Groups/{id}` — Partial update** + +``` +1. Lookup scim_groups by id (withOrgTx) +2. Not found → 404 +3. For each operation (single transaction — atomic): + + op: "add", path: "members", value: [{value: "user-uuid"}, ...] + → INSERT scim_group_members for each + → For non-exempt users: recompute role (§3.6), sync notification group (§3.7) + + op: "remove", path: "members[value eq \"user-uuid\"]" + — also accept: op: "remove", path: "members", value: [{value: "user-uuid"}] + (Entra ID sends value array instead of path filter — see §4.2) + → DELETE FROM scim_group_members + → For non-exempt users: recompute role (§3.6), unsync notification group (§3.7) + + op: "replace", path: "displayName", value: "new name" + → UPDATE scim_groups.display_name + +4. Return updated SCIM Group (200) +``` + +**`PUT /Groups/{id}` — Replace** + +``` +1. Lookup scim_groups by id (withOrgTx) +2. Not found → 404 +3. Update display_name, external_id +4. Diff current members vs new members: + Added → INSERT scim_group_members, recompute roles, sync notification group + Removed → DELETE scim_group_members, recompute roles, unsync notification group +5. Return updated SCIM Group (200) +``` + +**`DELETE /Groups/{id}` — Remove** + +``` +1. Lookup scim_groups by id (withOrgTx) +2. Not found → 204 (idempotent) +3. Collect affected non-exempt users +4. DELETE scim_groups (CASCADE deletes scim_group_members) +5. Recompute effective role for affected non-exempt users +6. Do NOT remove users from mapped notification group (too aggressive — could cause + missed notifications; admin cleans up manually or remaps a new SCIM group) +7. Return 204 +``` + +### 3.5 Discovery Endpoints (static) + +**`GET /ServiceProviderConfig`:** + +```json +{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "patch": {"supported": true}, + "bulk": {"supported": false, "maxOperations": 0, "maxPayloadSize": 0}, + "filter": {"supported": true, "maxResults": 200}, + "changePassword": {"supported": false}, + "sort": {"supported": false}, + "etag": {"supported": false}, + "authenticationSchemes": [{ + "type": "oauthbearertoken", + "name": "Bearer Token", + "description": "Authentication via org-scoped SCIM bearer token" + }] +} +``` + +**`GET /Schemas`** — User and Group schema definitions listing only attributes from §3.1. + +**`GET /ResourceTypes`** — Metadata for User and Group resources with schema refs and endpoint paths. + +### 3.6 Role Recomputation + +Called whenever SCIM group membership changes for a non-exempt user, or when a group's `mapped_role` is changed via the admin mapping endpoint (recompute for all current non-exempt members of that group): + +``` +1. If current org_members.role == 'owner' → skip (owner is always manual) +2. Load all scim_groups the user belongs to (via scim_group_members) +3. Collect non-null mapped_role values +4. Effective role = max(mapped_roles) or scim_configs.default_role if empty +5. Role hierarchy: admin > member > viewer +6. If computed role != current org_members.role → UPDATE org_members +``` + +Owner guard (step 1) prevents SCIM from downgrading a manually-assigned owner to admin/member/viewer. Owner assignment and removal are always manual operations. + +### 3.7 Notification Group Sync + +When a SCIM group has a non-null `mapped_group_id`, membership changes propagate to the notification `groups`/`group_members` table. Also triggered when a group's `mapped_group_id` is changed via the admin mapping endpoint — all current members are synced to the new notification group (and scim_managed memberships in the old notification group are cleaned up per the removal rules below). + +**Source tracking:** `group_members.scim_managed BOOLEAN NOT NULL DEFAULT false` tracks whether a notification group membership was created by SCIM sync. + +**Soft-delete guard:** Before inserting into `group_members`, verify the target group exists and `deleted_at IS NULL`. The `groups` table uses soft-delete; `ON DELETE SET NULL` on `scim_groups.mapped_group_id` only fires on hard-delete. A SCIM sync to a soft-deleted notification group is a no-op. + +**Sync rules:** + +| Event | Action | +|-------|--------| +| SCIM adds user to group with `mapped_group_id` | If target group is soft-deleted: no-op. If user not in notification group: INSERT `group_members` with `scim_managed = true`. If already a member with `scim_managed = false`: no-op (manual membership takes precedence). | +| SCIM removes user from group | If `scim_managed = true` AND no other SCIM group with same `mapped_group_id` includes user: DELETE from notification group. If `scim_managed = false`: no-op (admin owns it). | +| Admin manually adds user to notification group | INSERT with `scim_managed = false`. If already exists from SCIM: no change to flag. | +| Admin manually removes user from notification group | DELETE regardless of `scim_managed` (admin override). | +| SCIM group deleted | Do NOT remove users from mapped notification group. | + +**Multi-mapping edge case:** Two SCIM groups may map to the same notification group. Before removing a SCIM-managed membership, check if any other SCIM group with the same `mapped_group_id` still includes the user: + +```sql +SELECT COUNT(*) FROM scim_group_members sgm + JOIN scim_groups sg ON sgm.scim_group_id = sg.id + WHERE sgm.user_id = $1 + AND sg.mapped_group_id = $2 + AND sg.id != $3 +``` + +If count > 0, keep the notification group membership. + +**Why no removal on SCIM group delete:** Deleting a SCIM group is often an IdP restructuring event, not a "revoke access" intent. Removing notification group membership could cause missed vulnerability alerts. Admin can clean up manually. + +--- + +## 4. Microsoft Entra ID & Okta Compatibility + +### 4.1 SCIM Error Response Format + +All SCIM endpoints return errors using RFC 7644 §3.12 format, NOT RFC 9457 Problem Details. Our chi handlers are responsible for formatting error responses with `Content-Type: application/scim+json`. + +```json +{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "409", + "scimType": "uniqueness", + "detail": "userName already exists in this organization" +} +``` + +**Error mapping:** + +| Condition | HTTP Status | scimType | Detail | +|-----------|------------|----------|--------| +| Email uniqueness violation | 409 | `uniqueness` | userName already exists | +| externalId collision | 409 | `uniqueness` | externalId already linked to different user | +| Unsupported filter operator | 400 | `invalidFilter` | Unsupported operator: {op} | +| Missing required attribute | 400 | `invalidValue` | {attribute} is required | +| Sole-owner protection | 400 | `invalidValue` | Cannot deactivate the sole owner of this organization | +| Tier member limit exceeded | 403 | — | Organization member limit reached | +| Invalid PATCH path | 400 | `invalidPath` | Unrecognized attribute path: {path} | +| SCIM config disabled | 403 | — | SCIM provisioning is disabled for this organization | +| Auth failure | 401 | — | Invalid or missing bearer token | + +### 4.2 IdP Behavioral Differences + +| Aspect | Microsoft Entra ID | Okta | +|--------|-------------------|------| +| **PATCH op casing** | Capitalized: `"Replace"`, `"Add"`, `"Remove"` | Lowercase: `"replace"`, `"add"`, `"remove"` (spec-compliant) | +| **Boolean values** | String: `"False"`, `"True"` | JSON boolean: `false`, `true` (spec-compliant) | +| **User deactivation** | DELETE or PATCH `active=false` | PATCH `active=false` only — Okta **never** sends DELETE | +| **User attribute updates** | Primarily PATCH | Primarily PUT (PATCH only for active/password) | +| **Filter operators** | `eq`, `and` | `eq` primarily; `sw` for user import searches | +| **Test connection** | `GET /Users?filter=id eq "{random-guid}"` → expects 200 with empty list | `GET /Users?startIndex=1&count=1` → expects 200 with pagination envelope | +| **Group member removal** | Multiple formats (value array OR path filter expression) | Standard path filter format | +| **Sync cycle** | ~40-minute incremental sync | Event-driven (near-real-time for user changes) | +| **Multi-attribute PATCH** | Separate operation per attribute | N/A (uses PUT for multi-attribute updates) | + +**Our handling:** Case-insensitive op comparison. Boolean coercion from strings. Both DELETE and PATCH `active=false` produce the same outcome (soft-deactivate). PUT handler is a full implementation, not a PATCH wrapper — critical for Okta compatibility. + +### 4.3 Filter Operator Support + +| Operator | Required by | SQL mapping | Notes | +|----------|------------|-------------|-------| +| `eq` | Both | `WHERE col = $1` | Core — every provisioning operation uses this | +| `and` | Both | `AND` | Compound queries | +| `sw` | Okta (import) | `WHERE col LIKE $1 \|\| '%'` | Deferred from MVP (§8). Documented here for reference. Uses BTREE index. | + +Unsupported operators (`ne`, `co`, `ew`, `gt`, `ge`, `lt`, `le`, `or`, `not`, `pr`) return 400 with `scimType: "invalidFilter"`. + +### 4.4 Test Connection Behavior + +- **Entra ID:** `GET /Users?filter=id eq "{random-guid}"` — must return 200 with `{"totalResults": 0, "Resources": []}`. +- **Okta:** `GET /Users?startIndex=1&count=1` — must return 200 with pagination envelope. + +Both patterns work against our GET /Users implementation with no special-casing. + +### 4.5 Data Fidelity + +Values stored as received. No normalization: +- Email casing preserved (no `strings.ToLower`) +- displayName whitespace preserved +- externalId is opaque — could be a GUID, UPN, SID, employee number + +### 4.6 Performance + +| Context | Requirement | Our limit | +|---------|------------|-----------| +| Entra ID gallery app | 25 req/sec minimum | 50 req/sec (configurable) | +| Entra ID custom enterprise app | No published minimum | 50 req/sec (configurable) | +| Okta (any) | No published minimum | 50 req/sec (configurable) | + +### 4.7 Customer Configuration Guidance + +**Entra ID:** Recommend customers append `?aadOptscim062020` to the SCIM endpoint URL. This enables more spec-compliant behavior. + +**Okta:** Ensure SCIM 2.0 (not 1.1) is selected. Use "HTTP Header" authentication type. + +**Both:** The SCIM endpoint URL is `https://{host}/api/v1/orgs/{org_id}/scim/v2`. The `org_id` UUID is visible in org settings. + +--- + +## 5. API Endpoints + +### SCIM Endpoints (chi handlers, SCIM auth middleware) + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/orgs/{org_id}/scim/v2/Users` | GET | List/filter users | +| `/api/v1/orgs/{org_id}/scim/v2/Users` | POST | Provision user | +| `/api/v1/orgs/{org_id}/scim/v2/Users/{id}` | GET | Get user | +| `/api/v1/orgs/{org_id}/scim/v2/Users/{id}` | PUT | Replace user | +| `/api/v1/orgs/{org_id}/scim/v2/Users/{id}` | PATCH | Partial update user | +| `/api/v1/orgs/{org_id}/scim/v2/Users/{id}` | DELETE | Deprovision user | +| `/api/v1/orgs/{org_id}/scim/v2/Groups` | GET | List/filter groups | +| `/api/v1/orgs/{org_id}/scim/v2/Groups` | POST | Create group | +| `/api/v1/orgs/{org_id}/scim/v2/Groups/{id}` | GET | Get group | +| `/api/v1/orgs/{org_id}/scim/v2/Groups/{id}` | PUT | Replace group | +| `/api/v1/orgs/{org_id}/scim/v2/Groups/{id}` | PATCH | Update group | +| `/api/v1/orgs/{org_id}/scim/v2/Groups/{id}` | DELETE | Delete group | +| `/api/v1/orgs/{org_id}/scim/v2/ServiceProviderConfig` | GET | Capabilities | +| `/api/v1/orgs/{org_id}/scim/v2/Schemas` | GET | Schema definitions | +| `/api/v1/orgs/{org_id}/scim/v2/ResourceTypes` | GET | Resource metadata | + +All SCIM endpoints require `requireSCIMAuth` middleware. Response `Content-Type: application/scim+json`. Input accepts both `application/scim+json` and `application/json` (Entra ID sends the latter). + +**Routing boundary:** SCIM endpoints are chi handlers — they live outside huma's OpenAPI spec generation. SCIM's wire protocol (RFC 7644 error format, `application/scim+json` content type, SCIM PATCH semantics, ListResponse envelope) is incompatible with huma's conventions. Same pattern as SSO callback handlers. + +**Request ID coverage:** SCIM routes must be mounted AFTER `middleware_request_id.go` in the middleware chain, so all SCIM requests get correlation IDs in slog output. + +### Admin SCIM Management Endpoints (standard auth + RBAC) + +| Endpoint | Method | RBAC | Description | +|----------|--------|------|-------------| +| `/api/v1/orgs/{org_id}/sso/scim` | POST | owner | Create SCIM config (returns token once). Requires active SSO connection (400 if none). 409 if config already exists. | +| `/api/v1/orgs/{org_id}/sso/scim` | GET | admin+ | Get SCIM config (token masked) | +| `/api/v1/orgs/{org_id}/sso/scim` | PATCH | owner | Update SCIM config (enable/disable, default_role) | +| `/api/v1/orgs/{org_id}/sso/scim` | DELETE | owner | Delete SCIM config. SCIM groups and memberships survive (§2 lifecycle). 204. | +| `/api/v1/orgs/{org_id}/sso/scim/rotate-token` | POST | owner | Rotate SCIM token | +| `/api/v1/orgs/{org_id}/sso/scim/groups` | GET | admin+ | List SCIM groups with current mappings | +| `/api/v1/orgs/{org_id}/sso/scim/groups/{id}/mapping` | PATCH | admin+ | Set mapped_role and/or mapped_group_id | + +All admin endpoints are Enterprise-only (tier-gated via `requireEnterpriseTier`). Config read and group listing are admin+. Config creation, mutation, deletion, and token rotation are owner-only. + +**Admin endpoint response schemas:** + +- `POST /sso/scim` → `{id, org_id, enabled, default_role, token_prefix, token, created_at}` — `token` in cleartext once. 201. +- `GET /sso/scim` → `{id, org_id, enabled, default_role, token_prefix, created_at, updated_at}` — no `token` field. +- `PATCH /sso/scim` → returns updated config (same shape as GET). 200. +- `DELETE /sso/scim` → 204, no body. +- `POST /sso/scim/rotate-token` → `{token, token_prefix}` — new token in cleartext. 200. +- `GET /sso/scim/groups` → `{items: [{id, external_id, display_name, mapped_role, mapped_group_id, member_count, created_at}]}`. +- `PATCH /sso/scim/groups/{id}/mapping` → returns updated SCIM group. 200. + +### Existing Endpoint Modifications + +| Endpoint | Change | +|----------|--------| +| `PATCH /api/v1/orgs/{org_id}/members/{user_id}` | Add `active` (bool) and `scim_exempt` (bool) as patchable fields. `active` sets/clears `deactivated_at`. Both require admin+ role. | +| `GET /api/v1/orgs/{org_id}/members` | Response includes `active` (bool), `deactivated_at` (nullable), `scim_exempt` (bool) fields. | +| `RequireOrgRole` middleware | Add `deactivated_at IS NULL` check. Deactivated members get 403. | +| `DELETE /api/v1/orgs/{org_id}/sso` | Add pre-flight check: if SCIM config exists for this SSO connection → 409 (§2 SSO Delete Protection). | + +--- + +## 6. Logging & Observability + +### Three logging layers for SCIM + +| Layer | System | Purpose | When to use | +|-------|--------|---------|-------------| +| **slog** (structured) | `log/slog` | Operational visibility for developers/ops | Every SCIM operation, request lifecycle | +| **Audit log** | `internal/audit/` | Compliance trail visible to org admins | Business mutations (user/group/config changes) | +| **Security events** | `internal/secure/` | SOC monitoring, syslog forwarding, anomaly detection | Auth failures, suspicious activity, rate limiting | + +### slog Events + +Every SCIM slog line MUST include these standard attributes: `org_id`, `scim_config_id`. Additional context-specific attributes as listed below. + +**SCIM protocol operations:** + +| Level | Event | Additional Fields | +|-------|-------|--------| +| `Info` | SCIM user provisioned | `user_id`, `external_id`, `method` (created/linked/reactivated) | +| `Info` | SCIM user deactivated | `user_id`, `external_id`, `source` (patch/put/delete) | +| `Info` | SCIM user reactivated | `user_id`, `external_id`, `source` (patch/put/post) | +| `Info` | SCIM user attributes updated | `user_id`, `external_id`, `source` (patch/put), `changed_fields` | +| `Info` | SCIM user deprovisioned | `user_id`, `external_id` | +| `Info` | SCIM group created | `scim_group_id`, `display_name` | +| `Info` | SCIM group deleted | `scim_group_id`, `display_name`, `affected_user_count` | +| `Info` | SCIM group membership changed | `scim_group_id`, `user_id`, `action` (add/remove) | +| `Info` | Role recomputed from SCIM groups | `user_id`, `old_role`, `new_role` | +| `Info` | Notification group membership synced | `user_id`, `group_id`, `action` (add/remove), `scim_group_id` | +| `Warn` | SCIM operation suppressed (exempt user) | `user_id`, `operation` | +| `Warn` | SCIM sole-owner protection triggered | `user_id`, `operation` | +| `Warn` | SCIM auth failed | `reason` (invalid_token/disabled/org_mismatch) | +| `Debug` | SCIM request received | `method`, `path` | + +**Admin management actions:** + +| Level | Event | Additional Fields | +|-------|-------|--------| +| `Info` | SCIM config created | `actor_id` | +| `Info` | SCIM config enabled/disabled | `enabled`, `actor_id` | +| `Info` | SCIM config deleted | `actor_id` | +| `Info` | SCIM token rotated | `actor_id` | +| `Info` | SCIM group mapping updated | `scim_group_id`, `mapped_role`, `mapped_group_id`, `actor_id` | + +### Audit Log + +SCIM protocol operations: `actor_id = NULL` with `metadata: {"source": "scim", "scim_config_id": ""}`. +Admin management actions: authenticated user's `actor_id`. + +New entity types required: `scim_config`, `scim_group` (migration 000044 extends the `entity_type` CHECK, if one exists, or adds it). + +### Security Events (`secure.EventWriter`) + +New event type constants in `internal/secure/events.go`: + +| Event Type | Severity | Trigger | +|------------|----------|---------| +| `scim.auth_failed` | Warning | Invalid/missing bearer token | +| `scim.auth_org_mismatch` | Warning | Token used on wrong org's endpoint | +| `scim.auth_disabled` | Warning | Token used on disabled SCIM config | +| `scim.token_created` | Info | New SCIM config/token provisioned | +| `scim.token_rotated` | Info | Token rotated | +| `scim.user_provisioned` | Info | New user created via SCIM | +| `scim.user_deprovisioned` | Info | User deactivated via SCIM | +| `scim.sole_owner_protected` | Warning | Attempt to deactivate sole owner | +| `scim.exempt_suppressed` | Warning | Operation suppressed on exempt user | +| `scim.rate_limited` | Warning | SCIM rate limit hit | + +Security events include `OrgID` and `ActorIP` (from the IdP's outbound IP). No `UserID` for auth failures. `Details` map includes `scim_config_id` where available. + +--- + +## 7. Test Coverage Plan + +### SCIM Auth + +| Test | Validates | +|------|-----------| +| `TestSCIMAuth_ValidToken` | Bearer token accepted, org context set | +| `TestSCIMAuth_InvalidToken` | Wrong token → 401 | +| `TestSCIMAuth_OrgMismatch` | Token for org A used on org B endpoint → 401 | +| `TestSCIMAuth_Disabled` | Disabled config → 403 | +| `TestSCIMAuth_MissingHeader` | No Authorization header → 401 | +| `TestSCIMAuth_ConstantTimeCompare` | Timing-safe comparison (verified by code review) | +| `TestSCIMAuth_RateLimit` | SCIM rate limit independent of org API rate limit | +| `TestSCIMAuth_TokenRotation` | Old token rejected after rotation | +| `TestSCIMAuth_ErrorFormat` | Auth failures return SCIM error JSON (RFC 7644 §3.12), not RFC 9457 | +| `TestSCIMAuth_SecurityEvent` | Auth failures fire `scim.auth_failed` security event | + +### User Provisioning + +| Test | Validates | +|------|-----------| +| `TestSCIMCreateUser_NewUser` | Creates user + identity + membership, returns 201 | +| `TestSCIMCreateUser_ExistingByExternalId_Active` | Returns existing, 200 (idempotent) | +| `TestSCIMCreateUser_ExistingByExternalId_Deactivated` | Reactivates, returns 200 | +| `TestSCIMCreateUser_ExistingByEmail_OrgMember` | Links SCIM identity, returns 200 | +| `TestSCIMCreateUser_ExistingByEmail_NotMember` | Links identity + creates membership, 201 | +| `TestSCIMCreateUser_ExistingByEmail_Deactivated` | Reactivates + links identity, 200 | +| `TestSCIMCreateUser_EmailConflict` | Email in use by different user → 409 | +| `TestSCIMCreateUser_ExternalIdConflict` | externalId linked to different user → 409 | +| `TestSCIMCreateUser_TierMemberLimit` | Exceeds limit → 403 | +| `TestSCIMCreateUser_SCIMExempt_Deactivated` | Exempt user not reactivated, 200 returned | +| `TestSCIMCreateUser_DefaultRole` | New user gets scim_configs.default_role | +| `TestSCIMCreateUser_ConcurrentDuplicate` | Two concurrent provisions of same user — exactly one 201, other 200 | + +### User Read/List + +| Test | Validates | +|------|-----------| +| `TestSCIMGetUser_Found` | Returns SCIM User with correct attributes | +| `TestSCIMGetUser_Deactivated` | Returns user with active=false | +| `TestSCIMGetUser_NotFound` | 404 | +| `TestSCIMGetUser_CrossOrg` | Cannot read user from different org | +| `TestSCIMListUsers_All` | Returns all members including deactivated | +| `TestSCIMListUsers_FilterByUserName` | eq filter on email | +| `TestSCIMListUsers_FilterByExternalId` | eq filter on externalId | +| `TestSCIMListUsers_FilterById` | eq filter on id (Entra ID test connection) | +| `TestSCIMListUsers_FilterByActive` | eq filter on active | +| `TestSCIMListUsers_FilterAnd` | Compound filter | +| `TestSCIMListUsers_UnsupportedFilter` | Unsupported operator → 400 invalidFilter | +| `TestSCIMListUsers_Pagination` | startIndex + count produce correct pages | +| `TestSCIMListUsers_EmptyResult` | 200 with empty Resources[] (not 404) | + +### User Update + +| Test | Validates | +|------|-----------| +| `TestSCIMReplaceUser_Success` | PUT updates all attributes | +| `TestSCIMReplaceUser_EmailConflict` | New email already exists → 409 | +| `TestSCIMReplaceUser_SCIMExempt` | Returns current state, no modification | +| `TestSCIMReplaceUser_Deactivate` | active=false sets deactivated_at | +| `TestSCIMReplaceUser_SoleOwner` | Deactivate sole owner → 400 | +| `TestSCIMReplaceUser_NotFound` | 404 | +| `TestSCIMReplaceUser_Reactivate` | active=true clears deactivated_at | +| `TestSCIMReplaceUser_OmittedDisplayName` | Falls back to userName (email) | +| `TestSCIMPatchUser_ReplaceActive` | PATCH active=false deactivates | +| `TestSCIMPatchUser_CaseInsensitiveOp` | "Replace" and "replace" both work | +| `TestSCIMPatchUser_StringBoolean` | "False" coerced to false | +| `TestSCIMPatchUser_MultipleOps` | Multiple ops in single PATCH, atomic | +| `TestSCIMPatchUser_SCIMExempt` | Returns current state, no modification | +| `TestSCIMPatchUser_SoleOwner` | Cannot deactivate sole owner | +| `TestSCIMPatchUser_InvalidPath` | Unrecognized attribute → 400 invalidPath | + +### User Deprovision + +| Test | Validates | +|------|-----------| +| `TestSCIMDeleteUser_Success` | Sets deactivated_at, returns 204 | +| `TestSCIMDeleteUser_AlreadyDeactivated` | Idempotent, returns 204 | +| `TestSCIMDeleteUser_NotFound` | Returns 204 (idempotent) | +| `TestSCIMDeleteUser_SCIMExempt` | Returns 204, no modification | +| `TestSCIMDeleteUser_SoleOwner` | Cannot deactivate sole owner → 400 | +| `TestSCIMDeleteUser_PreservesData` | User record, identity, membership row still exist | + +### Group Operations + +| Test | Validates | +|------|-----------| +| `TestSCIMCreateGroup_Basic` | Creates group, returns 201 | +| `TestSCIMCreateGroup_WithMembers` | Creates group + memberships, roles recomputed | +| `TestSCIMGetGroup_WithMembers` | Returns group with member list | +| `TestSCIMListGroups_FilterByDisplayName` | eq filter works | +| `TestSCIMPatchGroup_AddMembers` | Adds members, recomputes roles | +| `TestSCIMPatchGroup_RemoveMembers` | Removes members, recomputes roles | +| `TestSCIMPatchGroup_UpdateDisplayName` | Updates name | +| `TestSCIMReplaceGroup_MemberDiff` | Correctly diffs and syncs membership | +| `TestSCIMDeleteGroup_CascadesMembers` | scim_group_members deleted | +| `TestSCIMDeleteGroup_RecomputesRoles` | Affected users' roles recomputed | +| `TestSCIMPatchGroup_EntraIdMemberRemoveFormat` | Value array format (Entra ID quirk) | +| `TestSCIMDeleteGroup_Idempotent` | Already-deleted group returns 204 | + +### Role Recomputation + +| Test | Validates | +|------|-----------| +| `TestRoleRecompute_SingleGroup` | User gets mapped_role | +| `TestRoleRecompute_MultipleGroups_HighestWins` | admin > member > viewer | +| `TestRoleRecompute_NoMappedGroups` | Falls back to default_role | +| `TestRoleRecompute_NeverSetsOwner` | Owner role never assigned by SCIM | +| `TestRoleRecompute_SCIMExempt_Skipped` | Exempt user's role unchanged | +| `TestRoleRecompute_RemovedFromAllGroups` | Falls back to default_role | +| `TestRoleRecompute_OwnerNotDowngraded` | Existing owner role preserved | +| `TestRoleRecompute_MappingChange_ImmediateEffect` | Admin changes mapped_role → roles recomputed immediately | + +### Notification Group Sync + +| Test | Validates | +|------|-----------| +| `TestNotifSync_Add_NewMember` | Inserts with scim_managed=true | +| `TestNotifSync_Add_AlreadyManualMember` | No change (manual precedence) | +| `TestNotifSync_Remove_SCIMManaged` | Deletes scim_managed=true row | +| `TestNotifSync_Remove_ManualMember` | No-op (admin owns it) | +| `TestNotifSync_Remove_MultiMapping` | Keeps membership if other SCIM group maps same | +| `TestNotifSync_AdminRemove_OverridesSCIM` | Admin delete removes regardless | +| `TestNotifSync_GroupDelete_NoRemoval` | SCIM group delete does not remove notification members | +| `TestNotifSync_ExemptUser_Skipped` | Exempt user not synced | +| `TestNotifSync_MappingChange_ImmediateSync` | Admin sets mapped_group_id → existing members synced | +| `TestNotifSync_MappingChanged_OldGroupCleanedUp` | Old group's scim_managed members cleaned up | +| `TestNotifSync_SoftDeletedTargetGroup` | mapped_group_id points to soft-deleted group → no-op | + +### SCIM Config Management + +| Test | Validates | +|------|-----------| +| `TestSCIMConfig_Create` | Owner creates, token returned once | +| `TestSCIMConfig_Create_RequiresSSO` | Fails without sso_connection | +| `TestSCIMConfig_Create_EnterpriseOnly` | Non-Enterprise → 403 | +| `TestSCIMConfig_Get_TokenMasked` | Token not in GET response | +| `TestSCIMConfig_Enable_Disable` | PATCH enabled flag works | +| `TestSCIMConfig_Delete` | Removes config, SCIM auth fails | +| `TestSCIMConfig_Delete_GroupsSurvive` | scim_groups not cascade-deleted | +| `TestSCIMConfig_RotateToken` | New token works, old rejected | +| `TestSCIMConfig_RBAC` | Non-owner → 403 for mutation; admin can GET | +| `TestSCIMConfig_Create_Duplicate` | Second POST → 409 | +| `TestSCIMConfig_Delete_Idempotent` | DELETE when no config → 204 | +| `TestSCIMConfig_SSODelete_Blocked` | SSO delete when SCIM config exists → 409 | + +### Group Mapping Admin + +| Test | Validates | +|------|-----------| +| `TestGroupMapping_SetRole` | mapped_role applied immediately | +| `TestGroupMapping_SetNotificationGroup` | mapped_group_id triggers immediate sync | +| `TestGroupMapping_ClearMapping` | Null mapped_role → roles recomputed to default | +| `TestGroupMapping_AdminRBAC` | Requires admin+ | +| `TestGroupMapping_CrossOrgGroupId` | mapped_group_id from different org → 400 | +| `TestGroupMapping_SoftDeletedGroupId` | mapped_group_id to soft-deleted group → 400 | +| `TestGroupMapping_ListGroups` | GET returns groups with mappings and member counts | + +### Deactivation (General Feature) + +| Test | Validates | +|------|-----------| +| `TestDeactivate_AdminManual` | PATCH active=false via member endpoint | +| `TestDeactivate_Reactivate` | PATCH active=true clears deactivated_at | +| `TestDeactivate_BlocksAccess` | Deactivated member gets 403 on org endpoints | +| `TestDeactivate_SoleOwnerProtection` | Cannot deactivate sole active owner | +| `TestDeactivate_SCIMExempt_ManualStillWorks` | Admin can deactivate exempt user manually | +| `TestMemberList_ShowsActiveStatus` | Response includes active, deactivated_at, scim_exempt | + +### Cross-Cutting + +| Concern | How handled | +|---------|------------| +| RLS isolation | Every org-scoped test includes cross-org check via `AppStore` | +| testcontainers-go | All Postgres integration tests | +| Pristine output | SCIM errors captured and validated; suppression warnings expected and verified | +| Test data isolation | Each test creates own org/user/data | +| Audit logging | SCIM operations produce audit entries with correct metadata and entity types | +| Security events | Auth failures and provisioning events verified in `security_events` table | +| Content-Type | All SCIM responses use `application/scim+json` | +| CSRF | SCIM endpoints exempt from CSRF (no cookie auth — Bearer token only) | + +--- + +## 8. Carry-Forward / Future Items + +| Item | Status | +|------|--------| +| SCIM bulk operations | Not supported. ServiceProviderConfig declares unsupported. | +| SCIM sorting | Not supported. | +| SCIM ETags | Not supported. | +| Additional filter operators (sw, co, or) | MVP supports eq + and only. `sw` SQL mapping documented in §4.3. | +| Enterprise User schema extension | Not advertised. Add if customer demand exists. | +| SAML 2.0 NameID matching | Deferred with SAML support. | +| Domain ownership verification for SCIM | Deferred to SaaS phase. | +| SCIM changePassword | Not planned. CVErt Ops uses OIDC/SSO for authentication. | +| SCIM token expiration / TTL | Tokens currently never expire. Add configurable TTL for security hardening. Not MVP. | +| SCIM provisioning observability | Add `last_sync_at` timestamp and `provisioned_user_count` to config. Not MVP. | +| Admin filter by provisioning source | `GET /members?provisioned_by=scim`. Not MVP. | + +--- + +## 9. Schema Review Findings (pre-resolved) + +| # | Issue | Resolution | +|---|-------|------------| +| 1 | All tables missing explicit RLS DDL | Full ENABLE/FORCE/CREATE POLICY on all three tables | +| 2 | No IF NOT EXISTS on CREATE TABLE | Added to all | +| 3 | No GRANT statements | Added per-table with correct verbs | +| 4 | scim_groups CASCADE from scim_configs loses mappings | Reference organizations(id) directly | +| 5 | Missing partial unique on scim_groups(org_id, external_id) | Added WHERE external_id IS NOT NULL | +| 6 | Missing FK on scim_groups.org_id | Added REFERENCES organizations(id) | +| 7 | Missing FK on scim_group_members.org_id | Added REFERENCES organizations(id) | +| 8 | No explicit indexes | Added all FK + query-pattern indexes | +| 9 | Missing index on scim_groups(mapped_group_id) FK | Added BTREE index | +| 10 | `ON DELETE CASCADE` on sso_connection_id silently destroys SCIM config | Changed to `ON DELETE RESTRICT` with 409 handler | \ No newline at end of file diff --git a/internal/api/context.go b/internal/api/context.go index 8dcb766b..2efbabfe 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -12,4 +12,5 @@ const ( ctxAPIKeyOrgID // uuid.UUID — org the API key belongs to (restricts access to that org only) ctxClientIP // string — client IP address for rate limiting ctxTierResolver // *tier.Resolver — resolved tier limits for this org + ctxSCIMConfigID // uuid.UUID — SCIM config ID (set by requireSCIMAuth) ) diff --git a/internal/api/middleware_rbac.go b/internal/api/middleware_rbac.go index adbf60c8..95b301e4 100644 --- a/internal/api/middleware_rbac.go +++ b/internal/api/middleware_rbac.go @@ -39,13 +39,18 @@ func (srv *Server) RequireOrgRole(minRole Role) func(http.Handler) http.Handler } } - roleStr, err := srv.store.GetOrgMemberRole(r.Context(), orgID, userID) - if err != nil || roleStr == nil { + member, err := srv.store.GetOrgMemberRoleAndStatus(r.Context(), orgID, userID) + if err != nil || member == nil { writeProblem(w, http.StatusForbidden, "forbidden") return } - effectiveRole := parseRole(*roleStr) + if member.DeactivatedAt.Valid { + writeProblem(w, http.StatusForbidden, "Your membership in this organization has been deactivated.") + return + } + + effectiveRole := parseRole(member.Role) // Cap effective role to the API key's role when the request is API-key-authenticated. if apiKeyRoleStr, ok := r.Context().Value(ctxAPIKeyRole).(string); ok && apiKeyRoleStr != "" { diff --git a/internal/api/middleware_rbac_test.go b/internal/api/middleware_rbac_test.go index 25278850..c425bfc9 100644 --- a/internal/api/middleware_rbac_test.go +++ b/internal/api/middleware_rbac_test.go @@ -431,5 +431,48 @@ func TestRequireOrgRole_APIKeySameOrg_200(t *testing.T) { } } +// TestRequireOrgRole_DeactivatedMember verifies that a deactivated org member +// gets 403 with a specific deactivation message, regardless of their role level. +func TestRequireOrgRole_DeactivatedMember(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "RBACOrgDeactivated") + user := db.MustCreateUser(t, ctx, "rbac_deactivated@example.com", "RBACDeactivated", "", 0) + if err := db.CreateOrgMember(ctx, org.ID, user.ID, "admin"); err != nil { + t.Fatalf("create member: %v", err) + } + + // Deactivate the member. + if err := db.DeactivateOrgMember(ctx, org.ID, user.ID); err != nil { + t.Fatalf("deactivate member: %v", err) + } + + token, err := auth.IssueAccessToken([]byte("rbacdeactivatedsecret"), user.ID, 1, 15*time.Minute) + if err != nil { + t.Fatalf("issue token: %v", err) + } + + srv := newRBACServer(t, db, "rbacdeactivatedsecret") + ts, _ := buildRBACTestServer(t, srv, RoleViewer) // lowest role — should still be rejected + t.Cleanup(ts.Close) + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/orgs/"+org.ID.String()+"/resource", nil) + req.AddCookie(&http.Cookie{Name: "access_token", Value: token}) + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: ts.URL is httptest.Server + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusForbidden { + t.Errorf("deactivated member: got %d, want 403", resp.StatusCode) + } + + // Verify the response is RFC 9457 with the deactivation-specific detail. + assertRFC9457Response(t, resp, http.StatusForbidden) +} + // Suppress unused import when uuid is not referenced directly. var _ = uuid.UUID{} diff --git a/internal/api/middleware_scim.go b/internal/api/middleware_scim.go new file mode 100644 index 00000000..dbb74a95 --- /dev/null +++ b/internal/api/middleware_scim.go @@ -0,0 +1,97 @@ +// ABOUTME: SCIM bearer token authentication middleware. +// ABOUTME: Mounted on /scim/v2/* only. Separate from RequireAuthenticated (no human actor). +package api + +import ( + "context" + "crypto/subtle" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/auth" + "github.com/scarson/cvert-ops/internal/secure" +) + +// requireSCIMAuth authenticates SCIM bearer tokens per design doc §2. +// Steps: extract org_id → extract Bearer token → hash → lookup → constant-time +// compare → verify org match → verify enabled → inject context. +func (srv *Server) requireSCIMAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 1. Extract org_id from URL path. + orgIDStr := chi.URLParam(r, "org_id") + orgID, err := uuid.Parse(orgIDStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "", "invalid org_id") + return + } + + // 2. Extract Bearer token. + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + srv.fireSCIMEvent(r.Context(), secure.EventSCIMAuthFailed, &orgID) + writeSCIMError(w, http.StatusUnauthorized, "", "missing or invalid Authorization header") + return + } + rawToken := strings.TrimPrefix(authHeader, "Bearer ") + + // 3. Hash token. + tokenHash := auth.HashSCIMToken(rawToken) + + // 4. Lookup config (withBypassTx — pre-context). + cfg, err := srv.store.LookupSCIMConfigByTokenHash(r.Context(), tokenHash) + if err != nil { + slog.ErrorContext(r.Context(), "scim auth: lookup", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // 5. Constant-time compare (defense-in-depth against timing). + if cfg == nil || subtle.ConstantTimeCompare([]byte(cfg.TokenHash), []byte(tokenHash)) != 1 { + slog.WarnContext(r.Context(), "scim auth failed", "org_id", orgID, "reason", "invalid_token") + srv.fireSCIMEvent(r.Context(), secure.EventSCIMAuthFailed, &orgID) + writeSCIMError(w, http.StatusUnauthorized, "", "invalid or missing bearer token") + return + } + + // 6. Verify org_id match (defense-in-depth). + if cfg.OrgID != orgID { + slog.WarnContext(r.Context(), "scim auth failed", "org_id", orgID, "config_org_id", cfg.OrgID, "reason", "org_mismatch") + srv.fireSCIMEvent(r.Context(), secure.EventSCIMAuthOrgMismatch, &orgID) + writeSCIMError(w, http.StatusUnauthorized, "", "invalid or missing bearer token") + return + } + + // 7. Check enabled. + if !cfg.Enabled { + slog.WarnContext(r.Context(), "scim auth failed", "org_id", orgID, "reason", "disabled") + srv.fireSCIMEvent(r.Context(), secure.EventSCIMAuthDisabled, &orgID) + writeSCIMError(w, http.StatusForbidden, "", "SCIM provisioning is disabled for this organization") + return + } + + // 8. Inject context. + ctx := context.WithValue(r.Context(), ctxOrgID, orgID) + ctx = context.WithValue(ctx, ctxSCIMConfigID, cfg.ID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// fireSCIMEvent fires a security event if the event writer is configured. +func (srv *Server) fireSCIMEvent(ctx context.Context, eventType string, orgID *uuid.UUID) { + if srv.eventWriter != nil { + severity, ok := secure.Severity(eventType) + if !ok { + severity = secure.SeverityWarning + } + srv.eventWriter.Write(ctx, secure.Event{ + Type: eventType, + Severity: severity, + ActorIP: clientIP(ctx), + OrgID: orgID, + }) + } +} diff --git a/internal/api/middleware_scim_test.go b/internal/api/middleware_scim_test.go new file mode 100644 index 00000000..a51f4c07 --- /dev/null +++ b/internal/api/middleware_scim_test.go @@ -0,0 +1,328 @@ +// ABOUTME: Tests for SCIM bearer token authentication middleware. +// ABOUTME: Validates token auth, org isolation, disabled config, error format, and security events. +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/auth" + "github.com/scarson/cvert-ops/internal/config" + "github.com/scarson/cvert-ops/internal/secure" + "github.com/scarson/cvert-ops/internal/testutil" +) + +// scimTestEnv bundles a test server with SCIM middleware mounted plus the raw token +// and org/config IDs for assertions. +type scimTestEnv struct { + ts *httptest.Server + srv *Server + ew *secure.EventWriter + db *testutil.TestDB + orgID uuid.UUID + configID uuid.UUID + rawToken string + capturedCtx context.Context // captured from the inner handler +} + +// newSCIMTestEnv sets up a test environment with org, SSO connection, and SCIM config. +// The inner handler captures the context so tests can inspect ctxOrgID and ctxSCIMConfigID. +func newSCIMTestEnv(t *testing.T, enabled bool) *scimTestEnv { + t.Helper() + + db := testutil.NewTestDB(t) + ctx := context.Background() + + // Create org. + org, err := db.CreateOrg(ctx, "scim-test-org") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + + // Create SSO connection (required FK for scim_configs). + ssoConn, err := db.CreateSSOConnection(ctx, org.ID, "Test IdP", + "https://idp.example.com", "client-id", []byte("encrypted"), nil, true) + if err != nil { + t.Fatalf("CreateSSOConnection: %v", err) + } + + // Generate SCIM token and create config. + rawToken, tokenHash, tokenPrefix, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken: %v", err) + } + + scimCfg, err := db.CreateSCIMConfig(ctx, org.ID, ssoConn.ID, enabled, tokenHash, tokenPrefix, "viewer") + if err != nil { + t.Fatalf("CreateSCIMConfig: %v", err) + } + + // Create event writer backed by real DB. + ew := secure.NewEventWriter(db.Store) + + cfg := &config.Config{ //nolint:exhaustruct // test: only relevant fields set + JWTSecret: "scim-test-secret-32bytes-minimum", + } + srv, err := NewServer(db.Store, cfg, ServerDeps{EventWriter: ew}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + t.Cleanup(srv.Close) + + env := &scimTestEnv{ + srv: srv, + ew: ew, + db: db, + orgID: org.ID, + configID: scimCfg.ID, + rawToken: rawToken, + } + + // Build chi router with {org_id} param and SCIM auth middleware. + r := chi.NewRouter() + r.Route("/api/v1/orgs/{org_id}/scim/v2", func(sub chi.Router) { + sub.Use(srv.requireSCIMAuth) + sub.Get("/test", func(w http.ResponseWriter, r *http.Request) { + env.capturedCtx = r.Context() + w.WriteHeader(http.StatusOK) + }) + }) + + ts := httptest.NewServer(r) + t.Cleanup(ts.Close) + env.ts = ts + + return env +} + +// assertSCIMErrorResponse checks that the response is a valid SCIM error JSON +// with the correct Content-Type and status. +func assertSCIMErrorResponse(t *testing.T, resp *http.Response, wantStatus int) { + t.Helper() + ct := resp.Header.Get("Content-Type") + if ct != "application/scim+json" { + t.Errorf("Content-Type = %q, want %q", ct, "application/scim+json") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var scimErr struct { + Schemas []string `json:"schemas"` + Status string `json:"status"` + Detail string `json:"detail"` + } + if err := json.Unmarshal(body, &scimErr); err != nil { + t.Fatalf("decode SCIM error: %v (body: %s)", err, string(body)) + } + if len(scimErr.Schemas) != 1 || scimErr.Schemas[0] != "urn:ietf:params:scim:api:messages:2.0:Error" { + t.Errorf("schemas = %v, want [urn:ietf:params:scim:api:messages:2.0:Error]", scimErr.Schemas) + } + wantStatusStr := http.StatusText(wantStatus) + _ = wantStatusStr // status field is the numeric string + if scimErr.Detail == "" { + t.Error("SCIM error detail is empty") + } +} + +func TestSCIMAuth_ValidToken(t *testing.T) { + t.Parallel() + env := newSCIMTestEnv(t, true) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + env.ts.URL+"/api/v1/orgs/"+env.orgID.String()+"/scim/v2/test", nil) + req.Header.Set("Authorization", "Bearer "+env.rawToken) + + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + + // Verify context values were injected. + gotOrgID, ok := env.capturedCtx.Value(ctxOrgID).(uuid.UUID) + if !ok || gotOrgID != env.orgID { + t.Errorf("ctxOrgID = %v, want %v", gotOrgID, env.orgID) + } + gotConfigID, ok := env.capturedCtx.Value(ctxSCIMConfigID).(uuid.UUID) + if !ok || gotConfigID != env.configID { + t.Errorf("ctxSCIMConfigID = %v, want %v", gotConfigID, env.configID) + } +} + +func TestSCIMAuth_InvalidToken(t *testing.T) { + t.Parallel() + env := newSCIMTestEnv(t, true) + + // Well-formed but wrong token: same prefix, correct length, different random bytes. + wrongToken, _, _, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken: %v", err) + } + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + env.ts.URL+"/api/v1/orgs/"+env.orgID.String()+"/scim/v2/test", nil) + req.Header.Set("Authorization", "Bearer "+wrongToken) + + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + assertSCIMErrorResponse(t, resp, http.StatusUnauthorized) +} + +func TestSCIMAuth_OrgMismatch(t *testing.T) { + t.Parallel() + env := newSCIMTestEnv(t, true) + + // Create a second org — token belongs to env.orgID, not this one. + ctx := context.Background() + org2, err := env.db.CreateOrg(ctx, "scim-other-org") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + env.ts.URL+"/api/v1/orgs/"+org2.ID.String()+"/scim/v2/test", nil) + req.Header.Set("Authorization", "Bearer "+env.rawToken) + + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + assertSCIMErrorResponse(t, resp, http.StatusUnauthorized) +} + +func TestSCIMAuth_Disabled(t *testing.T) { + t.Parallel() + env := newSCIMTestEnv(t, false) // config disabled + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + env.ts.URL+"/api/v1/orgs/"+env.orgID.String()+"/scim/v2/test", nil) + req.Header.Set("Authorization", "Bearer "+env.rawToken) + + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusForbidden { + t.Errorf("status = %d, want 403", resp.StatusCode) + } + assertSCIMErrorResponse(t, resp, http.StatusForbidden) +} + +func TestSCIMAuth_MissingHeader(t *testing.T) { + t.Parallel() + env := newSCIMTestEnv(t, true) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + env.ts.URL+"/api/v1/orgs/"+env.orgID.String()+"/scim/v2/test", nil) + // No Authorization header. + + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + assertSCIMErrorResponse(t, resp, http.StatusUnauthorized) +} + +func TestSCIMAuth_ErrorFormat(t *testing.T) { + t.Parallel() + env := newSCIMTestEnv(t, true) + + // Test multiple error scenarios all produce application/scim+json. + scenarios := []struct { + name string + authHeader string + orgID string + wantStatus int + }{ + {"no_auth_header", "", env.orgID.String(), http.StatusUnauthorized}, + {"bad_bearer", "Bearer " + "cvert_scim_0000000000000000000000000000000000000000000000000000000000000000", env.orgID.String(), http.StatusUnauthorized}, + {"invalid_org_id", "Bearer " + env.rawToken, "not-a-uuid", http.StatusBadRequest}, + } + + for _, sc := range scenarios { + t.Run(sc.name, func(t *testing.T) { + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + env.ts.URL+"/api/v1/orgs/"+sc.orgID+"/scim/v2/test", nil) + if sc.authHeader != "" { + req.Header.Set("Authorization", sc.authHeader) + } + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != sc.wantStatus { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Errorf("status = %d, want %d (body: %s)", resp.StatusCode, sc.wantStatus, string(body)) + return + } + + ct := resp.Header.Get("Content-Type") + if ct != "application/scim+json" { + t.Errorf("Content-Type = %q, want %q", ct, "application/scim+json") + } + }) + } +} + +func TestSCIMAuth_SecurityEvent(t *testing.T) { + t.Parallel() + env := newSCIMTestEnv(t, true) + + // Send a request with a well-formed but wrong token. + wrongToken, _, _, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken: %v", err) + } + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + env.ts.URL+"/api/v1/orgs/"+env.orgID.String()+"/scim/v2/test", nil) + req.Header.Set("Authorization", "Bearer "+wrongToken) + + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } + + // Flush event writer and query security_events. + events := flushAndQueryEvents(t, env.ew, env.db, secure.EventSCIMAuthFailed) + if len(events) == 0 { + t.Error("expected at least one scim.auth_failed security event, got 0") + } +} diff --git a/internal/api/openapi_spec.go b/internal/api/openapi_spec.go index 1b8e8544..b0282ce6 100644 --- a/internal/api/openapi_spec.go +++ b/internal/api/openapi_spec.go @@ -105,11 +105,11 @@ func mergeSpecPaths(prod, specOnly huma.API) { // These embed existing Chi DTO types so spec and runtime share field definitions. type specCreateGroupInput struct { - OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` + OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` Body createGroupBody `json:"body"` } type specCreateGroupOutput struct { - Location string `header:"Location"` + Location string `header:"Location"` Body groupEntry } @@ -238,7 +238,7 @@ type specCreateOrgInput struct { Body createOrgBody `json:"body"` } type specCreateOrgOutput struct { - Location string `header:"Location"` + Location string `header:"Location"` Body createOrgResponseBody } @@ -267,12 +267,12 @@ type specListMembersOutput struct { } type specUpdateMemberRoleInput struct { - OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` - UserID string `path:"user_id" format:"uuid" doc:"User ID"` - Body updateMemberRoleBody `json:"body"` + OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` + UserID string `path:"user_id" format:"uuid" doc:"User ID"` + Body patchMemberBody `json:"body"` } type specUpdateMemberRoleOutput struct { - Body updateMemberRoleResponseBody + Body patchMemberResponseBody } type specRemoveMemberInput struct { @@ -399,7 +399,7 @@ type specCreateAPIKeyInput struct { Body createAPIKeyBody `json:"body"` } type specCreateAPIKeyOutput struct { - Location string `header:"Location"` + Location string `header:"Location"` Body createAPIKeyResponse } @@ -446,11 +446,11 @@ func registerAPIKeysSpecOps(api huma.API) { // ── Watchlists spec-only declarations ──────────────────────────────────────── type specCreateWatchlistInput struct { - OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` + OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` Body createWatchlistBody `json:"body"` } type specCreateWatchlistOutput struct { - Location string `header:"Location"` + Location string `header:"Location"` Body watchlistEntry } @@ -488,12 +488,12 @@ type specDeleteWatchlistInput struct { } type specCreateWatchlistItemInput struct { - OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` - WatchlistID string `path:"id" format:"uuid" doc:"Watchlist ID"` + OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` + WatchlistID string `path:"id" format:"uuid" doc:"Watchlist ID"` Body createWatchlistItemBody `json:"body"` } type specCreateWatchlistItemOutput struct { - Location string `header:"Location"` + Location string `header:"Location"` Body watchlistItemEntry } @@ -585,11 +585,11 @@ func registerWatchlistsSpecOps(api huma.API) { // ── Alert Rules spec-only declarations ─────────────────────────────────────── type specCreateAlertRuleInput struct { - OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` + OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` Body createAlertRuleBody `json:"body"` } type specCreateAlertRuleOutput struct { - Location string `header:"Location"` + Location string `header:"Location"` Body alertRuleEntry } @@ -966,7 +966,7 @@ type specCreateReportInput struct { Body createReportBody `json:"body"` } type specCreateReportOutput struct { - Location string `header:"Location"` + Location string `header:"Location"` Body reportEntry } @@ -1092,11 +1092,11 @@ func registerReportsSpecOps(api huma.API) { // ── Saved Searches spec-only declarations ──────────────────────────────────── type specCreateSavedSearchInput struct { - OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` + OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` Body createSavedSearchRequest `json:"body"` } type specCreateSavedSearchOutput struct { - Location string `header:"Location"` + Location string `header:"Location"` Body savedSearchEntry } @@ -1120,8 +1120,8 @@ type specGetSavedSearchOutput struct { } type specUpdateSavedSearchInput struct { - OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` - ID string `path:"id" format:"uuid" doc:"Saved Search ID"` + OrgID string `path:"org_id" format:"uuid" doc:"Organization ID"` + ID string `path:"id" format:"uuid" doc:"Saved Search ID"` Body patchSavedSearchRequest `json:"body"` } type specUpdateSavedSearchOutput struct { diff --git a/internal/api/orgs.go b/internal/api/orgs.go index 1341e6e4..cec6f90e 100644 --- a/internal/api/orgs.go +++ b/internal/api/orgs.go @@ -165,22 +165,31 @@ func (srv *Server) updateOrgHandler(w http.ResponseWriter, r *http.Request) { // memberEntry is one row in the GET /members response. type memberEntry struct { - UserID string `json:"user_id"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - JoinedAt string `json:"joined_at"` + UserID string `json:"user_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Active bool `json:"active"` + DeactivatedAt *string `json:"deactivated_at,omitempty"` + SCIMExempt bool `json:"scim_exempt"` + JoinedAt string `json:"joined_at"` } -// updateMemberRoleBody is the request body for PATCH /members/{user_id}. -type updateMemberRoleBody struct { - Role string `json:"role"` +// patchMemberBody is the request body for PATCH /members/{user_id}. +// All fields are pointers: nil = not sent (no change). +type patchMemberBody struct { + Role *string `json:"role,omitempty"` + Active *bool `json:"active,omitempty"` + SCIMExempt *bool `json:"scim_exempt,omitempty"` } -// updateMemberRoleResponseBody is the response for PATCH /members/{user_id}. -type updateMemberRoleResponseBody struct { - UserID string `json:"user_id"` - Role string `json:"role"` +// patchMemberResponseBody is the response for PATCH /members/{user_id}. +type patchMemberResponseBody struct { + UserID string `json:"user_id"` + Role string `json:"role"` + Active bool `json:"active"` + DeactivatedAt *string `json:"deactivated_at,omitempty"` + SCIMExempt bool `json:"scim_exempt"` } // listMembersHandler handles GET /api/v1/orgs/{org_id}/members. @@ -201,20 +210,28 @@ func (srv *Server) listMembersHandler(w http.ResponseWriter, r *http.Request) { members := make([]memberEntry, 0, len(rows)) for _, m := range rows { - members = append(members, memberEntry{ + entry := memberEntry{ UserID: m.UserID.String(), Email: m.Email, DisplayName: m.DisplayName, Role: m.Role, + Active: !m.DeactivatedAt.Valid, + SCIMExempt: m.ScimExempt, JoinedAt: m.CreatedAt.Format(time.RFC3339), - }) + } + if m.DeactivatedAt.Valid { + ts := m.DeactivatedAt.Time.Format(time.RFC3339) + entry.DeactivatedAt = &ts + } + members = append(members, entry) } writeList(w, members, "") } // updateMemberRoleHandler handles PATCH /api/v1/orgs/{org_id}/members/{user_id}. -// Requires admin+ (enforced by middleware). Cannot change an existing owner's role -// or assign the owner role (use a transfer-ownership endpoint for that). +// Requires admin+ (enforced by middleware). Supports updating role, active status, +// and scim_exempt flag. Cannot change an existing owner's role or assign the owner +// role (use a transfer-ownership endpoint for that). func (srv *Server) updateMemberRoleHandler(w http.ResponseWriter, r *http.Request) { orgID, ok := r.Context().Value(ctxOrgID).(uuid.UUID) if !ok { @@ -234,66 +251,134 @@ func (srv *Server) updateMemberRoleHandler(w http.ResponseWriter, r *http.Reques return } - var req updateMemberRoleBody + var req patchMemberBody if decErr := decodeJSON(r, &req); decErr != nil { writeProblemWithErrors(w, http.StatusBadRequest, "invalid request body", decErr) return } - // Owner role cannot be assigned via PATCH; use a transfer-ownership endpoint. - if req.Role == "owner" { - writeProblemWithErrors(w, http.StatusUnprocessableEntity, "validation failed", - &huma.ErrorDetail{Message: "cannot assign owner role via this endpoint", Location: "body.role"}) + // Look up the target's current state. + current, err := srv.store.GetOrgMemberFull(r.Context(), orgID, targetID) + if err != nil { + slog.ErrorContext(r.Context(), "get target member", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") return } - if req.Role != "admin" && req.Role != "member" && req.Role != "viewer" { - writeProblemWithErrors(w, http.StatusUnprocessableEntity, "validation failed", - &huma.ErrorDetail{Message: "invalid role: must be admin, member, or viewer", Location: "body.role"}) + if current == nil { + writeProblem(w, http.StatusNotFound, "user not found in org") return } - // Caller cannot assign a role higher than their own. - newRole := parseRole(req.Role) - if newRole > callerRole { - writeProblem(w, http.StatusForbidden, "cannot assign role higher than your own") - return + oldState := map[string]any{"role": current.Role} + newState := map[string]any{} + + // Handle role update. + if req.Role != nil { + role := *req.Role + // Owner role cannot be assigned via PATCH; use a transfer-ownership endpoint. + if role == "owner" { + writeProblemWithErrors(w, http.StatusUnprocessableEntity, "validation failed", + &huma.ErrorDetail{Message: "cannot assign owner role via this endpoint", Location: "body.role"}) + return + } + if role != "admin" && role != "member" && role != "viewer" { + writeProblemWithErrors(w, http.StatusUnprocessableEntity, "validation failed", + &huma.ErrorDetail{Message: "invalid role: must be admin, member, or viewer", Location: "body.role"}) + return + } + + // Caller cannot assign a role higher than their own. + if parseRole(role) > callerRole { + writeProblem(w, http.StatusForbidden, "cannot assign role higher than your own") + return + } + + if current.Role == "owner" { + writeProblem(w, http.StatusForbidden, "cannot change role of an org owner") + return + } + + if err := srv.store.UpdateOrgMemberRole(r.Context(), orgID, targetID, role); err != nil { + slog.ErrorContext(r.Context(), "update member role", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + newState["role"] = role + } + + // Handle active status (deactivation/reactivation). + if req.Active != nil { + if !*req.Active { + // Deactivation: protect sole owner. + if current.Role == "owner" { + count, err := srv.store.CountActiveOrgOwners(r.Context(), orgID) + if err != nil { + slog.ErrorContext(r.Context(), "count active owners", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + if count <= 1 { + writeProblem(w, http.StatusBadRequest, "Cannot deactivate the sole owner") + return + } + } + if err := srv.store.DeactivateOrgMember(r.Context(), orgID, targetID); err != nil { + slog.ErrorContext(r.Context(), "deactivate member", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + newState["active"] = false + } else { + if err := srv.store.ReactivateOrgMember(r.Context(), orgID, targetID); err != nil { + slog.ErrorContext(r.Context(), "reactivate member", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + newState["active"] = true + } } - // Look up the target's current role. - currentRole, err := srv.store.GetOrgMemberRole(r.Context(), orgID, targetID) - if err != nil { - slog.ErrorContext(r.Context(), "get target role", "error", err) + // Handle scim_exempt flag. + if req.SCIMExempt != nil { + if err := srv.store.UpdateOrgMemberSCIMExempt(r.Context(), orgID, targetID, *req.SCIMExempt); err != nil { + slog.ErrorContext(r.Context(), "update scim exempt", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + newState["scim_exempt"] = *req.SCIMExempt + } + + // Re-read to get current state for response. + updated, err := srv.store.GetOrgMemberFull(r.Context(), orgID, targetID) + if err != nil || updated == nil { + slog.ErrorContext(r.Context(), "re-read member after patch", "error", err) writeProblem(w, http.StatusInternalServerError, "internal error") return } - if currentRole == nil { - writeProblem(w, http.StatusNotFound, "user not found in org") - return + + resp := patchMemberResponseBody{ + UserID: targetID.String(), + Role: updated.Role, + Active: !updated.DeactivatedAt.Valid, + SCIMExempt: updated.ScimExempt, } - if *currentRole == "owner" { - writeProblem(w, http.StatusForbidden, "cannot change role of an org owner") - return + if updated.DeactivatedAt.Valid { + ts := updated.DeactivatedAt.Time.Format(time.RFC3339) + resp.DeactivatedAt = &ts } - if err := srv.store.UpdateOrgMemberRole(r.Context(), orgID, targetID, req.Role); err != nil { - slog.ErrorContext(r.Context(), "update member role", "error", err) - writeProblem(w, http.StatusInternalServerError, "internal error") - return + writeJSON(w, http.StatusOK, resp) + if len(newState) > 0 { + srv.auditLog(r, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: targetID.String(), + Success: true, + OldState: oldState, + NewState: newState, + }) } - - writeJSON(w, http.StatusOK, updateMemberRoleResponseBody{ - UserID: targetID.String(), - Role: req.Role, - }) - srv.auditLog(r, audit.Entry{ - OrgID: orgID, - Action: "update", - EntityType: "member", - EntityID: targetID.String(), - Success: true, - OldState: map[string]any{"role": *currentRole}, - NewState: map[string]any{"role": req.Role}, - }) } // removeMemberHandler handles DELETE /api/v1/orgs/{org_id}/members/{user_id}. diff --git a/internal/api/orgs_test.go b/internal/api/orgs_test.go index 28c6357f..542fa3e5 100644 --- a/internal/api/orgs_test.go +++ b/internal/api/orgs_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" "net/http" "net/http/httptest" @@ -289,6 +290,20 @@ func doUpdateMemberRole(t *testing.T, ctx context.Context, ts *httptest.Server, return resp } +// doPatchMember calls PATCH /api/v1/orgs/{orgID}/members/{userID} with arbitrary JSON body. +func doPatchMember(t *testing.T, ctx context.Context, ts *httptest.Server, accessToken, orgID, userID, body string) *http.Response { + t.Helper() + req, _ := http.NewRequestWithContext(ctx, http.MethodPatch, ts.URL+"/api/v1/orgs/"+orgID+"/members/"+userID, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "access_token="+accessToken) + req.Header.Set("X-Requested-By", "CVErt-Ops") + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: srv.URL is httptest.Server + if err != nil { + t.Fatalf("patch member request: %v", err) + } + return resp +} + // doRemoveMember calls DELETE /api/v1/orgs/{orgID}/members/{userID}. func doRemoveMember(t *testing.T, ctx context.Context, ts *httptest.Server, accessToken, orgID, userID string) *http.Response { t.Helper() @@ -1765,3 +1780,175 @@ func TestCreateOrg_ValidationError_ProblemJSON(t *testing.T) { t.Errorf("errors[0].location = %v, want body.name", err0["location"]) } } + +// TestPatchMember_Deactivate verifies that PATCH {"active": false} deactivates a member. +func TestPatchMember_Deactivate(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + _, ts := newRegisterServer(t, db, "open") + + aliceReg := doRegister(t, ctx, ts, "alice@example.com", "test-password-1234") + bobReg := doRegister(t, ctx, ts, "bob@example.com", "test-password-1234") + orgID, _ := uuid.Parse(aliceReg.OrgID) + bobUserID, _ := uuid.Parse(bobReg.UserID) + if err := db.CreateOrgMember(ctx, orgID, bobUserID, "member"); err != nil { + t.Fatalf("add bob: %v", err) + } + + loginResp := doLogin(t, ctx, ts, "alice@example.com", "test-password-1234") + defer loginResp.Body.Close() //nolint:errcheck,gosec // G104 + aliceToken := cookieValue(loginResp, "access_token") + + resp := doPatchMember(t, ctx, ts, aliceToken, aliceReg.OrgID, bobReg.UserID, `{"active": false}`) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("deactivate: got %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + // Verify deactivated_at is set in DB. + member, err := db.GetOrgMemberFull(ctx, orgID, bobUserID) + if err != nil { + t.Fatalf("get member: %v", err) + } + if member == nil { + t.Fatal("member not found") + } + if !member.DeactivatedAt.Valid { + t.Error("deactivated_at should be set after deactivation") + } +} + +// TestPatchMember_Reactivate verifies that PATCH {"active": true} reactivates a deactivated member. +func TestPatchMember_Reactivate(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + _, ts := newRegisterServer(t, db, "open") + + aliceReg := doRegister(t, ctx, ts, "alice@example.com", "test-password-1234") + bobReg := doRegister(t, ctx, ts, "bob@example.com", "test-password-1234") + orgID, _ := uuid.Parse(aliceReg.OrgID) + bobUserID, _ := uuid.Parse(bobReg.UserID) + if err := db.CreateOrgMember(ctx, orgID, bobUserID, "member"); err != nil { + t.Fatalf("add bob: %v", err) + } + + // First deactivate. + if err := db.DeactivateOrgMember(ctx, orgID, bobUserID); err != nil { + t.Fatalf("deactivate: %v", err) + } + + loginResp := doLogin(t, ctx, ts, "alice@example.com", "test-password-1234") + defer loginResp.Body.Close() //nolint:errcheck,gosec // G104 + aliceToken := cookieValue(loginResp, "access_token") + + resp := doPatchMember(t, ctx, ts, aliceToken, aliceReg.OrgID, bobReg.UserID, `{"active": true}`) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("reactivate: got %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + // Verify deactivated_at is NULL. + member, err := db.GetOrgMemberFull(ctx, orgID, bobUserID) + if err != nil { + t.Fatalf("get member: %v", err) + } + if member == nil { + t.Fatal("member not found") + } + if member.DeactivatedAt.Valid { + t.Error("deactivated_at should be NULL after reactivation") + } +} + +// TestPatchMember_SoleOwnerProtection verifies that deactivating the sole owner returns 400. +func TestPatchMember_SoleOwnerProtection(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + _, ts := newRegisterServer(t, db, "open") + + aliceReg := doRegister(t, ctx, ts, "alice@example.com", "test-password-1234") + + loginResp := doLogin(t, ctx, ts, "alice@example.com", "test-password-1234") + defer loginResp.Body.Close() //nolint:errcheck,gosec // G104 + aliceToken := cookieValue(loginResp, "access_token") + + // Alice is the sole owner — deactivating should be blocked. + resp := doPatchMember(t, ctx, ts, aliceToken, aliceReg.OrgID, aliceReg.UserID, `{"active": false}`) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Errorf("sole owner deactivation: got %d, want 400 (body: %s)", resp.StatusCode, string(body)) + } +} + +// TestPatchMember_SCIMExempt verifies that PATCH {"scim_exempt": true} sets the flag. +func TestPatchMember_SCIMExempt(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + _, ts := newRegisterServer(t, db, "open") + + aliceReg := doRegister(t, ctx, ts, "alice@example.com", "test-password-1234") + bobReg := doRegister(t, ctx, ts, "bob@example.com", "test-password-1234") + orgID, _ := uuid.Parse(aliceReg.OrgID) + bobUserID, _ := uuid.Parse(bobReg.UserID) + if err := db.CreateOrgMember(ctx, orgID, bobUserID, "member"); err != nil { + t.Fatalf("add bob: %v", err) + } + + loginResp := doLogin(t, ctx, ts, "alice@example.com", "test-password-1234") + defer loginResp.Body.Close() //nolint:errcheck,gosec // G104 + aliceToken := cookieValue(loginResp, "access_token") + + resp := doPatchMember(t, ctx, ts, aliceToken, aliceReg.OrgID, bobReg.UserID, `{"scim_exempt": true}`) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("scim_exempt: got %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + // Verify scim_exempt is set in DB. + member, err := db.GetOrgMemberFull(ctx, orgID, bobUserID) + if err != nil { + t.Fatalf("get member: %v", err) + } + if member == nil { + t.Fatal("member not found") + } + if !member.ScimExempt { + t.Error("scim_exempt should be true") + } +} + +// TestPatchMember_RequiresAdmin verifies that a viewer cannot PATCH members. +func TestPatchMember_RequiresAdmin(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + _, ts := newRegisterServer(t, db, "open") + + aliceReg := doRegister(t, ctx, ts, "alice@example.com", "test-password-1234") + bobReg := doRegister(t, ctx, ts, "bob@example.com", "test-password-1234") + orgID, _ := uuid.Parse(aliceReg.OrgID) + bobUserID, _ := uuid.Parse(bobReg.UserID) + // Make bob a viewer. + if err := db.CreateOrgMember(ctx, orgID, bobUserID, "viewer"); err != nil { + t.Fatalf("add bob: %v", err) + } + + loginResp := doLogin(t, ctx, ts, "bob@example.com", "test-password-1234") + defer loginResp.Body.Close() //nolint:errcheck,gosec // G104 + bobToken := cookieValue(loginResp, "access_token") + + // Viewer tries to deactivate alice — should get 403. + resp := doPatchMember(t, ctx, ts, bobToken, aliceReg.OrgID, aliceReg.UserID, `{"active": false}`) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusForbidden { + t.Errorf("viewer PATCH: got %d, want 403", resp.StatusCode) + } +} diff --git a/internal/api/scim_admin.go b/internal/api/scim_admin.go new file mode 100644 index 00000000..0b456a9b --- /dev/null +++ b/internal/api/scim_admin.go @@ -0,0 +1,592 @@ +// ABOUTME: HTTP handlers for SCIM config admin CRUD (create, read, update, delete, token rotation). +// ABOUTME: Standard auth (cookie-based), RFC 9457 errors. Enterprise-tier-gated. Owner-only for mutations. +package api + +import ( + "database/sql" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/audit" + "github.com/scarson/cvert-ops/internal/auth" + "github.com/scarson/cvert-ops/internal/secure" +) + +// ── Request / response types ──────────────────────────────────────────────── + +type scimConfigResponse struct { + ID string `json:"id"` + OrgID string `json:"org_id"` + Enabled bool `json:"enabled"` + DefaultRole string `json:"default_role"` + TokenPrefix string `json:"token_prefix"` + Token string `json:"token,omitempty"` // only set on create and rotate + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type patchSCIMConfigBody struct { + Enabled *bool `json:"enabled"` + DefaultRole *string `json:"default_role"` +} + +type rotateTokenResponse struct { + Token string `json:"token"` + TokenPrefix string `json:"token_prefix"` +} + +type scimGroupItem struct { + ID string `json:"id"` + ExternalID *string `json:"external_id"` + DisplayName string `json:"display_name"` + MappedRole *string `json:"mapped_role"` + MappedGroupID *string `json:"mapped_group_id"` + MemberCount int `json:"member_count"` + CreatedAt string `json:"created_at"` +} + +type listSCIMGroupsResponse struct { + Items []scimGroupItem `json:"items"` +} + +type patchSCIMGroupMappingBody struct { + MappedRole *string `json:"mapped_role"` + MappedGroupID *uuid.UUID `json:"mapped_group_id"` +} + +type scimGroupResponse struct { + ID string `json:"id"` + ExternalID *string `json:"external_id"` + DisplayName string `json:"display_name"` + MappedRole *string `json:"mapped_role"` + MappedGroupID *string `json:"mapped_group_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ── Handlers ──────────────────────────────────────────────────────────────── + +// createSCIMConfigHandler handles POST /api/v1/orgs/{org_id}/sso/scim. +func (srv *Server) createSCIMConfigHandler(w http.ResponseWriter, r *http.Request) { + orgID, ok := r.Context().Value(ctxOrgID).(uuid.UUID) + if !ok { + writeProblem(w, http.StatusBadRequest, "bad request") + return + } + if !requireEnterpriseTier(w, r) { + return + } + + // Verify SSO connection exists for this org. + ssoConn, err := srv.store.GetSSOConnection(r.Context(), orgID) + if err != nil { + slog.ErrorContext(r.Context(), "scim config create: get sso connection", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + if ssoConn == nil { + writeProblem(w, http.StatusBadRequest, "SSO connection required before enabling SCIM") + return + } + + // Generate bearer token. + rawToken, tokenHash, tokenPrefix, err := auth.GenerateSCIMToken() + if err != nil { + slog.ErrorContext(r.Context(), "scim config create: generate token", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + row, err := srv.store.CreateSCIMConfig(r.Context(), orgID, ssoConn.ID, false, tokenHash, tokenPrefix, "viewer") + if err != nil { + if isUniqueViolation(err) { + writeProblem(w, http.StatusConflict, "SCIM configuration already exists for this organization") + return + } + slog.ErrorContext(r.Context(), "scim config create: store", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + srv.fireSCIMEvent(r.Context(), secure.EventSCIMTokenCreated, &orgID) + + srv.auditLog(r, audit.Entry{ //nolint:exhaustruct // optional fields + OrgID: orgID, + Action: "create", + EntityType: "scim_config", + EntityID: row.ID.String(), + Success: true, + NewState: map[string]any{ + "enabled": false, + "default_role": "viewer", + }, + }) + + writeJSON(w, http.StatusCreated, scimConfigResponse{ + ID: row.ID.String(), + OrgID: row.OrgID.String(), + Enabled: row.Enabled, + DefaultRole: row.DefaultRole, + TokenPrefix: row.TokenPrefix, + Token: rawToken, + CreatedAt: row.CreatedAt.Format(time.RFC3339), + }) +} + +// getSCIMConfigHandler handles GET /api/v1/orgs/{org_id}/sso/scim. +func (srv *Server) getSCIMConfigHandler(w http.ResponseWriter, r *http.Request) { + orgID, ok := r.Context().Value(ctxOrgID).(uuid.UUID) + if !ok { + writeProblem(w, http.StatusBadRequest, "bad request") + return + } + if !requireEnterpriseTier(w, r) { + return + } + + row, err := srv.store.GetSCIMConfig(r.Context(), orgID) + if err != nil { + slog.ErrorContext(r.Context(), "scim config get: store", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + if row == nil { + writeProblem(w, http.StatusNotFound, "no SCIM configuration") + return + } + + writeJSON(w, http.StatusOK, scimConfigResponse{ + ID: row.ID.String(), + OrgID: row.OrgID.String(), + Enabled: row.Enabled, + DefaultRole: row.DefaultRole, + TokenPrefix: row.TokenPrefix, + CreatedAt: row.CreatedAt.Format(time.RFC3339), + UpdatedAt: row.UpdatedAt.Format(time.RFC3339), + }) +} + +// patchSCIMConfigHandler handles PATCH /api/v1/orgs/{org_id}/sso/scim. +func (srv *Server) patchSCIMConfigHandler(w http.ResponseWriter, r *http.Request) { + orgID, ok := r.Context().Value(ctxOrgID).(uuid.UUID) + if !ok { + writeProblem(w, http.StatusBadRequest, "bad request") + return + } + if !requireEnterpriseTier(w, r) { + return + } + + current, err := srv.store.GetSCIMConfig(r.Context(), orgID) + if err != nil { + slog.ErrorContext(r.Context(), "scim config patch: get", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + if current == nil { + writeProblem(w, http.StatusNotFound, "no SCIM configuration") + return + } + + var req patchSCIMConfigBody + if errDetail := decodeJSON(r, &req); errDetail != nil { + writeProblemWithErrors(w, http.StatusBadRequest, "invalid request body", errDetail) + return + } + + enabled := current.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + defaultRole := current.DefaultRole + if req.DefaultRole != nil { + role := strings.TrimSpace(*req.DefaultRole) + if role != "viewer" && role != "member" { + writeProblem(w, http.StatusBadRequest, "default_role must be 'viewer' or 'member'") + return + } + defaultRole = role + } + + if err := srv.store.UpdateSCIMConfig(r.Context(), orgID, enabled, defaultRole); err != nil { + slog.ErrorContext(r.Context(), "scim config patch: update", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + // Re-read to get updated timestamps. + updated, err := srv.store.GetSCIMConfig(r.Context(), orgID) + if err != nil || updated == nil { + slog.ErrorContext(r.Context(), "scim config patch: re-read", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + srv.auditLog(r, audit.Entry{ //nolint:exhaustruct // optional fields + OrgID: orgID, + Action: "update", + EntityType: "scim_config", + EntityID: updated.ID.String(), + Success: true, + OldState: map[string]any{ + "enabled": current.Enabled, + "default_role": current.DefaultRole, + }, + NewState: map[string]any{ + "enabled": updated.Enabled, + "default_role": updated.DefaultRole, + }, + }) + + writeJSON(w, http.StatusOK, scimConfigResponse{ + ID: updated.ID.String(), + OrgID: updated.OrgID.String(), + Enabled: updated.Enabled, + DefaultRole: updated.DefaultRole, + TokenPrefix: updated.TokenPrefix, + CreatedAt: updated.CreatedAt.Format(time.RFC3339), + UpdatedAt: updated.UpdatedAt.Format(time.RFC3339), + }) +} + +// deleteSCIMConfigHandler handles DELETE /api/v1/orgs/{org_id}/sso/scim. +func (srv *Server) deleteSCIMConfigHandler(w http.ResponseWriter, r *http.Request) { + orgID, ok := r.Context().Value(ctxOrgID).(uuid.UUID) + if !ok { + writeProblem(w, http.StatusBadRequest, "bad request") + return + } + if !requireEnterpriseTier(w, r) { + return + } + + // Read current for audit trail. + current, err := srv.store.GetSCIMConfig(r.Context(), orgID) + if err != nil { + slog.ErrorContext(r.Context(), "scim config delete: get", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + // Idempotent: 204 even if no config exists. + if current != nil { + if err := srv.store.DeleteSCIMConfig(r.Context(), orgID); err != nil { + slog.ErrorContext(r.Context(), "scim config delete: store", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + srv.auditLog(r, audit.Entry{ //nolint:exhaustruct // optional fields + OrgID: orgID, + Action: "delete", + EntityType: "scim_config", + EntityID: current.ID.String(), + Success: true, + }) + } + + w.WriteHeader(http.StatusNoContent) +} + +// rotateSCIMTokenHandler handles POST /api/v1/orgs/{org_id}/sso/scim/rotate-token. +func (srv *Server) rotateSCIMTokenHandler(w http.ResponseWriter, r *http.Request) { + orgID, ok := r.Context().Value(ctxOrgID).(uuid.UUID) + if !ok { + writeProblem(w, http.StatusBadRequest, "bad request") + return + } + if !requireEnterpriseTier(w, r) { + return + } + + // Verify config exists. + cfg, err := srv.store.GetSCIMConfig(r.Context(), orgID) + if err != nil { + slog.ErrorContext(r.Context(), "scim token rotate: get", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + if cfg == nil { + writeProblem(w, http.StatusNotFound, "no SCIM configuration") + return + } + + rawToken, tokenHash, tokenPrefix, err := auth.GenerateSCIMToken() + if err != nil { + slog.ErrorContext(r.Context(), "scim token rotate: generate", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + if err := srv.store.RotateSCIMToken(r.Context(), orgID, tokenHash, tokenPrefix); err != nil { + slog.ErrorContext(r.Context(), "scim token rotate: store", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + srv.fireSCIMEvent(r.Context(), secure.EventSCIMTokenRotated, &orgID) + + srv.auditLog(r, audit.Entry{ //nolint:exhaustruct // optional fields + OrgID: orgID, + Action: "update", + EntityType: "scim_config", + EntityID: cfg.ID.String(), + Success: true, + }) + + writeJSON(w, http.StatusOK, rotateTokenResponse{ + Token: rawToken, + TokenPrefix: tokenPrefix, + }) +} + +// listSCIMGroupsHandler handles GET /api/v1/orgs/{org_id}/sso/scim/groups. +func (srv *Server) listSCIMGroupsHandler(w http.ResponseWriter, r *http.Request) { + orgID, ok := r.Context().Value(ctxOrgID).(uuid.UUID) + if !ok { + writeProblem(w, http.StatusBadRequest, "bad request") + return + } + if !requireEnterpriseTier(w, r) { + return + } + + rows, err := srv.store.ListSCIMGroups(r.Context(), orgID) + if err != nil { + slog.ErrorContext(r.Context(), "scim groups list: store", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + items := make([]scimGroupItem, 0, len(rows)) + for _, g := range rows { + item := scimGroupItem{ + ID: g.ID.String(), + DisplayName: g.DisplayName, + MemberCount: int(g.MemberCount), + CreatedAt: g.CreatedAt.Format(time.RFC3339), + } + if g.ExternalID.Valid { + item.ExternalID = &g.ExternalID.String + } + if g.MappedRole.Valid { + item.MappedRole = &g.MappedRole.String + } + if g.MappedGroupID.Valid { + s := g.MappedGroupID.UUID.String() + item.MappedGroupID = &s + } + items = append(items, item) + } + + writeJSON(w, http.StatusOK, listSCIMGroupsResponse{Items: items}) +} + +// patchSCIMGroupMappingHandler handles PATCH /api/v1/orgs/{org_id}/sso/scim/groups/{id}/mapping. +func (srv *Server) patchSCIMGroupMappingHandler(w http.ResponseWriter, r *http.Request) { + orgID, ok := r.Context().Value(ctxOrgID).(uuid.UUID) + if !ok { + writeProblem(w, http.StatusBadRequest, "bad request") + return + } + if !requireEnterpriseTier(w, r) { + return + } + + groupID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + writeProblem(w, http.StatusBadRequest, "invalid group id") + return + } + + // Get the SCIM group and verify it belongs to this org. + scimGroup, err := srv.store.GetSCIMGroup(r.Context(), orgID, groupID) + if err != nil { + slog.ErrorContext(r.Context(), "scim group mapping patch: get group", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + if scimGroup == nil { + writeProblem(w, http.StatusNotFound, "SCIM group not found") + return + } + + var req patchSCIMGroupMappingBody + if errDetail := decodeJSON(r, &req); errDetail != nil { + writeProblemWithErrors(w, http.StatusBadRequest, "invalid request body", errDetail) + return + } + + // Track whether each field was sent in the request (even if to clear). + roleSent := req.MappedRole != nil + groupIDSent := req.MappedGroupID != nil + + // Validate mapped_role if provided. + if roleSent { + role := strings.TrimSpace(*req.MappedRole) + if role != "" && role != "viewer" && role != "member" && role != "admin" { + writeProblem(w, http.StatusBadRequest, "mapped_role must be 'viewer', 'member', or 'admin'") + return + } + if role == "" { + req.MappedRole = nil // clear the mapping + } else { + req.MappedRole = &role + } + } + + // Validate mapped_group_id if provided: must be same org and active. + if groupIDSent { + group, err := srv.store.GetGroupIfActive(r.Context(), orgID, *req.MappedGroupID) + if err != nil { + slog.ErrorContext(r.Context(), "scim group mapping patch: get notification group", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + if group == nil { + writeProblem(w, http.StatusBadRequest, "notification group not found or deleted") + return + } + } + + // Capture old mapping for comparison. + oldMappedRole := scimGroup.MappedRole + oldMappedGroupID := scimGroup.MappedGroupID + + // Build the final values: use new value if sent, else keep current. + // req.MappedRole == nil after validation means "clear" if roleSent is true. + var mappedRolePtr *string + if roleSent { + mappedRolePtr = req.MappedRole // nil means clear, non-nil means set + } else if scimGroup.MappedRole.Valid { + mappedRolePtr = &scimGroup.MappedRole.String + } + + var mappedGroupIDPtr *uuid.UUID + if groupIDSent { + mappedGroupIDPtr = req.MappedGroupID + } else if scimGroup.MappedGroupID.Valid { + mappedGroupIDPtr = &scimGroup.MappedGroupID.UUID + } + + if err := srv.store.UpdateSCIMGroupMapping(r.Context(), orgID, groupID, mappedRolePtr, mappedGroupIDPtr); err != nil { + slog.ErrorContext(r.Context(), "scim group mapping patch: update", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + // Get SCIM config for default role (needed for role recomputation). + scimCfg, err := srv.store.GetSCIMConfig(r.Context(), orgID) + if err != nil { + slog.ErrorContext(r.Context(), "scim group mapping patch: get scim config", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + defaultRole := "viewer" + if scimCfg != nil { + defaultRole = scimCfg.DefaultRole + } + + // Apply immediate effects to all current members. + members, err := srv.store.ListSCIMGroupMembers(r.Context(), orgID, groupID) + if err != nil { + slog.ErrorContext(r.Context(), "scim group mapping patch: list members", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + roleChanged := roleSent && ptrStringDiffers(mappedRolePtr, oldMappedRole) + groupIDChanged := groupIDSent && ptrUUIDDiffers(mappedGroupIDPtr, oldMappedGroupID) + + for _, userID := range members { + // Role recomputation (if mapped_role changed). + if roleChanged { + if err := srv.recomputeSCIMRole(r.Context(), orgID, userID, defaultRole); err != nil { + slog.ErrorContext(r.Context(), "scim group mapping: role recomputation failed", + "user_id", userID, "error", err) + } + } + + // Notification group sync (if mapped_group_id changed). + if groupIDChanged { + // Remove from old notification group if applicable. + if oldMappedGroupID.Valid { + if err := srv.syncNotifGroupRemove(r.Context(), orgID, userID, oldMappedGroupID.UUID, groupID); err != nil { + slog.ErrorContext(r.Context(), "scim group mapping: notification group remove failed", + "user_id", userID, "error", err) + } + } + // Add to new notification group if applicable. + if mappedGroupIDPtr != nil { + if err := srv.syncNotifGroupAdd(r.Context(), orgID, userID, *mappedGroupIDPtr, groupID); err != nil { + slog.ErrorContext(r.Context(), "scim group mapping: notification group add failed", + "user_id", userID, "error", err) + } + } + } + } + + srv.auditLog(r, audit.Entry{ //nolint:exhaustruct // optional fields + OrgID: orgID, + Action: "update", + EntityType: "scim_group", + EntityID: groupID.String(), + EntityName: scimGroup.DisplayName, + Success: true, + }) + + // Re-read the group to get updated state. + updated, err := srv.store.GetSCIMGroup(r.Context(), orgID, groupID) + if err != nil || updated == nil { + slog.ErrorContext(r.Context(), "scim group mapping patch: re-read", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + + resp := scimGroupResponse{ + ID: updated.ID.String(), + DisplayName: updated.DisplayName, + CreatedAt: updated.CreatedAt.Format(time.RFC3339), + UpdatedAt: updated.UpdatedAt.Format(time.RFC3339), + } + if updated.ExternalID.Valid { + resp.ExternalID = &updated.ExternalID.String + } + if updated.MappedRole.Valid { + resp.MappedRole = &updated.MappedRole.String + } + if updated.MappedGroupID.Valid { + s := updated.MappedGroupID.UUID.String() + resp.MappedGroupID = &s + } + + writeJSON(w, http.StatusOK, resp) +} + +// ptrStringDiffers returns true if a *string value differs from a sql.NullString. +// nil means "no value" (cleared); NullString.Valid==false also means no value. +func ptrStringDiffers(ptr *string, ns sql.NullString) bool { + if ptr == nil { + return ns.Valid // was set, now cleared + } + if !ns.Valid { + return true // was null, now set + } + return *ptr != ns.String +} + +// ptrUUIDDiffers returns true if a *uuid.UUID value differs from a uuid.NullUUID. +func ptrUUIDDiffers(ptr *uuid.UUID, nu uuid.NullUUID) bool { + if ptr == nil { + return nu.Valid + } + if !nu.Valid { + return true + } + return *ptr != nu.UUID +} diff --git a/internal/api/scim_admin_test.go b/internal/api/scim_admin_test.go new file mode 100644 index 00000000..45ad4be1 --- /dev/null +++ b/internal/api/scim_admin_test.go @@ -0,0 +1,851 @@ +// ABOUTME: Integration tests for SCIM admin endpoints (config CRUD, token rotation, group mapping). +// ABOUTME: Uses standard auth (cookie-based), enterprise tier gating, and owner/admin RBAC. +package api + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/auth" + "github.com/scarson/cvert-ops/internal/config" + "github.com/scarson/cvert-ops/internal/secure" + "github.com/scarson/cvert-ops/internal/testutil" +) + +// ── SCIM admin test helpers ───────────────────────────────────────────────── + +// scimAdminEnv holds the test environment for SCIM admin tests. +type scimAdminEnv struct { + srv *Server + ts *httptest.Server + db *testutil.TestDB + ew *secure.EventWriter + orgID uuid.UUID + token string // owner access token +} + +func newSCIMAdminEnv(t *testing.T) *scimAdminEnv { + t.Helper() + db := testutil.NewTestDB(t) + ctx := context.Background() + + cfg := &config.Config{ //nolint:exhaustruct,gosec // test: only relevant fields; G101 false positive + JWTSecret: "scim-admin-test-secret-32-bytes!", + RegistrationMode: "open", + Argon2MaxConcurrent: 5, + MFAPendingTokenTTL: 5 * time.Minute, + SSOEncryptionKey: hex.EncodeToString([]byte("12345678901234567890123456789012")), + } + ew := secure.NewEventWriter(db.Store) + srv, err := NewServer(db.Store, cfg, ServerDeps{EventWriter: ew}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + t.Cleanup(srv.Close) + + // Register owner and set enterprise tier. + reg := doRegister(t, ctx, ts, "scim-admin-owner@example.com", "test-password-1234") + loginResp := doLogin(t, ctx, ts, "scim-admin-owner@example.com", "test-password-1234") + defer loginResp.Body.Close() //nolint:errcheck,gosec // G104 + ownerToken := cookieValue(loginResp, "access_token") + + orgID := mustParseUUID(t, reg.OrgID) + if err := db.UpdateOrgTier(ctx, orgID, "enterprise"); err != nil { + t.Fatalf("update tier: %v", err) + } + srv.tierCache.Invalidate(orgID) + + return &scimAdminEnv{ + srv: srv, + ts: ts, + db: db, + ew: ew, + orgID: orgID, + token: ownerToken, + } +} + +// createSSOForSCIM creates an SSO connection prerequisite. +func (env *scimAdminEnv) createSSOForSCIM(t *testing.T) { + t.Helper() + body := `{"display_name":"SCIM Test IdP","issuer_url":"https://idp.test.com","client_id":"test-client","client_secret":"test-secret"}` + resp := doCreateSSO(t, context.Background(), env.ts, env.token, env.orgID.String(), body) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("create SSO: got %d, want 201. Body: %s", resp.StatusCode, b) + } +} + +func doSCIMConfigCreate(t *testing.T, ts *httptest.Server, token, orgID string) *http.Response { + t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, + ts.URL+"/api/v1/orgs/"+orgID+"/sso/scim", nil) + req.Header.Set("Cookie", "access_token="+token) + req.Header.Set("X-Requested-By", "CVErt-Ops") + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: ts.URL is httptest.Server + if err != nil { + t.Fatalf("SCIM config create: %v", err) + } + return resp +} + +func doSCIMConfigGet(t *testing.T, ts *httptest.Server, token, orgID string) *http.Response { + t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + ts.URL+"/api/v1/orgs/"+orgID+"/sso/scim", nil) + req.Header.Set("Cookie", "access_token="+token) + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: ts.URL is httptest.Server + if err != nil { + t.Fatalf("SCIM config get: %v", err) + } + return resp +} + +func doSCIMConfigPatch(t *testing.T, ts *httptest.Server, token, orgID, body string) *http.Response { + t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPatch, + ts.URL+"/api/v1/orgs/"+orgID+"/sso/scim", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "access_token="+token) + req.Header.Set("X-Requested-By", "CVErt-Ops") + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: ts.URL is httptest.Server + if err != nil { + t.Fatalf("SCIM config patch: %v", err) + } + return resp +} + +func doSCIMConfigDelete(t *testing.T, ts *httptest.Server, token, orgID string) *http.Response { + t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodDelete, + ts.URL+"/api/v1/orgs/"+orgID+"/sso/scim", nil) + req.Header.Set("Cookie", "access_token="+token) + req.Header.Set("X-Requested-By", "CVErt-Ops") + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: ts.URL is httptest.Server + if err != nil { + t.Fatalf("SCIM config delete: %v", err) + } + return resp +} + +func doSCIMTokenRotate(t *testing.T, ts *httptest.Server, token, orgID string) *http.Response { + t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, + ts.URL+"/api/v1/orgs/"+orgID+"/sso/scim/rotate-token", nil) + req.Header.Set("Cookie", "access_token="+token) + req.Header.Set("X-Requested-By", "CVErt-Ops") + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: ts.URL is httptest.Server + if err != nil { + t.Fatalf("SCIM token rotate: %v", err) + } + return resp +} + +func doSCIMGroupsList(t *testing.T, ts *httptest.Server, token, orgID string) *http.Response { + t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + ts.URL+"/api/v1/orgs/"+orgID+"/sso/scim/groups", nil) + req.Header.Set("Cookie", "access_token="+token) + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: ts.URL is httptest.Server + if err != nil { + t.Fatalf("SCIM groups list: %v", err) + } + return resp +} + +func doSCIMGroupMappingPatch(t *testing.T, ts *httptest.Server, token, orgID, groupID, body string) *http.Response { + t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPatch, + ts.URL+"/api/v1/orgs/"+orgID+"/sso/scim/groups/"+groupID+"/mapping", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "access_token="+token) + req.Header.Set("X-Requested-By", "CVErt-Ops") + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: ts.URL is httptest.Server + if err != nil { + t.Fatalf("SCIM group mapping patch: %v", err) + } + return resp +} + +func readBody(t *testing.T, resp *http.Response) map[string]any { + t.Helper() + var out map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + return out +} + +// ── Config CRUD tests ─────────────────────────────────────────────────────── + +func TestSCIMConfig_Create(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + env.createSSOForSCIM(t) + + resp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("create: got %d, want 201. Body: %s", resp.StatusCode, b) + } + + body := readBody(t, resp) + if body["id"] == nil || body["id"] == "" { + t.Error("id should be set") + } + if body["org_id"] != env.orgID.String() { + t.Errorf("org_id = %v, want %s", body["org_id"], env.orgID) + } + if body["enabled"] != false { + t.Errorf("enabled = %v, want false", body["enabled"]) + } + if body["default_role"] != "viewer" { + t.Errorf("default_role = %v, want viewer", body["default_role"]) + } + if body["token"] == nil || body["token"] == "" { + t.Error("token should be returned on create") + } + if body["token_prefix"] == nil || body["token_prefix"] == "" { + t.Error("token_prefix should be set") + } + + // Verify the security event was emitted. + events := flushAndQueryEvents(t, env.ew, env.db, secure.EventSCIMTokenCreated) + if len(events) == 0 { + t.Error("expected EventSCIMTokenCreated security event") + } +} + +func TestSCIMConfig_Create_RequiresSSO(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + // No SSO connection created. + + resp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusBadRequest { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("create without SSO: got %d, want 400. Body: %s", resp.StatusCode, b) + } +} + +func TestSCIMConfig_Create_EnterpriseOnly(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + cfg := &config.Config{ //nolint:exhaustruct,gosec // test: only relevant fields; G101 false positive + JWTSecret: "scim-tier-test-secret-32-bytes!!", + RegistrationMode: "open", + Argon2MaxConcurrent: 5, + MFAPendingTokenTTL: 5 * time.Minute, + SSOEncryptionKey: hex.EncodeToString([]byte("12345678901234567890123456789012")), + } + srv, err := NewServer(db.Store, cfg, ServerDeps{}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + t.Cleanup(srv.Close) + + reg := doRegister(t, ctx, ts, "scim-free-tier@example.com", "test-password-1234") + loginResp := doLogin(t, ctx, ts, "scim-free-tier@example.com", "test-password-1234") + defer loginResp.Body.Close() //nolint:errcheck,gosec // G104 + token := cookieValue(loginResp, "access_token") + + // Org defaults to "free" tier — SCIM should be blocked. + resp := doSCIMConfigCreate(t, ts, token, reg.OrgID) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusForbidden { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("free tier create: got %d, want 403. Body: %s", resp.StatusCode, b) + } +} + +func TestSCIMConfig_Create_Duplicate(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + env.createSSOForSCIM(t) + + // First create. + resp1 := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer resp1.Body.Close() //nolint:errcheck,gosec // G104 + if resp1.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp1.Body) + t.Fatalf("first create: got %d, want 201. Body: %s", resp1.StatusCode, b) + } + + // Second create → 409. + resp2 := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer resp2.Body.Close() //nolint:errcheck,gosec // G104 + if resp2.StatusCode != http.StatusConflict { + b, _ := io.ReadAll(resp2.Body) + t.Fatalf("duplicate create: got %d, want 409. Body: %s", resp2.StatusCode, b) + } +} + +func TestSCIMConfig_Get_TokenMasked(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + env.createSSOForSCIM(t) + + // Create config. + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create: got %d. Body: %s", createResp.StatusCode, b) + } + + // GET should not return token. + getResp := doSCIMConfigGet(t, env.ts, env.token, env.orgID.String()) + defer getResp.Body.Close() //nolint:errcheck,gosec // G104 + if getResp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(getResp.Body) + t.Fatalf("get: got %d. Body: %s", getResp.StatusCode, b) + } + body := readBody(t, getResp) + if body["token"] != nil { + t.Errorf("GET should not return token, got: %v", body["token"]) + } + if body["token_prefix"] == nil || body["token_prefix"] == "" { + t.Error("GET should return token_prefix") + } + if body["updated_at"] == nil || body["updated_at"] == "" { + t.Error("GET should return updated_at") + } +} + +func TestSCIMConfig_Enable_Disable(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + env.createSSOForSCIM(t) + + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create: got %d. Body: %s", createResp.StatusCode, b) + } + + // Enable. + resp1 := doSCIMConfigPatch(t, env.ts, env.token, env.orgID.String(), `{"enabled":true}`) + defer resp1.Body.Close() //nolint:errcheck,gosec // G104 + if resp1.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp1.Body) + t.Fatalf("patch enable: got %d. Body: %s", resp1.StatusCode, b) + } + body := readBody(t, resp1) + if body["enabled"] != true { + t.Errorf("after enable: enabled = %v, want true", body["enabled"]) + } + + // Disable. + resp2 := doSCIMConfigPatch(t, env.ts, env.token, env.orgID.String(), `{"enabled":false}`) + defer resp2.Body.Close() //nolint:errcheck,gosec // G104 + if resp2.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp2.Body) + t.Fatalf("patch disable: got %d. Body: %s", resp2.StatusCode, b) + } + body2 := readBody(t, resp2) + if body2["enabled"] != false { + t.Errorf("after disable: enabled = %v, want false", body2["enabled"]) + } + + // Update default_role. + resp3 := doSCIMConfigPatch(t, env.ts, env.token, env.orgID.String(), `{"default_role":"member"}`) + defer resp3.Body.Close() //nolint:errcheck,gosec // G104 + if resp3.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp3.Body) + t.Fatalf("patch default_role: got %d. Body: %s", resp3.StatusCode, b) + } + body3 := readBody(t, resp3) + if body3["default_role"] != "member" { + t.Errorf("default_role = %v, want member", body3["default_role"]) + } + + // Invalid default_role. + resp4 := doSCIMConfigPatch(t, env.ts, env.token, env.orgID.String(), `{"default_role":"admin"}`) + defer resp4.Body.Close() //nolint:errcheck,gosec // G104 + if resp4.StatusCode != http.StatusBadRequest { + b, _ := io.ReadAll(resp4.Body) + t.Fatalf("patch invalid role: got %d, want 400. Body: %s", resp4.StatusCode, b) + } +} + +func TestSCIMConfig_Delete(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + env.createSSOForSCIM(t) + + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create: got %d. Body: %s", createResp.StatusCode, b) + } + + // Delete. + resp := doSCIMConfigDelete(t, env.ts, env.token, env.orgID.String()) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusNoContent { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("delete: got %d, want 204. Body: %s", resp.StatusCode, b) + } + + // GET after delete → 404. + getResp := doSCIMConfigGet(t, env.ts, env.token, env.orgID.String()) + defer getResp.Body.Close() //nolint:errcheck,gosec // G104 + if getResp.StatusCode != http.StatusNotFound { + t.Errorf("get after delete: got %d, want 404", getResp.StatusCode) + } + + // Idempotent delete → still 204. + resp2 := doSCIMConfigDelete(t, env.ts, env.token, env.orgID.String()) + defer resp2.Body.Close() //nolint:errcheck,gosec // G104 + if resp2.StatusCode != http.StatusNoContent { + t.Errorf("idempotent delete: got %d, want 204", resp2.StatusCode) + } +} + +func TestSCIMConfig_Delete_GroupsSurvive(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + ctx := context.Background() + env.createSSOForSCIM(t) + + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create: got %d. Body: %s", createResp.StatusCode, b) + } + + // Create a SCIM group directly via store. + _, err := env.db.CreateSCIMGroup(ctx, env.orgID, nil, "Engineering") + if err != nil { + t.Fatalf("create scim group: %v", err) + } + + // Delete SCIM config. + resp := doSCIMConfigDelete(t, env.ts, env.token, env.orgID.String()) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusNoContent { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("delete: got %d. Body: %s", resp.StatusCode, b) + } + + // SCIM groups should survive (they reference org directly, not config). + groups, err := env.db.ListSCIMGroups(ctx, env.orgID) + if err != nil { + t.Fatalf("list scim groups after delete: %v", err) + } + if len(groups) != 1 { + t.Errorf("expected 1 scim group to survive config delete, got %d", len(groups)) + } +} + +func TestSCIMConfig_RotateToken(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + env.createSSOForSCIM(t) + + // Create config. + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create: got %d. Body: %s", createResp.StatusCode, b) + } + createBody := readBody(t, createResp) + oldToken := createBody["token"].(string) + + // Rotate token. + rotateResp := doSCIMTokenRotate(t, env.ts, env.token, env.orgID.String()) + defer rotateResp.Body.Close() //nolint:errcheck,gosec // G104 + if rotateResp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(rotateResp.Body) + t.Fatalf("rotate: got %d, want 200. Body: %s", rotateResp.StatusCode, b) + } + rotateBody := readBody(t, rotateResp) + newToken := rotateBody["token"].(string) + newPrefix := rotateBody["token_prefix"].(string) + + if newToken == oldToken { + t.Error("new token should differ from old token") + } + if newPrefix == "" { + t.Error("new token_prefix should be set") + } + + // Verify old token hash no longer matches. + oldHash := auth.HashSCIMToken(oldToken) + cfg, err := env.db.LookupSCIMConfigByTokenHash(context.Background(), oldHash) + if err != nil { + t.Fatalf("lookup by old hash: %v", err) + } + if cfg != nil { + t.Error("old token hash should no longer resolve to a config") + } + + // Verify new token hash resolves. + newHash := auth.HashSCIMToken(newToken) + cfg, err = env.db.LookupSCIMConfigByTokenHash(context.Background(), newHash) + if err != nil { + t.Fatalf("lookup by new hash: %v", err) + } + if cfg == nil { + t.Error("new token hash should resolve to the config") + } + + // Verify security event. + events := flushAndQueryEvents(t, env.ew, env.db, secure.EventSCIMTokenRotated) + if len(events) == 0 { + t.Error("expected EventSCIMTokenRotated security event") + } +} + +func TestSCIMConfig_RBAC(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + ctx := context.Background() + env.createSSOForSCIM(t) + + // Create a viewer in the same org. + viewerReg := doRegister(t, ctx, env.ts, "scim-rbac-viewer@example.com", "test-password-1234") + viewerID := mustParseUUID(t, viewerReg.UserID) + if err := env.db.CreateOrgMember(ctx, env.orgID, viewerID, "viewer"); err != nil { + t.Fatalf("create viewer member: %v", err) + } + viewerLogin := doLogin(t, ctx, env.ts, "scim-rbac-viewer@example.com", "test-password-1234") + defer viewerLogin.Body.Close() //nolint:errcheck,gosec // G104 + viewerToken := cookieValue(viewerLogin, "access_token") + + // Create an admin in the same org. + adminReg := doRegister(t, ctx, env.ts, "scim-rbac-admin@example.com", "test-password-1234") + adminID := mustParseUUID(t, adminReg.UserID) + if err := env.db.CreateOrgMember(ctx, env.orgID, adminID, "admin"); err != nil { + t.Fatalf("create admin member: %v", err) + } + adminLogin := doLogin(t, ctx, env.ts, "scim-rbac-admin@example.com", "test-password-1234") + defer adminLogin.Body.Close() //nolint:errcheck,gosec // G104 + adminToken := cookieValue(adminLogin, "access_token") + + // Viewer cannot create SCIM config (owner-only). + resp := doSCIMConfigCreate(t, env.ts, viewerToken, env.orgID.String()) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusForbidden { + t.Errorf("viewer create: got %d, want 403", resp.StatusCode) + } + + // Admin cannot create SCIM config (owner-only). + resp2 := doSCIMConfigCreate(t, env.ts, adminToken, env.orgID.String()) + defer resp2.Body.Close() //nolint:errcheck,gosec // G104 + if resp2.StatusCode != http.StatusForbidden { + t.Errorf("admin create: got %d, want 403", resp2.StatusCode) + } + + // Owner creates config for further tests. + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("owner create: got %d. Body: %s", createResp.StatusCode, b) + } + + // Admin CAN get SCIM config. + getResp := doSCIMConfigGet(t, env.ts, adminToken, env.orgID.String()) + defer getResp.Body.Close() //nolint:errcheck,gosec // G104 + if getResp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(getResp.Body) + t.Errorf("admin get: got %d, want 200. Body: %s", getResp.StatusCode, b) + } + + // Viewer cannot get SCIM config (admin+ required). + getResp2 := doSCIMConfigGet(t, env.ts, viewerToken, env.orgID.String()) + defer getResp2.Body.Close() //nolint:errcheck,gosec // G104 + if getResp2.StatusCode != http.StatusForbidden { + t.Errorf("viewer get: got %d, want 403", getResp2.StatusCode) + } + + // Admin cannot patch (owner-only). + patchResp := doSCIMConfigPatch(t, env.ts, adminToken, env.orgID.String(), `{"enabled":true}`) + defer patchResp.Body.Close() //nolint:errcheck,gosec // G104 + if patchResp.StatusCode != http.StatusForbidden { + t.Errorf("admin patch: got %d, want 403", patchResp.StatusCode) + } + + // Admin cannot delete (owner-only). + delResp := doSCIMConfigDelete(t, env.ts, adminToken, env.orgID.String()) + defer delResp.Body.Close() //nolint:errcheck,gosec // G104 + if delResp.StatusCode != http.StatusForbidden { + t.Errorf("admin delete: got %d, want 403", delResp.StatusCode) + } + + // Admin cannot rotate token (owner-only). + rotResp := doSCIMTokenRotate(t, env.ts, adminToken, env.orgID.String()) + defer rotResp.Body.Close() //nolint:errcheck,gosec // G104 + if rotResp.StatusCode != http.StatusForbidden { + t.Errorf("admin rotate: got %d, want 403", rotResp.StatusCode) + } + + // Admin CAN list groups. + groupsResp := doSCIMGroupsList(t, env.ts, adminToken, env.orgID.String()) + defer groupsResp.Body.Close() //nolint:errcheck,gosec // G104 + if groupsResp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(groupsResp.Body) + t.Errorf("admin list groups: got %d, want 200. Body: %s", groupsResp.StatusCode, b) + } +} + +// ── Group Mapping tests ───────────────────────────────────────────────────── + +func TestGroupMapping_SetRole(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + ctx := context.Background() + env.createSSOForSCIM(t) + + // Create SCIM config. + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create config: got %d. Body: %s", createResp.StatusCode, b) + } + + // Create a SCIM group. + scimGroup, err := env.db.CreateSCIMGroup(ctx, env.orgID, nil, "Admins") + if err != nil { + t.Fatalf("create scim group: %v", err) + } + + // Create a member in the org for role recomputation testing. + memberReg := doRegister(t, ctx, env.ts, "scim-mapping-member@example.com", "test-password-1234") + memberID := mustParseUUID(t, memberReg.UserID) + if err := env.db.CreateOrgMember(ctx, env.orgID, memberID, "viewer"); err != nil { + t.Fatalf("create org member: %v", err) + } + + // Add member to the SCIM group. + if err := env.db.AddSCIMGroupMember(ctx, scimGroup.ID, memberID, env.orgID); err != nil { + t.Fatalf("add scim group member: %v", err) + } + + // Set mapped_role → "admin". + resp := doSCIMGroupMappingPatch(t, env.ts, env.token, env.orgID.String(), + scimGroup.ID.String(), `{"mapped_role":"admin"}`) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("set role mapping: got %d, want 200. Body: %s", resp.StatusCode, b) + } + + body := readBody(t, resp) + if body["mapped_role"] != "admin" { + t.Errorf("mapped_role = %v, want admin", body["mapped_role"]) + } + + // Verify the member's role was recomputed. + member, err := env.db.GetOrgMemberFull(ctx, env.orgID, memberID) + if err != nil { + t.Fatalf("get member: %v", err) + } + if member == nil { + t.Fatal("member should exist") + } + if member.Role != "admin" { + t.Errorf("member role = %q, want admin (recomputed from SCIM group mapping)", member.Role) + } +} + +func TestGroupMapping_SetNotificationGroup(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + ctx := context.Background() + env.createSSOForSCIM(t) + + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create config: got %d. Body: %s", createResp.StatusCode, b) + } + + // Create notification group. + notifGroup := env.db.MustCreateGroup(t, ctx, env.orgID, "Security Team", "Security notifications") + + // Create a SCIM group. + scimGroup, err := env.db.CreateSCIMGroup(ctx, env.orgID, nil, "Security") + if err != nil { + t.Fatalf("create scim group: %v", err) + } + + // Create a member and add to SCIM group. + memberReg := doRegister(t, ctx, env.ts, "scim-notif-member@example.com", "test-password-1234") + memberID := mustParseUUID(t, memberReg.UserID) + if err := env.db.CreateOrgMember(ctx, env.orgID, memberID, "viewer"); err != nil { + t.Fatalf("create org member: %v", err) + } + if err := env.db.AddSCIMGroupMember(ctx, scimGroup.ID, memberID, env.orgID); err != nil { + t.Fatalf("add scim group member: %v", err) + } + + // Set mapped_group_id. + body := `{"mapped_group_id":"` + notifGroup.ID.String() + `"}` + resp := doSCIMGroupMappingPatch(t, env.ts, env.token, env.orgID.String(), + scimGroup.ID.String(), body) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("set group mapping: got %d, want 200. Body: %s", resp.StatusCode, b) + } + + result := readBody(t, resp) + if result["mapped_group_id"] != notifGroup.ID.String() { + t.Errorf("mapped_group_id = %v, want %s", result["mapped_group_id"], notifGroup.ID) + } +} + +func TestGroupMapping_ClearMapping(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + ctx := context.Background() + env.createSSOForSCIM(t) + + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create config: got %d. Body: %s", createResp.StatusCode, b) + } + + // Create SCIM group with a mapped role. + scimGroup, err := env.db.CreateSCIMGroup(ctx, env.orgID, nil, "DevOps") + if err != nil { + t.Fatalf("create scim group: %v", err) + } + admin := "admin" + if err := env.db.UpdateSCIMGroupMapping(ctx, env.orgID, scimGroup.ID, &admin, nil); err != nil { + t.Fatalf("set initial mapping: %v", err) + } + + // Create member in SCIM group. + memberReg := doRegister(t, ctx, env.ts, "scim-clear-member@example.com", "test-password-1234") + memberID := mustParseUUID(t, memberReg.UserID) + if err := env.db.CreateOrgMember(ctx, env.orgID, memberID, "admin"); err != nil { + t.Fatalf("create org member: %v", err) + } + if err := env.db.AddSCIMGroupMember(ctx, scimGroup.ID, memberID, env.orgID); err != nil { + t.Fatalf("add scim group member: %v", err) + } + + // Clear mapped_role by sending empty string. + resp := doSCIMGroupMappingPatch(t, env.ts, env.token, env.orgID.String(), + scimGroup.ID.String(), `{"mapped_role":""}`) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("clear mapping: got %d, want 200. Body: %s", resp.StatusCode, b) + } + + result := readBody(t, resp) + if result["mapped_role"] != nil { + t.Errorf("mapped_role should be null after clear, got %v", result["mapped_role"]) + } + + // Verify the member's role was recomputed to the default ("viewer"). + member, err := env.db.GetOrgMemberFull(ctx, env.orgID, memberID) + if err != nil { + t.Fatalf("get member: %v", err) + } + if member == nil { + t.Fatal("member should exist") + } + if member.Role != "viewer" { + t.Errorf("member role = %q, want viewer (recomputed to default after mapping cleared)", member.Role) + } +} + +func TestGroupMapping_CrossOrgGroupId(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + ctx := context.Background() + env.createSSOForSCIM(t) + + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create config: got %d. Body: %s", createResp.StatusCode, b) + } + + // Create a SCIM group in our org. + scimGroup, err := env.db.CreateSCIMGroup(ctx, env.orgID, nil, "CrossOrgTest") + if err != nil { + t.Fatalf("create scim group: %v", err) + } + + // Create a different org and a notification group in it. + otherOrg := env.db.MustCreateOrg(t, ctx, "Other Org") + otherGroup := env.db.MustCreateGroup(t, ctx, otherOrg.ID, "Other Org Group", "wrong org") + + // Try to set mapped_group_id to a group from a different org → 400. + body := `{"mapped_group_id":"` + otherGroup.ID.String() + `"}` + resp := doSCIMGroupMappingPatch(t, env.ts, env.token, env.orgID.String(), + scimGroup.ID.String(), body) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusBadRequest { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("cross-org group: got %d, want 400. Body: %s", resp.StatusCode, b) + } +} + +func TestGroupMapping_SoftDeletedGroupId(t *testing.T) { + t.Parallel() + env := newSCIMAdminEnv(t) + ctx := context.Background() + env.createSSOForSCIM(t) + + createResp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer createResp.Body.Close() //nolint:errcheck,gosec // G104 + if createResp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(createResp.Body) + t.Fatalf("create config: got %d. Body: %s", createResp.StatusCode, b) + } + + // Create and soft-delete a notification group. + notifGroup := env.db.MustCreateGroup(t, ctx, env.orgID, "Deleted Group", "will be deleted") + if err := env.db.SoftDeleteGroup(ctx, env.orgID, notifGroup.ID); err != nil { + t.Fatalf("soft delete group: %v", err) + } + + // Create a SCIM group. + scimGroup, err := env.db.CreateSCIMGroup(ctx, env.orgID, nil, "SoftDelTest") + if err != nil { + t.Fatalf("create scim group: %v", err) + } + + // Try to map to soft-deleted group → 400. + body := `{"mapped_group_id":"` + notifGroup.ID.String() + `"}` + resp := doSCIMGroupMappingPatch(t, env.ts, env.token, env.orgID.String(), + scimGroup.ID.String(), body) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusBadRequest { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("soft-deleted group: got %d, want 400. Body: %s", resp.StatusCode, b) + } +} diff --git a/internal/api/scim_audit_test.go b/internal/api/scim_audit_test.go new file mode 100644 index 00000000..4eb2cf2e --- /dev/null +++ b/internal/api/scim_audit_test.go @@ -0,0 +1,473 @@ +// ABOUTME: Tests verifying SCIM operations produce correct audit log entries and security events. +// ABOUTME: Covers user provision/deprovision audit, auth failure events, and token create events. +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/audit" + "github.com/scarson/cvert-ops/internal/auth" + "github.com/scarson/cvert-ops/internal/config" + "github.com/scarson/cvert-ops/internal/secure" + "github.com/scarson/cvert-ops/internal/testutil" +) + +// scimAuditTestEnv bundles a test server with both audit writer and event writer for assertions. +type scimAuditTestEnv struct { + ts *httptest.Server + srv *Server + db *testutil.TestDB + ew *secure.EventWriter + aw *audit.Writer + orgID uuid.UUID + scimConfigID uuid.UUID + rawToken string +} + +func newSCIMAuditTestEnv(t *testing.T) *scimAuditTestEnv { + t.Helper() + + db := testutil.NewTestDB(t) + ctx := context.Background() + + org, err := db.CreateOrg(ctx, "scim-audit-test-org") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + + ssoConn, err := db.CreateSSOConnection(ctx, org.ID, "Audit Test IdP", + "https://idp.example.com", "client-id", []byte("encrypted"), nil, true) + if err != nil { + t.Fatalf("CreateSSOConnection: %v", err) + } + + rawToken, tokenHash, tokenPrefix, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken: %v", err) + } + + scimCfg, err := db.CreateSCIMConfig(ctx, org.ID, ssoConn.ID, true, tokenHash, tokenPrefix, "viewer") + if err != nil { + t.Fatalf("CreateSCIMConfig: %v", err) + } + + ew := secure.NewEventWriter(db.Store) + aw := audit.NewWriter(db.Store, slog.Default()) + + cfg := &config.Config{ //nolint:exhaustruct,gosec // test: only relevant fields set; G101 false positive + JWTSecret: "scim-audit-test-secret-32bytes-m", + } + srv, err := NewServer(db.Store, cfg, ServerDeps{EventWriter: ew, AuditWriter: aw}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + t.Cleanup(srv.Close) + + r := chi.NewRouter() + r.Route("/api/v1/orgs/{org_id}/scim/v2", func(sub chi.Router) { + sub.Use(srv.requireSCIMAuth) + sub.Post("/Users", srv.scimCreateUser) + sub.Get("/Users", srv.scimListUsers) + sub.Get("/Users/{id}", srv.scimGetUser) + sub.Put("/Users/{id}", srv.scimReplaceUser) + sub.Patch("/Users/{id}", srv.scimPatchUser) + sub.Delete("/Users/{id}", srv.scimDeleteUser) + }) + + ts := httptest.NewServer(r) + t.Cleanup(ts.Close) + + return &scimAuditTestEnv{ + ts: ts, + srv: srv, + db: db, + ew: ew, + aw: aw, + orgID: org.ID, + scimConfigID: scimCfg.ID, + rawToken: rawToken, + } +} + +// scimAuditRequest makes an HTTP request to the SCIM endpoint. +func (env *scimAuditTestEnv) scimAuditRequest(t *testing.T, method, path string, body any) *http.Response { + t.Helper() + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + bodyReader = bytes.NewReader(b) + } + rawURL := fmt.Sprintf("%s/api/v1/orgs/%s/scim/v2%s", env.ts.URL, env.orgID, path) + req, err := http.NewRequestWithContext(context.Background(), method, rawURL, bodyReader) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+env.rawToken) + if body != nil { + req.Header.Set("Content-Type", "application/scim+json") + } + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + return resp +} + +// queryAuditEntries returns audit log entries matching the given entity type for the test org. +func (env *scimAuditTestEnv) queryAuditEntries(t *testing.T, entityType string) []auditRow { + t.Helper() + env.aw.Flush() + rows, err := env.db.Pool().Query(context.Background(), + "SELECT entity_type, action, entity_id, success, new_state, metadata FROM audit_log WHERE org_id = $1 AND entity_type = $2 ORDER BY created_at", + env.orgID, entityType) + if err != nil { + t.Fatalf("query audit_log: %v", err) + } + defer rows.Close() + var result []auditRow + for rows.Next() { + var r auditRow + var newState, metadata *[]byte + if err := rows.Scan(&r.EntityType, &r.Action, &r.EntityID, &r.Success, &newState, &metadata); err != nil { + t.Fatalf("scan audit_log: %v", err) + } + if newState != nil { + r.NewState = *newState + } + if metadata != nil { + r.Metadata = *metadata + } + result = append(result, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("audit_log rows: %v", err) + } + return result +} + +// auditRow is a simplified audit log row for test assertions. +type auditRow struct { + EntityType string + Action string + EntityID string + Success bool + NewState []byte + Metadata []byte +} + +func TestSCIMAudit_UserProvisionAuditEntry(t *testing.T) { + t.Parallel() + env := newSCIMAuditTestEnv(t) + + // POST /Users — create a brand new user. + body := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "userName": "provision-audit@example.com", + "externalId": "ext-provision-audit-001", + } + resp := env.scimAuditRequest(t, http.MethodPost, "/Users", body) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("POST /Users: got %d, want 201/200. Body: %s", resp.StatusCode, b) + } + + // Query audit log. + entries := env.queryAuditEntries(t, "member") + if len(entries) == 0 { + t.Fatal("expected at least one audit entry for entity_type=member, got 0") + } + + // Find the create entry. + var found bool + for _, e := range entries { + if e.Action == "create" { + found = true + if !e.Success { + t.Error("audit entry Success should be true") + } + // Check metadata contains source=scim. + if e.Metadata != nil { + var meta map[string]any + if err := json.Unmarshal(e.Metadata, &meta); err == nil { + if meta["source"] != "scim" { + t.Errorf("metadata.source = %v, want scim", meta["source"]) + } + if meta["scim_config_id"] != env.scimConfigID.String() { + t.Errorf("metadata.scim_config_id = %v, want %s", meta["scim_config_id"], env.scimConfigID) + } + } + } else { + t.Error("audit entry metadata should not be nil") + } + } + } + if !found { + t.Error("expected audit entry with action=create for user provision") + } +} + +func TestSCIMAudit_UserDeprovisionAuditEntry(t *testing.T) { + t.Parallel() + env := newSCIMAuditTestEnv(t) + ctx := context.Background() + + // Create a user and make them an org member (not the owner). + user := env.db.MustCreateUser(t, ctx, "deprovision-audit@example.com", "Deprovisioner", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + // DELETE /Users/{id} — deactivate. + resp := env.scimAuditRequest(t, http.MethodDelete, "/Users/"+user.ID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusNoContent { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("DELETE /Users: got %d, want 204. Body: %s", resp.StatusCode, b) + } + + // Query audit log. + entries := env.queryAuditEntries(t, "member") + if len(entries) == 0 { + t.Fatal("expected at least one audit entry for entity_type=member, got 0") + } + + // Find the update entry for deactivation. + var found bool + for _, e := range entries { + if e.Action == "update" && e.EntityID == user.ID.String() { + found = true + if !e.Success { + t.Error("audit entry Success should be true") + } + // Check new_state has active=false. + if e.NewState != nil { + var ns map[string]any + if err := json.Unmarshal(e.NewState, &ns); err == nil { + if ns["active"] != false { + t.Errorf("new_state.active = %v, want false", ns["active"]) + } + } + } + // Check metadata has source=scim. + if e.Metadata != nil { + var meta map[string]any + if err := json.Unmarshal(e.Metadata, &meta); err == nil { + if meta["source"] != "scim" { + t.Errorf("metadata.source = %v, want scim", meta["source"]) + } + } + } + } + } + if !found { + t.Error("expected audit entry with action=update for user deactivation") + } +} + +func TestSCIMAudit_AuthFailureSecurityEvent(t *testing.T) { + t.Parallel() + env := newSCIMAuditTestEnv(t) + + // Make a request with a wrong token. + wrongToken, _, _, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken: %v", err) + } + + rawURL := fmt.Sprintf("%s/api/v1/orgs/%s/scim/v2/Users", env.ts.URL, env.orgID) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + req.Header.Set("Authorization", "Bearer "+wrongToken) + + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } + + // Verify security event was emitted. + events := flushAndQueryEvents(t, env.ew, env.db, secure.EventSCIMAuthFailed) + if len(events) == 0 { + t.Error("expected at least one scim.auth_failed security event, got 0") + } + // Verify the event has the correct org_id. + for _, ev := range events { + if orgID, ok := ev["org_id"].(*uuid.UUID); ok && orgID != nil { + if *orgID != env.orgID { + t.Errorf("security event org_id = %v, want %v", *orgID, env.orgID) + } + } + } +} + +func TestSCIMAudit_TokenCreateSecurityEvent(t *testing.T) { + t.Parallel() + // This test uses the scimAdminEnv because token creation goes through the admin endpoint. + env := newSCIMAdminEnv(t) + env.createSSOForSCIM(t) + + resp := doSCIMConfigCreate(t, env.ts, env.token, env.orgID.String()) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("create config: got %d, want 201. Body: %s", resp.StatusCode, b) + } + + // Verify security event was emitted. + events := flushAndQueryEvents(t, env.ew, env.db, secure.EventSCIMTokenCreated) + if len(events) == 0 { + t.Error("expected at least one scim.token_created security event, got 0") + } + // Verify event type value. + for _, ev := range events { + if ev["event_type"] != secure.EventSCIMTokenCreated { + t.Errorf("event_type = %v, want %v", ev["event_type"], secure.EventSCIMTokenCreated) + } + } +} + +func TestSCIMAudit_UserProvisionedSecurityEvent(t *testing.T) { + t.Parallel() + env := newSCIMAuditTestEnv(t) + + // POST /Users — create a brand new user. + body := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "userName": "provisioned-event@example.com", + "externalId": "ext-provisioned-event-001", + } + resp := env.scimAuditRequest(t, http.MethodPost, "/Users", body) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("POST /Users: got %d, want 201/200. Body: %s", resp.StatusCode, b) + } + + // Verify security event. + events := flushAndQueryEvents(t, env.ew, env.db, secure.EventSCIMUserProvisioned) + if len(events) == 0 { + t.Error("expected at least one scim.user_provisioned security event, got 0") + } +} + +func TestSCIMAudit_UserDeprovisionedSecurityEvent(t *testing.T) { + t.Parallel() + env := newSCIMAuditTestEnv(t) + ctx := context.Background() + + // Create a user and org membership. + user := env.db.MustCreateUser(t, ctx, "deprovisioned-event@example.com", "Deprovisioner", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + // DELETE /Users/{id} — deactivate. + resp := env.scimAuditRequest(t, http.MethodDelete, "/Users/"+user.ID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusNoContent { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("DELETE /Users: got %d, want 204. Body: %s", resp.StatusCode, b) + } + + // Verify security event. + events := flushAndQueryEvents(t, env.ew, env.db, secure.EventSCIMUserDeprovisioned) + if len(events) == 0 { + t.Error("expected at least one scim.user_deprovisioned security event, got 0") + } +} + +func TestSCIMAudit_ExemptSuppressedSecurityEvent(t *testing.T) { + t.Parallel() + env := newSCIMAuditTestEnv(t) + ctx := context.Background() + + // Create a user, make them an org member, then mark scim_exempt. + user := env.db.MustCreateUser(t, ctx, "exempt-event@example.com", "Exempt User", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + if err := env.db.UpdateOrgMemberSCIMExempt(ctx, env.orgID, user.ID, true); err != nil { + t.Fatalf("UpdateOrgMemberSCIMExempt: %v", err) + } + + // PATCH /Users/{id} — try to modify exempt user. + patchBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + "Operations": []map[string]any{ + {"op": "replace", "path": "displayName", "value": "Should Not Change"}, + }, + } + resp := env.scimAuditRequest(t, http.MethodPatch, "/Users/"+user.ID.String(), patchBody) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("PATCH exempt user: got %d, want 200. Body: %s", resp.StatusCode, b) + } + + // Verify security event. + events := flushAndQueryEvents(t, env.ew, env.db, secure.EventSCIMExemptSuppressed) + if len(events) == 0 { + t.Error("expected at least one scim.exempt_suppressed security event, got 0") + } + + // Verify audit entry has suppressed metadata. + entries := env.queryAuditEntries(t, "member") + var foundSuppressed bool + for _, e := range entries { + if e.Metadata != nil { + var meta map[string]any + if err := json.Unmarshal(e.Metadata, &meta); err == nil { + if meta["suppressed"] == true && meta["reason"] == "scim_exempt" { + foundSuppressed = true + } + } + } + } + if !foundSuppressed { + t.Error("expected audit entry with metadata.suppressed=true and metadata.reason=scim_exempt") + } +} + +func TestSCIMAudit_SoleOwnerProtectedSecurityEvent(t *testing.T) { + t.Parallel() + env := newSCIMAuditTestEnv(t) + ctx := context.Background() + + // Create a user, make them the sole owner of the org. + user := env.db.MustCreateUser(t, ctx, "sole-owner-event@example.com", "Sole Owner", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "owner"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + // DELETE /Users/{id} — try to deactivate sole owner. + resp := env.scimAuditRequest(t, http.MethodDelete, "/Users/"+user.ID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusBadRequest { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("DELETE sole owner: got %d, want 400. Body: %s", resp.StatusCode, b) + } + + // Verify security event. + events := flushAndQueryEvents(t, env.ew, env.db, secure.EventSCIMSoleOwnerProtected) + if len(events) == 0 { + t.Error("expected at least one scim.sole_owner_protected security event, got 0") + } +} diff --git a/internal/api/scim_discovery.go b/internal/api/scim_discovery.go new file mode 100644 index 00000000..17df40dd --- /dev/null +++ b/internal/api/scim_discovery.go @@ -0,0 +1,94 @@ +// ABOUTME: SCIM 2.0 discovery endpoints (ServiceProviderConfig, Schemas, ResourceTypes). +// ABOUTME: Static JSON responses per RFC 7643/7644. Declares supported capabilities to IdPs. +package api + +import "net/http" + +// scimServiceProviderConfig returns SCIM capabilities (RFC 7644 §4). +func (srv *Server) scimServiceProviderConfig(w http.ResponseWriter, _ *http.Request) { + writeSCIMJSON(w, http.StatusOK, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"}, + "patch": map[string]any{"supported": true}, + "bulk": map[string]any{"supported": false, "maxOperations": 0, "maxPayloadSize": 0}, + "filter": map[string]any{"supported": true, "maxResults": 200}, + "changePassword": map[string]any{"supported": false}, + "sort": map[string]any{"supported": false}, + "etag": map[string]any{"supported": false}, + "authenticationSchemes": []map[string]any{ + { + "type": "oauthbearertoken", + "name": "Bearer Token", + "description": "Authentication via org-scoped SCIM bearer token", + }, + }, + }) +} + +// scimSchemas returns User and Group schema definitions (RFC 7643 §7). +func (srv *Server) scimSchemas(w http.ResponseWriter, _ *http.Request) { + userSchema := map[string]any{ + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User Account", + "attributes": []map[string]any{ + {"name": "userName", "type": "string", "multiValued": false, "required": true, "mutability": "readWrite", "uniqueness": "server"}, + {"name": "displayName", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite"}, + {"name": "active", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite"}, + {"name": "externalId", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite"}, + }, + "meta": map[string]any{ + "resourceType": "Schema", + "location": "/Schemas/urn:ietf:params:scim:schemas:core:2.0:User", + }, + } + groupSchema := map[string]any{ + "id": "urn:ietf:params:scim:schemas:core:2.0:Group", + "name": "Group", + "description": "Group", + "attributes": []map[string]any{ + {"name": "displayName", "type": "string", "multiValued": false, "required": true, "mutability": "readWrite"}, + {"name": "externalId", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite"}, + {"name": "members", "type": "complex", "multiValued": true, "required": false, "mutability": "readWrite", + "subAttributes": []map[string]any{ + {"name": "value", "type": "string", "mutability": "immutable"}, + {"name": "display", "type": "string", "mutability": "readOnly"}, + }, + }, + }, + "meta": map[string]any{ + "resourceType": "Schema", + "location": "/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group", + }, + } + writeSCIMJSON(w, http.StatusOK, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + "totalResults": 2, + "Resources": []any{userSchema, groupSchema}, + }) +} + +// scimResourceTypes returns metadata for User and Group resources (RFC 7643 §6). +func (srv *Server) scimResourceTypes(w http.ResponseWriter, _ *http.Request) { + writeSCIMJSON(w, http.StatusOK, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + "totalResults": 2, + "Resources": []any{ + map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + "id": "User", + "name": "User", + "endpoint": "/Users", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "meta": map[string]any{"resourceType": "ResourceType", "location": "/ResourceTypes/User"}, + }, + map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + "id": "Group", + "name": "Group", + "endpoint": "/Groups", + "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", + "meta": map[string]any{"resourceType": "ResourceType", "location": "/ResourceTypes/Group"}, + }, + }, + }) +} diff --git a/internal/api/scim_e2e_test.go b/internal/api/scim_e2e_test.go new file mode 100644 index 00000000..9679c2b0 --- /dev/null +++ b/internal/api/scim_e2e_test.go @@ -0,0 +1,715 @@ +// ABOUTME: End-to-end SCIM integration tests simulating real IdP provisioning workflows. +// ABOUTME: Multi-step sequences through the full HTTP stack (SCIM + admin endpoints). +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/auth" + "github.com/scarson/cvert-ops/internal/config" + "github.com/scarson/cvert-ops/internal/secure" + "github.com/scarson/cvert-ops/internal/testutil" +) + +// scimE2EEnv bundles a test server with both SCIM and admin routes for e2e tests. +type scimE2EEnv struct { + ts *httptest.Server + srv *Server + db *testutil.TestDB + orgID uuid.UUID + scimConfigID uuid.UUID + rawToken string +} + +// newSCIME2EEnv creates a SCIM e2e test environment with only SCIM handlers mounted. +func newSCIME2EEnv(t *testing.T) *scimE2EEnv { + t.Helper() + + db := testutil.NewTestDB(t) + ctx := context.Background() + + org, err := db.CreateOrg(ctx, "scim-e2e-"+uuid.New().String()[:8]) + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + + ssoConn, err := db.CreateSSOConnection(ctx, org.ID, "E2E IdP", + "https://idp.example.com", "client-id", []byte("encrypted"), nil, true) + if err != nil { + t.Fatalf("CreateSSOConnection: %v", err) + } + + rawToken, tokenHash, tokenPrefix, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken: %v", err) + } + + scimCfg, err := db.CreateSCIMConfig(ctx, org.ID, ssoConn.ID, true, tokenHash, tokenPrefix, "viewer") + if err != nil { + t.Fatalf("CreateSCIMConfig: %v", err) + } + + ew := secure.NewEventWriter(db.Store) + cfg := &config.Config{ //nolint:exhaustruct,gosec // test: only relevant fields; G101 false positive + JWTSecret: "scim-e2e-test-secret-32-bytes-min", + } + srv, err := NewServer(db.Store, cfg, ServerDeps{EventWriter: ew}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + t.Cleanup(srv.Close) + + r := chi.NewRouter() + r.Route("/api/v1/orgs/{org_id}/scim/v2", func(sub chi.Router) { + sub.Use(srv.requireSCIMAuth) + // Users + sub.Post("/Users", srv.scimCreateUser) + sub.Get("/Users", srv.scimListUsers) + sub.Get("/Users/{id}", srv.scimGetUser) + sub.Put("/Users/{id}", srv.scimReplaceUser) + sub.Patch("/Users/{id}", srv.scimPatchUser) + sub.Delete("/Users/{id}", srv.scimDeleteUser) + // Groups + sub.Post("/Groups", srv.scimCreateGroup) + sub.Get("/Groups", srv.scimListGroups) + sub.Get("/Groups/{id}", srv.scimGetGroup) + sub.Put("/Groups/{id}", srv.scimReplaceGroup) + sub.Patch("/Groups/{id}", srv.scimPatchGroup) + sub.Delete("/Groups/{id}", srv.scimDeleteGroup) + }) + + ts := httptest.NewServer(r) + t.Cleanup(ts.Close) + + return &scimE2EEnv{ + ts: ts, + srv: srv, + db: db, + orgID: org.ID, + scimConfigID: scimCfg.ID, + rawToken: rawToken, + } +} + +// scimReq makes an authenticated SCIM request and returns the response. +func (env *scimE2EEnv) scimReq(t *testing.T, method, path string, body any) *http.Response { + t.Helper() + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + bodyReader = bytes.NewReader(b) + } + rawURL := fmt.Sprintf("%s/api/v1/orgs/%s/scim/v2%s", env.ts.URL, env.orgID, path) + req, err := http.NewRequestWithContext(context.Background(), method, rawURL, bodyReader) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+env.rawToken) + if body != nil { + req.Header.Set("Content-Type", "application/scim+json") + } + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704: httptest URL + if err != nil { + t.Fatalf("request %s %s: %v", method, path, err) + } + return resp +} + +// scimReqWithToken makes a SCIM request with a specific bearer token. +func (env *scimE2EEnv) scimReqWithToken(t *testing.T, method, path, token string) *http.Response { + t.Helper() + rawURL := fmt.Sprintf("%s/api/v1/orgs/%s/scim/v2%s", env.ts.URL, env.orgID, path) + req, err := http.NewRequestWithContext(context.Background(), method, rawURL, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + return resp +} + +// readSCIMBody reads and parses a JSON response body. +func readSCIMBody(t *testing.T, resp *http.Response) map[string]any { + t.Helper() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decode body: %v (raw: %s)", err, string(body)) + } + return out +} + +// ── Test 1: Full Provisioning Lifecycle ───────────────────────────────────────── + +func TestSCIME2E_FullProvisioningLifecycle(t *testing.T) { + t.Parallel() + env := newSCIME2EEnv(t) + + // Step 1: Provision user via POST /Users. + createBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "lifecycle-ext-001", + "userName": "lifecycle@example.com", + "displayName": "Lifecycle User", + "active": true, + } + + resp := env.scimReq(t, http.MethodPost, "/Users", createBody) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("POST /Users: got %d, want 201 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) + + user := decodeSCIMUser(t, resp) + if user.ID == "" { + t.Fatal("user ID is empty") + } + userID := user.ID + if user.UserName != "lifecycle@example.com" { + t.Errorf("userName = %q, want %q", user.UserName, "lifecycle@example.com") + } + if !user.Active { + t.Error("active = false, want true") + } + + // Step 2: Verify user exists via GET /Users/{id}. + resp2 := env.scimReq(t, http.MethodGet, "/Users/"+userID, nil) + defer resp2.Body.Close() //nolint:errcheck + if resp2.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp2.Body) //nolint:errcheck + t.Fatalf("GET /Users/{id}: got %d, want 200 (body: %s)", resp2.StatusCode, string(body)) + } + user2 := decodeSCIMUser(t, resp2) + if user2.UserName != "lifecycle@example.com" { + t.Errorf("GET user userName = %q, want %q", user2.UserName, "lifecycle@example.com") + } + + // Step 3: Deactivate via PATCH (active=false). + deactivatePatch := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + {Op: "replace", Path: "active", Value: json.RawMessage(`false`)}, + }, + } + + resp3 := env.scimReq(t, http.MethodPatch, "/Users/"+userID, deactivatePatch) + defer resp3.Body.Close() //nolint:errcheck + if resp3.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp3.Body) //nolint:errcheck + t.Fatalf("PATCH deactivate: got %d, want 200 (body: %s)", resp3.StatusCode, string(body)) + } + user3 := decodeSCIMUser(t, resp3) + if user3.Active { + t.Error("after deactivate: active = true, want false") + } + + // Step 4: Verify deactivated via GET. + resp4 := env.scimReq(t, http.MethodGet, "/Users/"+userID, nil) + defer resp4.Body.Close() //nolint:errcheck + if resp4.StatusCode != http.StatusOK { + t.Fatalf("GET after deactivate: got %d, want 200", resp4.StatusCode) + } + user4 := decodeSCIMUser(t, resp4) + if user4.Active { + t.Error("GET after deactivate: active = true, want false") + } + + // Step 5: Reactivate via PATCH (active=true). + reactivatePatch := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + {Op: "replace", Path: "active", Value: json.RawMessage(`true`)}, + }, + } + + resp5 := env.scimReq(t, http.MethodPatch, "/Users/"+userID, reactivatePatch) + defer resp5.Body.Close() //nolint:errcheck + if resp5.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp5.Body) //nolint:errcheck + t.Fatalf("PATCH reactivate: got %d, want 200 (body: %s)", resp5.StatusCode, string(body)) + } + user5 := decodeSCIMUser(t, resp5) + if !user5.Active { + t.Error("after reactivate: active = false, want true") + } + + // Step 6: Verify active again via GET. + resp6 := env.scimReq(t, http.MethodGet, "/Users/"+userID, nil) + defer resp6.Body.Close() //nolint:errcheck + user6 := decodeSCIMUser(t, resp6) + if !user6.Active { + t.Error("GET after reactivate: active = false, want true") + } + + // Step 7: Deprovision via DELETE. + resp7 := env.scimReq(t, http.MethodDelete, "/Users/"+userID, nil) + defer resp7.Body.Close() //nolint:errcheck + if resp7.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp7.Body) //nolint:errcheck + t.Fatalf("DELETE: got %d, want 204 (body: %s)", resp7.StatusCode, string(body)) + } +} + +// ── Test 2: Group Role Mapping ────────────────────────────────────────────────── + +func TestSCIME2E_GroupRoleMapping(t *testing.T) { + t.Parallel() + env := newSCIME2EEnv(t) + ctx := context.Background() + + // Provision a user via SCIM. + createBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "role-map-ext-001", + "userName": "rolemap@example.com", + "displayName": "Role Map User", + "active": true, + } + + resp := env.scimReq(t, http.MethodPost, "/Users", createBody) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("POST /Users: got %d, want 201 (body: %s)", resp.StatusCode, string(body)) + } + user := decodeSCIMUser(t, resp) + userUUID, err := uuid.Parse(user.ID) + if err != nil { + t.Fatalf("parse user ID: %v", err) + } + + // Verify initial role is "viewer" (default_role). + member, err := env.db.GetOrgMemberFull(ctx, env.orgID, userUUID) + if err != nil || member == nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member.Role != "viewer" { + t.Errorf("initial role = %q, want viewer", member.Role) + } + + // Create SCIM group via POST /Groups. + groupBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "Admins", + } + groupResp := env.scimReq(t, http.MethodPost, "/Groups", groupBody) + defer groupResp.Body.Close() //nolint:errcheck + if groupResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(groupResp.Body) //nolint:errcheck + t.Fatalf("POST /Groups: got %d, want 201 (body: %s)", groupResp.StatusCode, string(body)) + } + var group SCIMGroup + if err := json.NewDecoder(groupResp.Body).Decode(&group); err != nil { + t.Fatalf("decode group: %v", err) + } + groupUUID, err := uuid.Parse(group.ID) + if err != nil { + t.Fatalf("parse group ID: %v", err) + } + + // Set mapped_role="admin" via store (simulating admin action). + adminRole := "admin" + if err := env.db.UpdateSCIMGroupMapping(ctx, env.orgID, groupUUID, &adminRole, nil); err != nil { + t.Fatalf("UpdateSCIMGroupMapping: %v", err) + } + + // Add user to group via SCIM PATCH /Groups/{id}. + addMemberPatch := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + { + Op: "add", + Path: "members", + Value: json.RawMessage(fmt.Sprintf( + `[{"value":"%s"}]`, user.ID)), + }, + }, + } + + patchResp := env.scimReq(t, http.MethodPatch, "/Groups/"+group.ID, addMemberPatch) + defer patchResp.Body.Close() //nolint:errcheck + if patchResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(patchResp.Body) //nolint:errcheck + t.Fatalf("PATCH add member: got %d, want 200 (body: %s)", patchResp.StatusCode, string(body)) + } + + // Verify user's role was recomputed to "admin". + member, err = env.db.GetOrgMemberFull(ctx, env.orgID, userUUID) + if err != nil || member == nil { + t.Fatalf("GetOrgMemberFull after add: %v", err) + } + if member.Role != "admin" { + t.Errorf("role after group add = %q, want admin", member.Role) + } + + // Remove member from group via SCIM PATCH /Groups/{id}. + removeMemberPatch := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + { + Op: "remove", + Path: fmt.Sprintf(`members[value eq "%s"]`, user.ID), + }, + }, + } + + removeResp := env.scimReq(t, http.MethodPatch, "/Groups/"+group.ID, removeMemberPatch) + defer removeResp.Body.Close() //nolint:errcheck + if removeResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(removeResp.Body) //nolint:errcheck + t.Fatalf("PATCH remove member: got %d, want 200 (body: %s)", removeResp.StatusCode, string(body)) + } + + // Verify role reverted to default "viewer". + member, err = env.db.GetOrgMemberFull(ctx, env.orgID, userUUID) + if err != nil || member == nil { + t.Fatalf("GetOrgMemberFull after remove: %v", err) + } + if member.Role != "viewer" { + t.Errorf("role after group remove = %q, want viewer", member.Role) + } +} + +// ── Test 3: Entra ID Compatibility ────────────────────────────────────────────── + +func TestSCIME2E_EntraIDCompatibility(t *testing.T) { + t.Parallel() + env := newSCIME2EEnv(t) + + // Provision a user first. + createBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "entra-ext-001", + "userName": "entra@example.com", + "displayName": "Entra User", + "active": true, + } + resp := env.scimReq(t, http.MethodPost, "/Users", createBody) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("POST /Users: got %d, want 201 (body: %s)", resp.StatusCode, string(body)) + } + user := decodeSCIMUser(t, resp) + + // Quirk 1: Capitalized op "Replace" (not "replace"). + patchCapitalized := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + {Op: "Replace", Path: "displayName", Value: json.RawMessage(`"Entra Updated"`)}, + }, + } + resp2 := env.scimReq(t, http.MethodPatch, "/Users/"+user.ID, patchCapitalized) + defer resp2.Body.Close() //nolint:errcheck + if resp2.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp2.Body) //nolint:errcheck + t.Fatalf("PATCH capitalized op: got %d, want 200 (body: %s)", resp2.StatusCode, string(body)) + } + user2 := decodeSCIMUser(t, resp2) + if user2.DisplayName != "Entra Updated" { + t.Errorf("displayName after capitalized Replace = %q, want %q", user2.DisplayName, "Entra Updated") + } + + // Quirk 2: String boolean "False" for active field. + patchStringBool := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + {Op: "Replace", Path: "active", Value: json.RawMessage(`"False"`)}, + }, + } + resp3 := env.scimReq(t, http.MethodPatch, "/Users/"+user.ID, patchStringBool) + defer resp3.Body.Close() //nolint:errcheck + if resp3.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp3.Body) //nolint:errcheck + t.Fatalf("PATCH string boolean: got %d, want 200 (body: %s)", resp3.StatusCode, string(body)) + } + user3 := decodeSCIMUser(t, resp3) + if user3.Active { + t.Error("active = true after string 'False', want false") + } + + // Quirk 3: Group member removal via value-array format. + // First, create a group and add the user. + groupBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "Entra Group", + "members": []map[string]any{{"value": user.ID}}, + } + groupResp := env.scimReq(t, http.MethodPost, "/Groups", groupBody) + defer groupResp.Body.Close() //nolint:errcheck + if groupResp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(groupResp.Body) //nolint:errcheck + t.Fatalf("POST /Groups: got %d, want 201 (body: %s)", groupResp.StatusCode, string(body)) + } + var group SCIMGroup + if err := json.NewDecoder(groupResp.Body).Decode(&group); err != nil { + t.Fatalf("decode group: %v", err) + } + + // Remove via Entra ID value-array format: {op: "remove", path: "members", value: [{value: "uuid"}]} + removeEntra := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + { + Op: "Remove", + Path: "members", + Value: json.RawMessage(fmt.Sprintf(`[{"value":"%s"}]`, user.ID)), + }, + }, + } + removeResp := env.scimReq(t, http.MethodPatch, "/Groups/"+group.ID, removeEntra) + defer removeResp.Body.Close() //nolint:errcheck + if removeResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(removeResp.Body) //nolint:errcheck + t.Fatalf("PATCH Entra remove member: got %d, want 200 (body: %s)", removeResp.StatusCode, string(body)) + } + + // Verify group now has no members. + getGroupResp := env.scimReq(t, http.MethodGet, "/Groups/"+group.ID, nil) + defer getGroupResp.Body.Close() //nolint:errcheck + var groupAfter SCIMGroup + if err := json.NewDecoder(getGroupResp.Body).Decode(&groupAfter); err != nil { + t.Fatalf("decode group after remove: %v", err) + } + if len(groupAfter.Members) != 0 { + t.Errorf("group members after Entra remove = %d, want 0", len(groupAfter.Members)) + } +} + +// ── Test 4: Test Connection Patterns ──────────────────────────────────────────── + +func TestSCIME2E_TestConnectionPatterns(t *testing.T) { + t.Parallel() + env := newSCIME2EEnv(t) + + // Entra ID test connection: GET /Users?filter=id eq "{random-uuid}" + // Expects 200 with totalResults: 0 and empty Resources array. + randomUUID := uuid.New().String() + entraFilter := fmt.Sprintf(`id eq "%s"`, randomUUID) + rawURL := fmt.Sprintf("%s/api/v1/orgs/%s/scim/v2/Users", env.ts.URL, env.orgID) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+env.rawToken) + q := req.URL.Query() + q.Set("filter", entraFilter) + req.URL.RawQuery = q.Encode() + + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704: httptest URL + if err != nil { + t.Fatalf("Entra test connection: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("Entra test: got %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) + + entraList := decodeSCIMList(t, resp) + if entraList.TotalResults != 0 { + t.Errorf("Entra totalResults = %d, want 0", entraList.TotalResults) + } + if entraList.Resources == nil { + t.Error("Entra Resources is nil, want empty array") + } + + // Okta test connection: GET /Users?startIndex=1&count=1 + // Expects 200 with pagination envelope. + oktaResp := env.scimReq(t, http.MethodGet, "/Users?startIndex=1&count=1", nil) + defer oktaResp.Body.Close() //nolint:errcheck + + if oktaResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(oktaResp.Body) //nolint:errcheck + t.Fatalf("Okta test: got %d, want 200 (body: %s)", oktaResp.StatusCode, string(body)) + } + assertSCIMContentType(t, oktaResp) + + oktaList := decodeSCIMList(t, oktaResp) + // Must have pagination envelope fields. + if oktaList.StartIndex != 1 { + t.Errorf("Okta startIndex = %d, want 1", oktaList.StartIndex) + } + if oktaList.ItemsPerPage < 0 { + t.Errorf("Okta itemsPerPage = %d, want >= 0", oktaList.ItemsPerPage) + } + // Resources must be an array (even if empty). + if oktaList.Resources == nil { + t.Error("Okta Resources is nil, want array") + } +} + +// ── Test 5: Cross-Org Isolation ───────────────────────────────────────────────── + +func TestSCIME2E_CrossOrgIsolation(t *testing.T) { + t.Parallel() + + // Org A setup. + envA := newSCIME2EEnv(t) + + // Provision user in org A. + createBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "cross-org-ext-001", + "userName": "crossorg@example.com", + "displayName": "Cross Org User", + "active": true, + } + resp := envA.scimReq(t, http.MethodPost, "/Users", createBody) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("POST user in org A: got %d, want 201 (body: %s)", resp.StatusCode, string(body)) + } + userA := decodeSCIMUser(t, resp) + + // Org B setup (separate env with its own DB, org, and token). + envB := newSCIME2EEnv(t) + + // Try to GET org A's user via org B's SCIM endpoint with org B's token. + // This uses org B's URL path but targets user A's ID. + resp2 := envB.scimReq(t, http.MethodGet, "/Users/"+userA.ID, nil) + defer resp2.Body.Close() //nolint:errcheck + + if resp2.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(resp2.Body) //nolint:errcheck + t.Fatalf("cross-org GET: got %d, want 404 (body: %s)", resp2.StatusCode, string(body)) + } + assertSCIMContentType(t, resp2) +} + +// ── Test 6: Token Rotation ────────────────────────────────────────────────────── + +func TestSCIME2E_TokenRotation(t *testing.T) { + t.Parallel() + env := newSCIME2EEnv(t) + ctx := context.Background() + + // Verify current token works. + resp := env.scimReq(t, http.MethodGet, "/Users?startIndex=1&count=1", nil) + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + t.Fatalf("pre-rotation GET: got %d, want 200", resp.StatusCode) + } + + // Rotate token via store (simulating admin token rotation). + oldToken := env.rawToken + newRawToken, newHash, newPrefix, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken: %v", err) + } + if err := env.db.RotateSCIMToken(ctx, env.orgID, newHash, newPrefix); err != nil { + t.Fatalf("RotateSCIMToken: %v", err) + } + + // Old token should now be rejected. + resp2 := env.scimReqWithToken(t, http.MethodGet, "/Users?startIndex=1&count=1", oldToken) + defer resp2.Body.Close() //nolint:errcheck + if resp2.StatusCode != http.StatusUnauthorized { + t.Errorf("old token after rotation: got %d, want 401", resp2.StatusCode) + } + assertSCIMContentType(t, resp2) + + // New token should work. + env.rawToken = newRawToken + resp3 := env.scimReq(t, http.MethodGet, "/Users?startIndex=1&count=1", nil) + defer resp3.Body.Close() //nolint:errcheck + if resp3.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp3.Body) //nolint:errcheck + t.Fatalf("new token after rotation: got %d, want 200 (body: %s)", resp3.StatusCode, string(body)) + } + + _ = newPrefix // used only for store call +} + +// ── Test 7: Error Content-Type ────────────────────────────────────────────────── + +func TestSCIME2E_ErrorContentType(t *testing.T) { + t.Parallel() + env := newSCIME2EEnv(t) + + // Error 1: Invalid token → 401. + resp1 := env.scimReqWithToken(t, http.MethodGet, "/Users", "invalid-token-value") + defer resp1.Body.Close() //nolint:errcheck + if resp1.StatusCode != http.StatusUnauthorized { + t.Errorf("invalid token: got %d, want 401", resp1.StatusCode) + } + ct1 := resp1.Header.Get("Content-Type") + if ct1 != "application/scim+json" { + t.Errorf("401 Content-Type = %q, want %q", ct1, "application/scim+json") + } + + // Error 2: Not-found user → 404. + fakeID := uuid.New().String() + resp2 := env.scimReq(t, http.MethodGet, "/Users/"+fakeID, nil) + defer resp2.Body.Close() //nolint:errcheck + if resp2.StatusCode != http.StatusNotFound { + t.Errorf("not found: got %d, want 404", resp2.StatusCode) + } + ct2 := resp2.Header.Get("Content-Type") + if ct2 != "application/scim+json" { + t.Errorf("404 Content-Type = %q, want %q", ct2, "application/scim+json") + } + + // Error 3: Invalid filter → 400. + rawURL := fmt.Sprintf("%s/api/v1/orgs/%s/scim/v2/Users", env.ts.URL, env.orgID) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+env.rawToken) + q := req.URL.Query() + q.Set("filter", `userName sw "test"`) // "sw" (starts-with) is unsupported + req.URL.RawQuery = q.Encode() + resp3, err := env.ts.Client().Do(req) //nolint:gosec // G704: httptest URL + if err != nil { + t.Fatalf("invalid filter request: %v", err) + } + defer resp3.Body.Close() //nolint:errcheck + if resp3.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp3.Body) //nolint:errcheck + t.Errorf("invalid filter: got %d, want 400 (body: %s)", resp3.StatusCode, string(body)) + } + ct3 := resp3.Header.Get("Content-Type") + if ct3 != "application/scim+json" { + t.Errorf("400 Content-Type = %q, want %q", ct3, "application/scim+json") + } + + // Verify all error bodies are valid JSON with SCIM error schema. + // (Re-test 401 with body parsing) + resp4 := env.scimReqWithToken(t, http.MethodGet, "/Users", "bad-token") + defer resp4.Body.Close() //nolint:errcheck + errBody := readSCIMBody(t, resp4) + schemas, ok := errBody["schemas"].([]any) + if !ok || len(schemas) == 0 { + t.Error("401 error body missing schemas array") + } else { + schemaStr, _ := schemas[0].(string) + if schemaStr != "urn:ietf:params:scim:api:messages:2.0:Error" { + t.Errorf("401 error schema = %q, want SCIM Error schema", schemaStr) + } + } +} diff --git a/internal/api/scim_groups_handler.go b/internal/api/scim_groups_handler.go new file mode 100644 index 00000000..61c53a73 --- /dev/null +++ b/internal/api/scim_groups_handler.go @@ -0,0 +1,679 @@ +// ABOUTME: SCIM 2.0 Group endpoint handlers (RFC 7644 §3). +// ABOUTME: Mounted as chi handlers on /scim/v2/Groups. Supports create, get, list, replace, patch, delete. +package api + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "regexp" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/audit" +) + +// scimGroupRequest is the JSON body for POST and PUT /Groups. +type scimGroupRequest struct { + ExternalID string `json:"externalId"` + DisplayName string `json:"displayName"` + Members []SCIMGroupMember `json:"members"` +} + +// memberFilterRE extracts a user UUID from a SCIM path filter expression like +// members[value eq "some-uuid"]. +var memberFilterRE = regexp.MustCompile(`(?i)^members\[value\s+eq\s+"(.+)"\]$`) + +// scimCreateGroup handles POST /Groups. +func (srv *Server) scimCreateGroup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + orgID := ctx.Value(ctxOrgID).(uuid.UUID) + + slog.InfoContext(ctx, "scim create group", slog.String("org_id", orgID.String())) + + var body scimGroupRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "invalid JSON body") + return + } + + if strings.TrimSpace(body.DisplayName) == "" { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "displayName is required") + return + } + + var externalID *string + if body.ExternalID != "" { + externalID = &body.ExternalID + } + + group, err := srv.store.CreateSCIMGroup(ctx, orgID, externalID, body.DisplayName) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") { + writeSCIMError(w, http.StatusConflict, "uniqueness", "group displayName already exists in this organization") + return + } + slog.ErrorContext(ctx, "scim: create group", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Get default_role for role recomputation. + cfg, err := srv.store.GetSCIMConfig(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim: get config for role recompute", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + defaultRole := "viewer" + if cfg != nil { + defaultRole = cfg.DefaultRole + } + + // Process initial members. + for _, m := range body.Members { + userID, parseErr := uuid.Parse(m.Value) + if parseErr != nil { + continue + } + if addErr := srv.store.AddSCIMGroupMember(ctx, group.ID, userID, orgID); addErr != nil { + slog.ErrorContext(ctx, "scim: add group member", "group_id", group.ID, "user_id", userID, "error", addErr) + continue + } + srv.processGroupMemberAdd(ctx, orgID, userID, group.ID, group.MappedRole.String, group.MappedRole.Valid, group.MappedGroupID, defaultRole) + } + + // Load members for response. + memberIDs, err := srv.store.ListSCIMGroupMembers(ctx, orgID, group.ID) + if err != nil { + slog.ErrorContext(ctx, "scim: list members after create", "error", err) + } + + srv.auditLog(r, audit.Entry{ + OrgID: orgID, + Action: "create", + EntityType: "scim_group", + EntityID: group.ID.String(), + EntityName: group.DisplayName, + Success: true, + Metadata: map[string]any{"source": "scim", "scim_config_id": ctx.Value(ctxSCIMConfigID).(uuid.UUID).String()}, + }) + + resp := srv.buildSCIMGroupResponse(r, group.ID.String(), group.ExternalID.String, group.DisplayName, group.CreatedAt.Format("2006-01-02T15:04:05Z"), group.UpdatedAt.Format("2006-01-02T15:04:05Z"), memberIDs) + writeSCIMJSON(w, http.StatusCreated, resp) +} + +// scimGetGroup handles GET /Groups/{id}. +func (srv *Server) scimGetGroup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + orgID := ctx.Value(ctxOrgID).(uuid.UUID) + + slog.InfoContext(ctx, "scim get group", slog.String("org_id", orgID.String())) + + groupIDStr := chi.URLParam(r, "id") + groupID, err := uuid.Parse(groupIDStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "invalid group id") + return + } + + group, err := srv.store.GetSCIMGroup(ctx, orgID, groupID) + if err != nil { + slog.ErrorContext(ctx, "scim: get group", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if group == nil { + writeSCIMError(w, http.StatusNotFound, "", "group not found") + return + } + + memberIDs, err := srv.store.ListSCIMGroupMembers(ctx, orgID, group.ID) + if err != nil { + slog.ErrorContext(ctx, "scim: list group members", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + resp := srv.buildSCIMGroupResponse(r, group.ID.String(), group.ExternalID.String, group.DisplayName, group.CreatedAt.Format("2006-01-02T15:04:05Z"), group.UpdatedAt.Format("2006-01-02T15:04:05Z"), memberIDs) + writeSCIMJSON(w, http.StatusOK, resp) +} + +// scimListGroups handles GET /Groups with optional filtering. +func (srv *Server) scimListGroups(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + orgID := ctx.Value(ctxOrgID).(uuid.UUID) + + slog.InfoContext(ctx, "scim list groups", slog.String("org_id", orgID.String())) + + filterStr := r.URL.Query().Get("filter") + exprs, err := parseSCIMFilter(filterStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } + + groups, err := srv.store.ListSCIMGroups(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim: list groups", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Apply filters in-memory (group counts are small). + var filtered []any + for _, g := range groups { + if matchesSCIMGroupFilter(g.ID.String(), g.ExternalID.String, g.DisplayName, exprs) { + memberIDs, mErr := srv.store.ListSCIMGroupMembers(ctx, orgID, g.ID) + if mErr != nil { + slog.ErrorContext(ctx, "scim: list group members for list", "group_id", g.ID, "error", mErr) + continue + } + resp := srv.buildSCIMGroupResponse(r, g.ID.String(), g.ExternalID.String, g.DisplayName, g.CreatedAt.Format("2006-01-02T15:04:05Z"), g.UpdatedAt.Format("2006-01-02T15:04:05Z"), memberIDs) + filtered = append(filtered, resp) + } + } + + if filtered == nil { + filtered = []any{} + } + + writeSCIMJSON(w, http.StatusOK, SCIMListResponse{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + TotalResults: len(filtered), + ItemsPerPage: len(filtered), + StartIndex: 1, + Resources: filtered, + }) +} + +// scimReplaceGroup handles PUT /Groups/{id}. +func (srv *Server) scimReplaceGroup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + orgID := ctx.Value(ctxOrgID).(uuid.UUID) + + slog.InfoContext(ctx, "scim replace group", slog.String("org_id", orgID.String())) + + groupIDStr := chi.URLParam(r, "id") + groupID, err := uuid.Parse(groupIDStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "invalid group id") + return + } + + group, err := srv.store.GetSCIMGroup(ctx, orgID, groupID) + if err != nil { + slog.ErrorContext(ctx, "scim: get group for replace", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if group == nil { + writeSCIMError(w, http.StatusNotFound, "", "group not found") + return + } + + var body scimGroupRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "invalid JSON body") + return + } + + if strings.TrimSpace(body.DisplayName) == "" { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "displayName is required") + return + } + + var externalID *string + if body.ExternalID != "" { + externalID = &body.ExternalID + } + + if err := srv.store.UpdateSCIMGroup(ctx, orgID, groupID, body.DisplayName, externalID); err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") { + writeSCIMError(w, http.StatusConflict, "uniqueness", "group displayName already exists in this organization") + return + } + slog.ErrorContext(ctx, "scim: update group", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Get config for role recomputation. + cfg, err := srv.store.GetSCIMConfig(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim: get config for replace", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + defaultRole := "viewer" + if cfg != nil { + defaultRole = cfg.DefaultRole + } + + // Diff members: current vs new. + currentMembers, err := srv.store.ListSCIMGroupMembers(ctx, orgID, groupID) + if err != nil { + slog.ErrorContext(ctx, "scim: list members for diff", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + newMemberSet := make(map[uuid.UUID]bool, len(body.Members)) + for _, m := range body.Members { + uid, parseErr := uuid.Parse(m.Value) + if parseErr != nil { + continue + } + newMemberSet[uid] = true + } + + currentSet := make(map[uuid.UUID]bool, len(currentMembers)) + for _, uid := range currentMembers { + currentSet[uid] = true + } + + // Reload group to get the current mapped_role and mapped_group_id. + group, _ = srv.store.GetSCIMGroup(ctx, orgID, groupID) + + // Add new members. + for uid := range newMemberSet { + if !currentSet[uid] { + if addErr := srv.store.AddSCIMGroupMember(ctx, groupID, uid, orgID); addErr != nil { + slog.ErrorContext(ctx, "scim: add member in replace", "user_id", uid, "error", addErr) + continue + } + srv.processGroupMemberAdd(ctx, orgID, uid, groupID, group.MappedRole.String, group.MappedRole.Valid, group.MappedGroupID, defaultRole) + } + } + + // Remove absent members. + for _, uid := range currentMembers { + if !newMemberSet[uid] { + if rmErr := srv.store.RemoveSCIMGroupMember(ctx, orgID, groupID, uid); rmErr != nil { + slog.ErrorContext(ctx, "scim: remove member in replace", "user_id", uid, "error", rmErr) + continue + } + srv.processGroupMemberRemove(ctx, orgID, uid, groupID, group.MappedRole.String, group.MappedRole.Valid, group.MappedGroupID, defaultRole) + } + } + + srv.auditLog(r, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "scim_group", + EntityID: groupID.String(), + EntityName: body.DisplayName, + Success: true, + Metadata: map[string]any{"source": "scim", "scim_config_id": ctx.Value(ctxSCIMConfigID).(uuid.UUID).String()}, + }) + + // Reload for response. + memberIDs, _ := srv.store.ListSCIMGroupMembers(ctx, orgID, groupID) + group, _ = srv.store.GetSCIMGroup(ctx, orgID, groupID) + resp := srv.buildSCIMGroupResponse(r, group.ID.String(), group.ExternalID.String, group.DisplayName, group.CreatedAt.Format("2006-01-02T15:04:05Z"), group.UpdatedAt.Format("2006-01-02T15:04:05Z"), memberIDs) + writeSCIMJSON(w, http.StatusOK, resp) +} + +// scimPatchGroup handles PATCH /Groups/{id}. +func (srv *Server) scimPatchGroup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + orgID := ctx.Value(ctxOrgID).(uuid.UUID) + + slog.InfoContext(ctx, "scim patch group", slog.String("org_id", orgID.String())) + + groupIDStr := chi.URLParam(r, "id") + groupID, err := uuid.Parse(groupIDStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "invalid group id") + return + } + + group, err := srv.store.GetSCIMGroup(ctx, orgID, groupID) + if err != nil { + slog.ErrorContext(ctx, "scim: get group for patch", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if group == nil { + writeSCIMError(w, http.StatusNotFound, "", "group not found") + return + } + + var patch SCIMPatchOp + if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "invalid JSON body") + return + } + + // Get config for role recomputation. + cfg, err := srv.store.GetSCIMConfig(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim: get config for patch", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + defaultRole := "viewer" + if cfg != nil { + defaultRole = cfg.DefaultRole + } + + for _, op := range patch.Operations { + opLower := strings.ToLower(op.Op) + pathLower := strings.ToLower(op.Path) + + switch opLower { + case "add": + if pathLower == "members" { + userIDs := extractMemberValues(op.Value) + for _, userID := range userIDs { + if addErr := srv.store.AddSCIMGroupMember(ctx, group.ID, userID, orgID); addErr != nil { + slog.ErrorContext(ctx, "scim: patch add member", "user_id", userID, "error", addErr) + continue + } + srv.processGroupMemberAdd(ctx, orgID, userID, group.ID, group.MappedRole.String, group.MappedRole.Valid, group.MappedGroupID, defaultRole) + } + } + + case "remove": + userIDs := srv.extractRemoveTargets(op) + for _, userID := range userIDs { + if rmErr := srv.store.RemoveSCIMGroupMember(ctx, orgID, group.ID, userID); rmErr != nil { + slog.ErrorContext(ctx, "scim: patch remove member", "user_id", userID, "error", rmErr) + continue + } + srv.processGroupMemberRemove(ctx, orgID, userID, group.ID, group.MappedRole.String, group.MappedRole.Valid, group.MappedGroupID, defaultRole) + } + + case "replace": + if pathLower == "displayname" { + var newName string + if err := json.Unmarshal(op.Value, &newName); err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "displayName value must be a string") + return + } + if strings.TrimSpace(newName) == "" { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "displayName cannot be empty") + return + } + if updateErr := srv.store.UpdateSCIMGroup(ctx, orgID, group.ID, newName, nil); updateErr != nil { + if strings.Contains(updateErr.Error(), "duplicate key") || strings.Contains(updateErr.Error(), "unique constraint") { + writeSCIMError(w, http.StatusConflict, "uniqueness", "group displayName already exists in this organization") + return + } + slog.ErrorContext(ctx, "scim: patch replace displayName", "error", updateErr) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + } else { + writeSCIMError(w, http.StatusBadRequest, "invalidPath", fmt.Sprintf("unrecognized attribute path: %q", op.Path)) + return + } + + default: + writeSCIMError(w, http.StatusBadRequest, "invalidValue", fmt.Sprintf("unsupported operation: %q", op.Op)) + return + } + } + + srv.auditLog(r, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "scim_group", + EntityID: groupID.String(), + EntityName: group.DisplayName, + Success: true, + Metadata: map[string]any{"source": "scim", "scim_config_id": ctx.Value(ctxSCIMConfigID).(uuid.UUID).String()}, + }) + + // Reload for response. + group, _ = srv.store.GetSCIMGroup(ctx, orgID, groupID) + memberIDs, _ := srv.store.ListSCIMGroupMembers(ctx, orgID, groupID) + resp := srv.buildSCIMGroupResponse(r, group.ID.String(), group.ExternalID.String, group.DisplayName, group.CreatedAt.Format("2006-01-02T15:04:05Z"), group.UpdatedAt.Format("2006-01-02T15:04:05Z"), memberIDs) + writeSCIMJSON(w, http.StatusOK, resp) +} + +// scimDeleteGroup handles DELETE /Groups/{id}. +func (srv *Server) scimDeleteGroup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + orgID := ctx.Value(ctxOrgID).(uuid.UUID) + + slog.InfoContext(ctx, "scim delete group", slog.String("org_id", orgID.String())) + + groupIDStr := chi.URLParam(r, "id") + groupID, err := uuid.Parse(groupIDStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "invalid group id") + return + } + + group, err := srv.store.GetSCIMGroup(ctx, orgID, groupID) + if err != nil { + slog.ErrorContext(ctx, "scim: get group for delete", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if group == nil { + // Idempotent — already deleted returns 204. + w.WriteHeader(http.StatusNoContent) + return + } + + // Collect affected non-exempt users before deletion. + memberIDs, err := srv.store.ListSCIMGroupMembers(ctx, orgID, groupID) + if err != nil { + slog.ErrorContext(ctx, "scim: list members for delete", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Get config for role recomputation. + cfg, err := srv.store.GetSCIMConfig(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim: get config for delete", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + defaultRole := "viewer" + if cfg != nil { + defaultRole = cfg.DefaultRole + } + + // Delete the group (CASCADE deletes scim_group_members). + if err := srv.store.DeleteSCIMGroup(ctx, orgID, groupID); err != nil { + slog.ErrorContext(ctx, "scim: delete group", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Recompute roles for affected non-exempt users. + for _, uid := range memberIDs { + member, mErr := srv.store.GetOrgMemberFull(ctx, orgID, uid) + if mErr != nil || member == nil { + continue + } + if member.ScimExempt { + continue + } + if roleErr := srv.recomputeSCIMRole(ctx, orgID, uid, defaultRole); roleErr != nil { + slog.ErrorContext(ctx, "scim: recompute role after group delete", "user_id", uid, "error", roleErr) + } + } + // Design decision (§3.4): do NOT remove users from mapped notification group on group delete. + + srv.auditLog(r, audit.Entry{ + OrgID: orgID, + Action: "delete", + EntityType: "scim_group", + EntityID: groupID.String(), + EntityName: group.DisplayName, + Success: true, + Metadata: map[string]any{"source": "scim", "scim_config_id": ctx.Value(ctxSCIMConfigID).(uuid.UUID).String()}, + }) + + w.WriteHeader(http.StatusNoContent) +} + +// processGroupMemberAdd handles role recomputation and notification group sync +// when a user is added to a SCIM group. +func (srv *Server) processGroupMemberAdd(ctx context.Context, orgID, userID, scimGroupID uuid.UUID, _ string, hasMappedRole bool, mappedGroupID uuid.NullUUID, defaultRole string) { + member, err := srv.store.GetOrgMemberFull(ctx, orgID, userID) + if err != nil || member == nil { + return + } + if member.ScimExempt { + return + } + + if hasMappedRole { + if err := srv.recomputeSCIMRole(ctx, orgID, userID, defaultRole); err != nil { + slog.ErrorContext(ctx, "scim: recompute role on add", "user_id", userID, "error", err) + } + } + + if mappedGroupID.Valid { + if err := srv.syncNotifGroupAdd(ctx, orgID, userID, mappedGroupID.UUID, scimGroupID); err != nil { + slog.ErrorContext(ctx, "scim: sync notif group add", "user_id", userID, "error", err) + } + } +} + +// processGroupMemberRemove handles role recomputation and notification group sync +// when a user is removed from a SCIM group. +func (srv *Server) processGroupMemberRemove(ctx context.Context, orgID, userID, scimGroupID uuid.UUID, _ string, hasMappedRole bool, mappedGroupID uuid.NullUUID, defaultRole string) { + member, err := srv.store.GetOrgMemberFull(ctx, orgID, userID) + if err != nil || member == nil { + return + } + if member.ScimExempt { + return + } + + if hasMappedRole { + if err := srv.recomputeSCIMRole(ctx, orgID, userID, defaultRole); err != nil { + slog.ErrorContext(ctx, "scim: recompute role on remove", "user_id", userID, "error", err) + } + } + + if mappedGroupID.Valid { + if err := srv.syncNotifGroupRemove(ctx, orgID, userID, mappedGroupID.UUID, scimGroupID); err != nil { + slog.ErrorContext(ctx, "scim: sync notif group remove", "user_id", userID, "error", err) + } + } +} + +// extractMemberValues parses member references from a SCIM PATCH value field. +// Handles both: [{value: "uuid"}, ...] and single {value: "uuid"}. +func extractMemberValues(raw json.RawMessage) []uuid.UUID { + // Try array of member objects. + var members []SCIMGroupMember + if err := json.Unmarshal(raw, &members); err == nil { + result := make([]uuid.UUID, 0, len(members)) + for _, m := range members { + uid, err := uuid.Parse(m.Value) + if err == nil { + result = append(result, uid) + } + } + return result + } + + // Try single member object. + var single SCIMGroupMember + if err := json.Unmarshal(raw, &single); err == nil { + uid, err := uuid.Parse(single.Value) + if err == nil { + return []uuid.UUID{uid} + } + } + + return nil +} + +// extractRemoveTargets extracts user UUIDs from a remove operation. +// Supports two formats: +// 1. Standard: path = members[value eq "user-uuid"] +// 2. Entra ID: path = "members", value = [{value: "user-uuid"}, ...] +func (srv *Server) extractRemoveTargets(op SCIMPatchOperation) []uuid.UUID { + // Format 1: path filter expression. + if matches := memberFilterRE.FindStringSubmatch(op.Path); len(matches) == 2 { + uid, err := uuid.Parse(matches[1]) + if err == nil { + return []uuid.UUID{uid} + } + } + + // Format 2: Entra ID value array (path is "members", values in body). + if strings.EqualFold(op.Path, "members") && len(op.Value) > 0 { + return extractMemberValues(op.Value) + } + + return nil +} + +// matchesSCIMGroupFilter checks if a group matches the given SCIM filter expressions. +func matchesSCIMGroupFilter(id, externalID, displayName string, exprs []SCIMFilterExpr) bool { + if len(exprs) == 0 { + return true + } + for _, expr := range exprs { + attrLower := strings.ToLower(expr.Attr) + switch attrLower { + case "displayname": + if expr.Value != displayName { + return false + } + case "externalid": + if expr.Value != externalID { + return false + } + case "id": + if expr.Value != id { + return false + } + default: + slog.Warn("scim list groups: unsupported filter attribute", slog.String("attribute", expr.Attr)) //nolint:gosec // G706: slog structured field, not interpolated into log format string + return false + } + } + return true +} + +// buildSCIMGroupResponse constructs a SCIMGroup response. +func (srv *Server) buildSCIMGroupResponse(r *http.Request, id, externalID, displayName, created, lastModified string, memberIDs []uuid.UUID) SCIMGroup { + members := make([]SCIMGroupMember, 0, len(memberIDs)) + for _, uid := range memberIDs { + members = append(members, SCIMGroupMember{ + Value: uid.String(), + Ref: fmt.Sprintf("%s/Users/%s", scimBaseURL(r), uid.String()), + }) + } + + return SCIMGroup{ + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + ID: id, + ExternalID: externalID, + DisplayName: displayName, + Members: members, + Meta: SCIMMeta{ + ResourceType: "Group", + Created: created, + LastModified: lastModified, + Location: fmt.Sprintf("%s/Groups/%s", scimBaseURL(r), id), + }, + } +} + +// scimBaseURL returns the SCIM v2 base URL derived from the request. +// For a request to /api/v1/orgs/{org_id}/scim/v2/Groups/..., returns +// the URL up to and including /scim/v2. +func scimBaseURL(r *http.Request) string { + path := r.URL.Path + if idx := strings.Index(path, "/scim/v2"); idx >= 0 { + path = path[:idx+len("/scim/v2")] + } + return fmt.Sprintf("%s://%s%s", scimScheme(r), r.Host, path) +} diff --git a/internal/api/scim_groups_handler_test.go b/internal/api/scim_groups_handler_test.go new file mode 100644 index 00000000..f632c314 --- /dev/null +++ b/internal/api/scim_groups_handler_test.go @@ -0,0 +1,595 @@ +// ABOUTME: Integration tests for SCIM 2.0 Group handlers (RFC 7644). +// ABOUTME: Uses real Postgres via testutil.NewTestDB and httptest.Server with SCIM auth. +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/scarson/cvert-ops/internal/auth" + "github.com/scarson/cvert-ops/internal/config" + "github.com/scarson/cvert-ops/internal/testutil" +) + +// scimGroupTestEnv bundles a test server with SCIM group routes mounted. +type scimGroupTestEnv struct { + ts *httptest.Server + srv *Server + db *testutil.TestDB + orgID uuid.UUID + configID uuid.UUID + rawToken string + baseURL string // e.g. http://host/api/v1/orgs/{org_id}/scim/v2 +} + +// newSCIMGroupTestEnv creates a full SCIM test environment with group routes. +func newSCIMGroupTestEnv(t *testing.T) *scimGroupTestEnv { + t.Helper() + + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "scim-group-test-"+uuid.New().String()[:8]) + + // SSO connection (FK for scim_configs). + ssoConn, err := db.CreateSSOConnection(ctx, org.ID, "Test IdP", + "https://idp.example.com", "client-id", []byte("encrypted"), nil, true) + require.NoError(t, err, "CreateSSOConnection") + + rawToken, tokenHash, tokenPrefix, err := auth.GenerateSCIMToken() + require.NoError(t, err, "GenerateSCIMToken") + + scimCfg, err := db.CreateSCIMConfig(ctx, org.ID, ssoConn.ID, true, tokenHash, tokenPrefix, "viewer") + require.NoError(t, err, "CreateSCIMConfig") + + cfg := &config.Config{ //nolint:exhaustruct,gosec // test: only relevant fields set; G101: test-only credential + JWTSecret: "scim-group-test-secret-32bytesmin", + Argon2MaxConcurrent: 1, + } + srv, err := NewServer(db.Store, cfg, ServerDeps{}) + require.NoError(t, err, "NewServer") + t.Cleanup(srv.Close) + + r := chi.NewRouter() + r.Route("/api/v1/orgs/{org_id}/scim/v2", func(sub chi.Router) { + sub.Use(srv.requireSCIMAuth) + sub.Post("/Groups", srv.scimCreateGroup) + sub.Get("/Groups", srv.scimListGroups) + sub.Get("/Groups/{id}", srv.scimGetGroup) + sub.Put("/Groups/{id}", srv.scimReplaceGroup) + sub.Patch("/Groups/{id}", srv.scimPatchGroup) + sub.Delete("/Groups/{id}", srv.scimDeleteGroup) + }) + + ts := httptest.NewServer(r) + t.Cleanup(ts.Close) + + env := &scimGroupTestEnv{ + ts: ts, + srv: srv, + db: db, + orgID: org.ID, + configID: scimCfg.ID, + rawToken: rawToken, + baseURL: fmt.Sprintf("%s/api/v1/orgs/%s/scim/v2", ts.URL, org.ID), + } + + return env +} + +// scimRequest makes an authenticated SCIM request. +func (env *scimGroupTestEnv) scimRequest(t *testing.T, method, path string, body any) *http.Response { + t.Helper() + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + require.NoError(t, err) + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, env.baseURL+path, bodyReader) //nolint:noctx // test code + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+env.rawToken) + if body != nil { + req.Header.Set("Content-Type", "application/scim+json") + } + + resp, err := http.DefaultClient.Do(req) //nolint:gosec // G704: test httptest URL is not user-controlled + require.NoError(t, err) + return resp +} + +// createTestUser creates a user and adds them as a member of the test org. +func (env *scimGroupTestEnv) createTestUser(t *testing.T, ctx context.Context, role string) uuid.UUID { + t.Helper() + user := env.db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "Test User", "hash", 1) + err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, role) + require.NoError(t, err, "CreateOrgMember") + return user.ID +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +func TestSCIMCreateGroup_Basic(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + + resp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "Engineering", + "externalId": "ext-eng-001", + }) + defer resp.Body.Close() + + require.Equal(t, http.StatusCreated, resp.StatusCode) + assert.Equal(t, "application/scim+json", resp.Header.Get("Content-Type")) + + var group SCIMGroup + require.NoError(t, json.NewDecoder(resp.Body).Decode(&group)) + assert.Equal(t, "Engineering", group.DisplayName) + assert.Equal(t, "ext-eng-001", group.ExternalID) + assert.NotEmpty(t, group.ID) + assert.Equal(t, "Group", group.Meta.ResourceType) + assert.Empty(t, group.Members) +} + +func TestSCIMCreateGroup_WithMembers(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + ctx := context.Background() + + userID := env.createTestUser(t, ctx, "viewer") + + resp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "Team Alpha", + "members": []map[string]string{ + {"value": userID.String()}, + }, + }) + defer resp.Body.Close() + + require.Equal(t, http.StatusCreated, resp.StatusCode) + + var group SCIMGroup + require.NoError(t, json.NewDecoder(resp.Body).Decode(&group)) + assert.Equal(t, "Team Alpha", group.DisplayName) + require.Len(t, group.Members, 1) + assert.Equal(t, userID.String(), group.Members[0].Value) +} + +func TestSCIMGetGroup_WithMembers(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + ctx := context.Background() + + userID := env.createTestUser(t, ctx, "viewer") + + // Create group with member. + createResp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "DevOps", + "members": []map[string]string{{"value": userID.String()}}, + }) + defer createResp.Body.Close() + require.Equal(t, http.StatusCreated, createResp.StatusCode) + + var created SCIMGroup + require.NoError(t, json.NewDecoder(createResp.Body).Decode(&created)) + + // GET the group. + getResp := env.scimRequest(t, "GET", "/Groups/"+created.ID, nil) + defer getResp.Body.Close() + + require.Equal(t, http.StatusOK, getResp.StatusCode) + assert.Equal(t, "application/scim+json", getResp.Header.Get("Content-Type")) + + var got SCIMGroup + require.NoError(t, json.NewDecoder(getResp.Body).Decode(&got)) + assert.Equal(t, "DevOps", got.DisplayName) + require.Len(t, got.Members, 1) + assert.Equal(t, userID.String(), got.Members[0].Value) +} + +func TestSCIMGetGroup_NotFound(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + + resp := env.scimRequest(t, "GET", "/Groups/"+uuid.New().String(), nil) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestSCIMListGroups_FilterByDisplayName(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + + // Create two groups. + r1 := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "Frontend", + }) + require.Equal(t, http.StatusCreated, r1.StatusCode) + _ = r1.Body.Close() + + r2 := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "Backend", + }) + require.Equal(t, http.StatusCreated, r2.StatusCode) + _ = r2.Body.Close() + + // Filter by displayName. URL-encode the filter value. + resp := env.scimRequest(t, "GET", `/Groups?filter=displayName+eq+"Frontend"`, nil) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var listResp SCIMListResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&listResp)) + assert.Equal(t, 1, listResp.TotalResults) + require.Len(t, listResp.Resources, 1) + + // Verify the returned group is Frontend. + raw, err := json.Marshal(listResp.Resources[0]) + require.NoError(t, err) + var group SCIMGroup + require.NoError(t, json.Unmarshal(raw, &group)) + assert.Equal(t, "Frontend", group.DisplayName) +} + +func TestSCIMListGroups_NoFilter(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + + ra := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, "displayName": "A", + }) + require.Equal(t, http.StatusCreated, ra.StatusCode) + _ = ra.Body.Close() + + rb := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, "displayName": "B", + }) + require.Equal(t, http.StatusCreated, rb.StatusCode) + _ = rb.Body.Close() + + resp := env.scimRequest(t, "GET", "/Groups", nil) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var listResp SCIMListResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&listResp)) + assert.Equal(t, 2, listResp.TotalResults) + assert.Len(t, listResp.Resources, 2) +} + +func TestSCIMPatchGroup_AddMembers(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + ctx := context.Background() + + userID := env.createTestUser(t, ctx, "viewer") + + // Create empty group. + createResp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "PatchTest", + }) + defer createResp.Body.Close() + require.Equal(t, http.StatusCreated, createResp.StatusCode) + + var created SCIMGroup + require.NoError(t, json.NewDecoder(createResp.Body).Decode(&created)) + + // PATCH add member. + patchResp := env.scimRequest(t, "PATCH", "/Groups/"+created.ID, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + "Operations": []map[string]any{ + { + "op": "add", + "path": "members", + "value": []map[string]string{ + {"value": userID.String()}, + }, + }, + }, + }) + defer patchResp.Body.Close() + + require.Equal(t, http.StatusOK, patchResp.StatusCode) + + var patched SCIMGroup + require.NoError(t, json.NewDecoder(patchResp.Body).Decode(&patched)) + require.Len(t, patched.Members, 1) + assert.Equal(t, userID.String(), patched.Members[0].Value) +} + +func TestSCIMPatchGroup_RemoveMembers_Standard(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + ctx := context.Background() + + userID := env.createTestUser(t, ctx, "viewer") + + // Create group with member. + createResp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "RemoveStd", + "members": []map[string]string{{"value": userID.String()}}, + }) + defer createResp.Body.Close() + require.Equal(t, http.StatusCreated, createResp.StatusCode) + + var created SCIMGroup + require.NoError(t, json.NewDecoder(createResp.Body).Decode(&created)) + require.Len(t, created.Members, 1) + + // PATCH remove member using standard path filter format. + patchResp := env.scimRequest(t, "PATCH", "/Groups/"+created.ID, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + "Operations": []map[string]any{ + { + "op": "remove", + "path": fmt.Sprintf(`members[value eq "%s"]`, userID.String()), + }, + }, + }) + defer patchResp.Body.Close() + + require.Equal(t, http.StatusOK, patchResp.StatusCode) + + var patched SCIMGroup + require.NoError(t, json.NewDecoder(patchResp.Body).Decode(&patched)) + assert.Empty(t, patched.Members) +} + +func TestSCIMPatchGroup_RemoveMembers_EntraID(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + ctx := context.Background() + + userID := env.createTestUser(t, ctx, "viewer") + + // Create group with member. + createResp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "RemoveEntra", + "members": []map[string]string{{"value": userID.String()}}, + }) + defer createResp.Body.Close() + require.Equal(t, http.StatusCreated, createResp.StatusCode) + + var created SCIMGroup + require.NoError(t, json.NewDecoder(createResp.Body).Decode(&created)) + require.Len(t, created.Members, 1) + + // PATCH remove member using Entra ID format (value array). + patchResp := env.scimRequest(t, "PATCH", "/Groups/"+created.ID, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + "Operations": []map[string]any{ + { + "op": "Remove", + "path": "members", + "value": []map[string]string{ + {"value": userID.String()}, + }, + }, + }, + }) + defer patchResp.Body.Close() + + require.Equal(t, http.StatusOK, patchResp.StatusCode) + + var patched SCIMGroup + require.NoError(t, json.NewDecoder(patchResp.Body).Decode(&patched)) + assert.Empty(t, patched.Members) +} + +func TestSCIMPatchGroup_UpdateDisplayName(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + + // Create group. + createResp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "OldName", + }) + defer createResp.Body.Close() + require.Equal(t, http.StatusCreated, createResp.StatusCode) + + var created SCIMGroup + require.NoError(t, json.NewDecoder(createResp.Body).Decode(&created)) + + // PATCH replace displayName. + patchResp := env.scimRequest(t, "PATCH", "/Groups/"+created.ID, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + "Operations": []map[string]any{ + { + "op": "Replace", + "path": "displayName", + "value": "NewName", + }, + }, + }) + defer patchResp.Body.Close() + + require.Equal(t, http.StatusOK, patchResp.StatusCode) + + var patched SCIMGroup + require.NoError(t, json.NewDecoder(patchResp.Body).Decode(&patched)) + assert.Equal(t, "NewName", patched.DisplayName) +} + +func TestSCIMReplaceGroup_MemberDiff(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + ctx := context.Background() + + user1 := env.createTestUser(t, ctx, "viewer") + user2 := env.createTestUser(t, ctx, "viewer") + + // Create group with user1. + createResp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "ReplaceTest", + "members": []map[string]string{{"value": user1.String()}}, + }) + defer createResp.Body.Close() + require.Equal(t, http.StatusCreated, createResp.StatusCode) + + var created SCIMGroup + require.NoError(t, json.NewDecoder(createResp.Body).Decode(&created)) + require.Len(t, created.Members, 1) + + // PUT with user2 only (removes user1, adds user2). + putResp := env.scimRequest(t, "PUT", "/Groups/"+created.ID, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "ReplaceTest Updated", + "members": []map[string]string{{"value": user2.String()}}, + }) + defer putResp.Body.Close() + + require.Equal(t, http.StatusOK, putResp.StatusCode) + + var replaced SCIMGroup + require.NoError(t, json.NewDecoder(putResp.Body).Decode(&replaced)) + assert.Equal(t, "ReplaceTest Updated", replaced.DisplayName) + require.Len(t, replaced.Members, 1) + assert.Equal(t, user2.String(), replaced.Members[0].Value) +} + +func TestSCIMDeleteGroup_CascadesMembers(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + ctx := context.Background() + + userID := env.createTestUser(t, ctx, "viewer") + + // Create group with member. + createResp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "DeleteTest", + "members": []map[string]string{{"value": userID.String()}}, + }) + defer createResp.Body.Close() + require.Equal(t, http.StatusCreated, createResp.StatusCode) + + var created SCIMGroup + require.NoError(t, json.NewDecoder(createResp.Body).Decode(&created)) + + // DELETE the group. + delResp := env.scimRequest(t, "DELETE", "/Groups/"+created.ID, nil) + defer delResp.Body.Close() + + require.Equal(t, http.StatusNoContent, delResp.StatusCode) + + // Verify group is gone. + getResp := env.scimRequest(t, "GET", "/Groups/"+created.ID, nil) + defer getResp.Body.Close() + assert.Equal(t, http.StatusNotFound, getResp.StatusCode) + + // Verify members are gone. + memberIDs, err := env.db.ListSCIMGroupMembers(ctx, env.orgID, uuid.MustParse(created.ID)) + require.NoError(t, err) + assert.Empty(t, memberIDs) +} + +func TestSCIMDeleteGroup_RecomputesRoles(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + ctx := context.Background() + + userID := env.createTestUser(t, ctx, "viewer") + + // Create a SCIM group with mapped_role=admin and add the user. + group, err := env.db.CreateSCIMGroup(ctx, env.orgID, nil, "AdminGroup") + require.NoError(t, err) + adminRole := "admin" + require.NoError(t, env.db.UpdateSCIMGroupMapping(ctx, env.orgID, group.ID, &adminRole, nil)) + require.NoError(t, env.db.AddSCIMGroupMember(ctx, group.ID, userID, env.orgID)) + + // Recompute role to set user to admin. + require.NoError(t, env.srv.recomputeSCIMRole(ctx, env.orgID, userID, "viewer")) + member, err := env.db.GetOrgMemberFull(ctx, env.orgID, userID) + require.NoError(t, err) + require.Equal(t, "admin", member.Role) + + // DELETE the group via SCIM. + delResp := env.scimRequest(t, "DELETE", "/Groups/"+group.ID.String(), nil) + defer delResp.Body.Close() + require.Equal(t, http.StatusNoContent, delResp.StatusCode) + + // Verify role was recomputed back to default (viewer). + member, err = env.db.GetOrgMemberFull(ctx, env.orgID, userID) + require.NoError(t, err) + require.Equal(t, "viewer", member.Role) +} + +func TestSCIMDeleteGroup_Idempotent(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + + // Delete a non-existent group — should return 204. + resp := env.scimRequest(t, "DELETE", "/Groups/"+uuid.New().String(), nil) + defer resp.Body.Close() + + require.Equal(t, http.StatusNoContent, resp.StatusCode) +} + +func TestSCIMCreateGroup_MissingDisplayName(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + + resp := env.scimRequest(t, "POST", "/Groups", map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + }) + defer resp.Body.Close() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + var scimErr SCIMError + require.NoError(t, json.NewDecoder(resp.Body).Decode(&scimErr)) + assert.Equal(t, "invalidValue", scimErr.SCIMType) +} + +func TestSCIMPatchGroup_NotFound(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + + resp := env.scimRequest(t, "PATCH", "/Groups/"+uuid.New().String(), map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + "Operations": []map[string]any{ + {"op": "replace", "path": "displayName", "value": "X"}, + }, + }) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func TestSCIMReplaceGroup_NotFound(t *testing.T) { + t.Parallel() + env := newSCIMGroupTestEnv(t) + + resp := env.scimRequest(t, "PUT", "/Groups/"+uuid.New().String(), map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "displayName": "NoSuchGroup", + }) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) +} diff --git a/internal/api/scim_notif_sync.go b/internal/api/scim_notif_sync.go new file mode 100644 index 00000000..603995f2 --- /dev/null +++ b/internal/api/scim_notif_sync.go @@ -0,0 +1,41 @@ +// ABOUTME: Notification group sync from SCIM group mappings. +// ABOUTME: Propagates SCIM group membership changes to notification groups. +package api + +import ( + "context" + + "github.com/google/uuid" +) + +// syncNotifGroupAdd adds a user to a notification group as a SCIM-managed member. +// If the notification group is soft-deleted, this is a no-op. If the user is already +// a member (manually or via SCIM), the existing membership is preserved via ON CONFLICT DO NOTHING. +func (srv *Server) syncNotifGroupAdd(ctx context.Context, orgID, userID, mappedGroupID, _ uuid.UUID) error { + // Verify the target notification group exists and is not soft-deleted. + group, err := srv.store.GetGroupIfActive(ctx, orgID, mappedGroupID) + if err != nil { + return err + } + if group == nil { + return nil // soft-deleted or non-existent — no-op + } + + return srv.store.AddGroupMemberSCIMManaged(ctx, mappedGroupID, userID, orgID) +} + +// syncNotifGroupRemove removes a user from a notification group, but only if: +// - The membership is scim_managed=true (manual memberships are preserved) +// - No other SCIM group with the same mapped_group_id still includes the user +func (srv *Server) syncNotifGroupRemove(ctx context.Context, orgID, userID, mappedGroupID, scimGroupID uuid.UUID) error { + // Check if another SCIM group maps to the same notification group and includes this user. + count, err := srv.store.CountOtherSCIMGroupsWithSameMapping(ctx, orgID, userID, mappedGroupID, scimGroupID) + if err != nil { + return err + } + if count > 0 { + return nil // another SCIM group still maps here — keep the membership + } + + return srv.store.RemoveSCIMManagedGroupMember(ctx, mappedGroupID, userID, orgID) +} diff --git a/internal/api/scim_notif_sync_test.go b/internal/api/scim_notif_sync_test.go new file mode 100644 index 00000000..b84a7fd5 --- /dev/null +++ b/internal/api/scim_notif_sync_test.go @@ -0,0 +1,310 @@ +// ABOUTME: Integration tests for notification group sync from SCIM group mappings. +// ABOUTME: Uses real Postgres via testutil.NewTestDB to exercise the full store path. +package api + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/testutil" +) + +// findGroupMember searches the group members list for the given userID. +// Returns (scim_managed, found). +func findGroupMember(t *testing.T, db *testutil.TestDB, ctx context.Context, orgID, groupID, userID uuid.UUID) (scimManaged, found bool) { + t.Helper() + members, err := db.ListGroupMembers(ctx, orgID, groupID) + if err != nil { + t.Fatalf("ListGroupMembers: %v", err) + } + for _, m := range members { + if m.UserID == userID { + return m.ScimManaged, true + } + } + return false, false +} + +func TestNotifSync_Add_NewMember(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + org := db.MustCreateOrg(t, ctx, "notif-add-"+uuid.New().String()[:8]) + user := db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "User", "hash", 1) + if err := db.CreateOrgMember(ctx, org.ID, user.ID, "member"); err != nil { + t.Fatalf("setup: %v", err) + } + + notifGroup := db.MustCreateGroup(t, ctx, org.ID, "Alerts", "Alert group") + scimGroup, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Engineering") + if err != nil { + t.Fatalf("setup: CreateSCIMGroup: %v", err) + } + + if err := srv.syncNotifGroupAdd(ctx, org.ID, user.ID, notifGroup.ID, scimGroup.ID); err != nil { + t.Fatalf("syncNotifGroupAdd: %v", err) + } + + scimManaged, found := findGroupMember(t, db, ctx, org.ID, notifGroup.ID, user.ID) + if !found { + t.Fatal("user should be a member of the notification group") + } + if !scimManaged { + t.Error("membership should be scim_managed=true") + } +} + +func TestNotifSync_Add_AlreadyManualMember(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + org := db.MustCreateOrg(t, ctx, "notif-manual-"+uuid.New().String()[:8]) + user := db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "User", "hash", 1) + if err := db.CreateOrgMember(ctx, org.ID, user.ID, "member"); err != nil { + t.Fatalf("setup: %v", err) + } + + notifGroup := db.MustCreateGroup(t, ctx, org.ID, "Alerts", "Alert group") + scimGroup, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Engineering") + if err != nil { + t.Fatalf("setup: CreateSCIMGroup: %v", err) + } + + // Add as manual member first (scim_managed=false). + if err := db.AddGroupMember(ctx, org.ID, notifGroup.ID, user.ID); err != nil { + t.Fatalf("setup: AddGroupMember: %v", err) + } + + // SCIM sync should not overwrite manual membership. + if err := srv.syncNotifGroupAdd(ctx, org.ID, user.ID, notifGroup.ID, scimGroup.ID); err != nil { + t.Fatalf("syncNotifGroupAdd: %v", err) + } + + scimManaged, found := findGroupMember(t, db, ctx, org.ID, notifGroup.ID, user.ID) + if !found { + t.Fatal("user should still be a member") + } + if scimManaged { + t.Error("scim_managed should remain false (manual membership takes precedence)") + } +} + +func TestNotifSync_Remove_SCIMManaged(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + org := db.MustCreateOrg(t, ctx, "notif-rm-"+uuid.New().String()[:8]) + user := db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "User", "hash", 1) + if err := db.CreateOrgMember(ctx, org.ID, user.ID, "member"); err != nil { + t.Fatalf("setup: %v", err) + } + + notifGroup := db.MustCreateGroup(t, ctx, org.ID, "Alerts", "Alert group") + scimGroup, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Engineering") + if err != nil { + t.Fatalf("setup: CreateSCIMGroup: %v", err) + } + + // Add via SCIM (scim_managed=true). + if err := db.AddGroupMemberSCIMManaged(ctx, notifGroup.ID, user.ID, org.ID); err != nil { + t.Fatalf("setup: AddGroupMemberSCIMManaged: %v", err) + } + + if err := srv.syncNotifGroupRemove(ctx, org.ID, user.ID, notifGroup.ID, scimGroup.ID); err != nil { + t.Fatalf("syncNotifGroupRemove: %v", err) + } + + _, found := findGroupMember(t, db, ctx, org.ID, notifGroup.ID, user.ID) + if found { + t.Error("user should have been removed from the notification group") + } +} + +func TestNotifSync_Remove_ManualMember(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + org := db.MustCreateOrg(t, ctx, "notif-rm-manual-"+uuid.New().String()[:8]) + user := db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "User", "hash", 1) + if err := db.CreateOrgMember(ctx, org.ID, user.ID, "member"); err != nil { + t.Fatalf("setup: %v", err) + } + + notifGroup := db.MustCreateGroup(t, ctx, org.ID, "Alerts", "Alert group") + scimGroup, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Engineering") + if err != nil { + t.Fatalf("setup: CreateSCIMGroup: %v", err) + } + + // Add as manual member (scim_managed=false). + if err := db.AddGroupMember(ctx, org.ID, notifGroup.ID, user.ID); err != nil { + t.Fatalf("setup: AddGroupMember: %v", err) + } + + // SCIM removal should not affect manual membership. + if err := srv.syncNotifGroupRemove(ctx, org.ID, user.ID, notifGroup.ID, scimGroup.ID); err != nil { + t.Fatalf("syncNotifGroupRemove: %v", err) + } + + _, found := findGroupMember(t, db, ctx, org.ID, notifGroup.ID, user.ID) + if !found { + t.Error("manual member should not be removed by SCIM sync") + } +} + +func TestNotifSync_Remove_MultiMapping(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + org := db.MustCreateOrg(t, ctx, "notif-multi-"+uuid.New().String()[:8]) + user := db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "User", "hash", 1) + if err := db.CreateOrgMember(ctx, org.ID, user.ID, "member"); err != nil { + t.Fatalf("setup: %v", err) + } + + notifGroup := db.MustCreateGroup(t, ctx, org.ID, "Alerts", "Alert group") + + // Two SCIM groups, both mapped to the same notification group. + scimGroupA, err := db.CreateSCIMGroup(ctx, org.ID, nil, "GroupA") + if err != nil { + t.Fatalf("setup: CreateSCIMGroup A: %v", err) + } + scimGroupB, err := db.CreateSCIMGroup(ctx, org.ID, nil, "GroupB") + if err != nil { + t.Fatalf("setup: CreateSCIMGroup B: %v", err) + } + + // Map both SCIM groups to the same notification group. + notifGroupID := notifGroup.ID + if err := db.UpdateSCIMGroupMapping(ctx, org.ID, scimGroupA.ID, nil, ¬ifGroupID); err != nil { + t.Fatalf("setup: UpdateSCIMGroupMapping A: %v", err) + } + if err := db.UpdateSCIMGroupMapping(ctx, org.ID, scimGroupB.ID, nil, ¬ifGroupID); err != nil { + t.Fatalf("setup: UpdateSCIMGroupMapping B: %v", err) + } + + // Add user to both SCIM groups. + if err := db.AddSCIMGroupMember(ctx, scimGroupA.ID, user.ID, org.ID); err != nil { + t.Fatalf("setup: AddSCIMGroupMember A: %v", err) + } + if err := db.AddSCIMGroupMember(ctx, scimGroupB.ID, user.ID, org.ID); err != nil { + t.Fatalf("setup: AddSCIMGroupMember B: %v", err) + } + + // Add SCIM-managed membership to notification group. + if err := db.AddGroupMemberSCIMManaged(ctx, notifGroup.ID, user.ID, org.ID); err != nil { + t.Fatalf("setup: AddGroupMemberSCIMManaged: %v", err) + } + + // Remove from SCIM group A — user still in group B with same mapping. + if err := srv.syncNotifGroupRemove(ctx, org.ID, user.ID, notifGroup.ID, scimGroupA.ID); err != nil { + t.Fatalf("syncNotifGroupRemove (A): %v", err) + } + _, found := findGroupMember(t, db, ctx, org.ID, notifGroup.ID, user.ID) + if !found { + t.Fatal("user should still be a member (another SCIM group maps to the same notification group)") + } + + // Now remove from SCIM group B — no more mappings, should remove. + if err := db.RemoveSCIMGroupMember(ctx, org.ID, scimGroupA.ID, user.ID); err != nil { + t.Fatalf("RemoveSCIMGroupMember A: %v", err) + } + if err := srv.syncNotifGroupRemove(ctx, org.ID, user.ID, notifGroup.ID, scimGroupB.ID); err != nil { + t.Fatalf("syncNotifGroupRemove (B): %v", err) + } + _, found = findGroupMember(t, db, ctx, org.ID, notifGroup.ID, user.ID) + if found { + t.Error("user should have been removed after last SCIM group mapping removed") + } +} + +func TestNotifSync_GroupDelete_NoRemoval(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "notif-del-"+uuid.New().String()[:8]) + user := db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "User", "hash", 1) + if err := db.CreateOrgMember(ctx, org.ID, user.ID, "member"); err != nil { + t.Fatalf("setup: %v", err) + } + + notifGroup := db.MustCreateGroup(t, ctx, org.ID, "Alerts", "Alert group") + scimGroup, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Engineering") + if err != nil { + t.Fatalf("setup: CreateSCIMGroup: %v", err) + } + + // Add membership via SCIM. + if err := db.AddGroupMemberSCIMManaged(ctx, notifGroup.ID, user.ID, org.ID); err != nil { + t.Fatalf("setup: AddGroupMemberSCIMManaged: %v", err) + } + + // Add user to SCIM group, then delete SCIM group (CASCADE deletes scim_group_members). + if err := db.AddSCIMGroupMember(ctx, scimGroup.ID, user.ID, org.ID); err != nil { + t.Fatalf("setup: AddSCIMGroupMember: %v", err) + } + if err := db.DeleteSCIMGroup(ctx, org.ID, scimGroup.ID); err != nil { + t.Fatalf("DeleteSCIMGroup: %v", err) + } + + // Notification group membership should still exist. + _, found := findGroupMember(t, db, ctx, org.ID, notifGroup.ID, user.ID) + if !found { + t.Error("notification group membership should survive SCIM group deletion") + } +} + +func TestNotifSync_SoftDeletedTargetGroup(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + org := db.MustCreateOrg(t, ctx, "notif-soft-"+uuid.New().String()[:8]) + user := db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "User", "hash", 1) + if err := db.CreateOrgMember(ctx, org.ID, user.ID, "member"); err != nil { + t.Fatalf("setup: %v", err) + } + + notifGroup := db.MustCreateGroup(t, ctx, org.ID, "Alerts", "Alert group") + scimGroup, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Engineering") + if err != nil { + t.Fatalf("setup: CreateSCIMGroup: %v", err) + } + + // Soft-delete the notification group. + if err := db.SoftDeleteGroup(ctx, org.ID, notifGroup.ID); err != nil { + t.Fatalf("SoftDeleteGroup: %v", err) + } + + // syncNotifGroupAdd should be a no-op — no error, no row created. + if err := srv.syncNotifGroupAdd(ctx, org.ID, user.ID, notifGroup.ID, scimGroup.ID); err != nil { + t.Fatalf("syncNotifGroupAdd: %v", err) + } + + // The group is soft-deleted, so ListGroupMembers may not find it through + // the normal org-scoped path. Query directly to verify no membership was created. + // We use ListGroupMembers which joins group_members regardless of the group's deleted_at. + members, err := db.ListGroupMembers(ctx, org.ID, notifGroup.ID) + if err != nil { + t.Fatalf("ListGroupMembers: %v", err) + } + for _, m := range members { + if m.UserID == user.ID { + t.Error("no membership should be created for a soft-deleted notification group") + } + } +} diff --git a/internal/api/scim_ratelimit.go b/internal/api/scim_ratelimit.go new file mode 100644 index 00000000..e0405a05 --- /dev/null +++ b/internal/api/scim_ratelimit.go @@ -0,0 +1,110 @@ +// ABOUTME: Per-org rate limiter for SCIM endpoints. +// ABOUTME: Separate from the API rate limiter. Keyed by org_id UUID from context. +package api + +import ( + "net/http" + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/time/rate" + + "github.com/scarson/cvert-ops/internal/secure" +) + +type scimRateLimiter struct { + mu sync.Mutex + limiters map[uuid.UUID]*scimRateEntry + r rate.Limit + burst int + evictTTL time.Duration + done chan struct{} +} + +type scimRateEntry struct { + limiter *rate.Limiter + lastAt time.Time +} + +func newSCIMRateLimiter(r rate.Limit, burst int, evictTTL time.Duration) *scimRateLimiter { + rl := &scimRateLimiter{ + limiters: make(map[uuid.UUID]*scimRateEntry), + r: r, + burst: burst, + evictTTL: evictTTL, + done: make(chan struct{}), + } + go rl.cleanupLoop() + return rl +} + +// Stop terminates the background cleanup goroutine. +func (rl *scimRateLimiter) Stop() { + close(rl.done) +} + +// Allow reports whether the given org is within its SCIM rate limit. +func (rl *scimRateLimiter) Allow(orgID uuid.UUID) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + e, ok := rl.limiters[orgID] + if !ok { + e = &scimRateEntry{limiter: rate.NewLimiter(rl.r, rl.burst)} + rl.limiters[orgID] = e + } + e.lastAt = time.Now() + return e.limiter.Allow() +} + +func (rl *scimRateLimiter) cleanupLoop() { + ticker := time.NewTicker(rl.evictTTL / 2) + defer ticker.Stop() + for { + select { + case <-ticker.C: + rl.mu.Lock() + cutoff := time.Now().Add(-rl.evictTTL) + for id, e := range rl.limiters { + if e.lastAt.Before(cutoff) { + delete(rl.limiters, id) + } + } + rl.mu.Unlock() + case <-rl.done: + return + } + } +} + +// scimRateLimit returns a chi middleware that enforces per-org SCIM rate limiting. +// Must run after requireSCIMAuth (which injects ctxOrgID into context). +// The rate limiter is stored on the Server and stopped via Close(). +func (srv *Server) scimRateLimit() func(http.Handler) http.Handler { + rateVal := srv.cfg.SCIMRateLimit + if rateVal <= 0 { + rateVal = 50 + } + rl := newSCIMRateLimiter(rate.Limit(rateVal), int(rateVal), 15*time.Minute) + srv.scimRL = rl + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + orgID, ok := r.Context().Value(ctxOrgID).(uuid.UUID) + if !ok { + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + if !rl.Allow(orgID) { + srv.fireSCIMEvent(r.Context(), secure.EventSCIMRateLimited, &orgID) + w.Header().Set("Retry-After", "1") + writeSCIMError(w, http.StatusTooManyRequests, "", "Rate limit exceeded") + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/api/scim_ratelimit_test.go b/internal/api/scim_ratelimit_test.go new file mode 100644 index 00000000..f1dd5983 --- /dev/null +++ b/internal/api/scim_ratelimit_test.go @@ -0,0 +1,223 @@ +// ABOUTME: Tests for the dedicated SCIM per-org rate limiter. +// ABOUTME: Validates per-org enforcement, cross-org independence, and SCIM error format. +package api + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/auth" + "github.com/scarson/cvert-ops/internal/config" + "github.com/scarson/cvert-ops/internal/secure" + "github.com/scarson/cvert-ops/internal/testutil" +) + +// scimRLTestEnv bundles a test server with SCIM auth + rate limiter mounted. +type scimRLTestEnv struct { + ts *httptest.Server + srv *Server + db *testutil.TestDB + orgID uuid.UUID + rawToken string +} + +// newSCIMRLTestEnv sets up a test environment for SCIM rate limiter tests. +// The rate limit is set to rateLimit req/sec. +func newSCIMRLTestEnv(t *testing.T, rateLimit float64) *scimRLTestEnv { + t.Helper() + + db := testutil.NewTestDB(t) + ctx := context.Background() + + org, err := db.CreateOrg(ctx, "scim-rl-org") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + + ssoConn, err := db.CreateSSOConnection(ctx, org.ID, "RL IdP", + "https://idp.example.com", "client-id", []byte("encrypted"), nil, true) + if err != nil { + t.Fatalf("CreateSSOConnection: %v", err) + } + + rawToken, tokenHash, tokenPrefix, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken: %v", err) + } + + _, err = db.CreateSCIMConfig(ctx, org.ID, ssoConn.ID, true, tokenHash, tokenPrefix, "viewer") + if err != nil { + t.Fatalf("CreateSCIMConfig: %v", err) + } + + ew := secure.NewEventWriter(db.Store) + + cfg := &config.Config{ //nolint:exhaustruct,gosec // test: only relevant fields set; G101 false positive + JWTSecret: "scim-rl-test-secret-32bytes-min", + SCIMRateLimit: rateLimit, + } + srv, err := NewServer(db.Store, cfg, ServerDeps{EventWriter: ew}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + t.Cleanup(srv.Close) + + r := chi.NewRouter() + r.Route("/api/v1/orgs/{org_id}/scim/v2", func(sub chi.Router) { + sub.Use(srv.requireSCIMAuth) + sub.Use(srv.scimRateLimit()) + sub.Get("/test", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + }) + + ts := httptest.NewServer(r) + t.Cleanup(ts.Close) + + return &scimRLTestEnv{ + ts: ts, + srv: srv, + db: db, + orgID: org.ID, + rawToken: rawToken, + } +} + +func TestSCIMRateLimit_EnforcesLimit(t *testing.T) { + t.Parallel() + // 2 req/sec — first 2 succeed, rest get 429. + env := newSCIMRLTestEnv(t, 2) + + var successCount, limitedCount int + for i := 0; i < 5; i++ { + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, + env.ts.URL+"/api/v1/orgs/"+env.orgID.String()+"/scim/v2/test", nil) + req.Header.Set("Authorization", "Bearer "+env.rawToken) + + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("request %d: %v", i, err) + } + switch resp.StatusCode { + case http.StatusOK: + successCount++ + case http.StatusTooManyRequests: + limitedCount++ + // Verify SCIM error format on 429 responses. + ct := resp.Header.Get("Content-Type") + if ct != "application/scim+json" { + t.Errorf("429 Content-Type = %q, want %q", ct, "application/scim+json") + } + default: + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Errorf("request %d: unexpected status %d (body: %s)", i, resp.StatusCode, string(body)) + } + resp.Body.Close() //nolint:errcheck,gosec // G104: test cleanup + } + + if successCount == 0 { + t.Error("expected at least 1 successful request, got 0") + } + if limitedCount == 0 { + t.Error("expected at least 1 rate-limited request, got 0") + } + t.Logf("success=%d limited=%d", successCount, limitedCount) +} + +func TestSCIMRateLimit_PerOrg(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + // Create two orgs, each with SCIM config. + orgA, err := db.CreateOrg(ctx, "scim-rl-orgA") + if err != nil { + t.Fatalf("CreateOrg A: %v", err) + } + ssoA, err := db.CreateSSOConnection(ctx, orgA.ID, "IdP A", + "https://idp-a.example.com", "client-a", []byte("enc"), nil, true) + if err != nil { + t.Fatalf("CreateSSOConnection A: %v", err) + } + rawA, hashA, prefA, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken A: %v", err) + } + _, err = db.CreateSCIMConfig(ctx, orgA.ID, ssoA.ID, true, hashA, prefA, "viewer") + if err != nil { + t.Fatalf("CreateSCIMConfig A: %v", err) + } + + orgB, err := db.CreateOrg(ctx, "scim-rl-orgB") + if err != nil { + t.Fatalf("CreateOrg B: %v", err) + } + ssoB, err := db.CreateSSOConnection(ctx, orgB.ID, "IdP B", + "https://idp-b.example.com", "client-b", []byte("enc"), nil, true) + if err != nil { + t.Fatalf("CreateSSOConnection B: %v", err) + } + rawB, hashB, prefB, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken B: %v", err) + } + _, err = db.CreateSCIMConfig(ctx, orgB.ID, ssoB.ID, true, hashB, prefB, "viewer") + if err != nil { + t.Fatalf("CreateSCIMConfig B: %v", err) + } + + ew := secure.NewEventWriter(db.Store) + cfg := &config.Config{ //nolint:exhaustruct,gosec // test: only relevant fields set; G101 false positive + JWTSecret: "scim-rl-perorg-secret-32bytes-m", + SCIMRateLimit: 2, // 2 req/sec + } + srv, err := NewServer(db.Store, cfg, ServerDeps{EventWriter: ew}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + t.Cleanup(srv.Close) + + r := chi.NewRouter() + r.Route("/api/v1/orgs/{org_id}/scim/v2", func(sub chi.Router) { + sub.Use(srv.requireSCIMAuth) + sub.Use(srv.scimRateLimit()) + sub.Get("/test", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + }) + ts := httptest.NewServer(r) + t.Cleanup(ts.Close) + + // Exhaust org A's rate limit. + for i := 0; i < 5; i++ { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + ts.URL+"/api/v1/orgs/"+orgA.ID.String()+"/scim/v2/test", nil) + req.Header.Set("Authorization", "Bearer "+rawA) + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("orgA request %d: %v", i, err) + } + resp.Body.Close() //nolint:errcheck,gosec // G104: test cleanup + } + + // Org B should still succeed. + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + ts.URL+"/api/v1/orgs/"+orgB.ID.String()+"/scim/v2/test", nil) + req.Header.Set("Authorization", "Bearer "+rawB) + resp, err := ts.Client().Do(req) //nolint:gosec // G704 false positive: httptest URL + if err != nil { + t.Fatalf("orgB request: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Errorf("orgB status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } +} diff --git a/internal/api/scim_roles.go b/internal/api/scim_roles.go new file mode 100644 index 00000000..a0f44339 --- /dev/null +++ b/internal/api/scim_roles.go @@ -0,0 +1,83 @@ +// ABOUTME: Role recomputation from SCIM group mappings. +// ABOUTME: Called on group membership change and admin mapping change. Apply-on-write. +package api + +import ( + "context" + "log/slog" + + "github.com/google/uuid" +) + +// roleHierarchy maps SCIM-assignable roles to a numeric rank for comparison. +// Owner is excluded — SCIM never assigns owner. +var roleHierarchy = map[string]int{ + "viewer": 1, + "member": 2, + "admin": 3, +} + +// recomputeSCIMRole recalculates a user's org role based on their SCIM group +// memberships and the groups' mapped roles. If the user is an owner or SCIM-exempt, +// their role is left unchanged. If no mapped roles exist, the defaultRole is used. +func (srv *Server) recomputeSCIMRole(ctx context.Context, orgID, userID uuid.UUID, defaultRole string) error { + // 1. Load current membership. + member, err := srv.store.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + return err + } + if member == nil { + return nil // not a member of this org + } + + // 2. Owner is always manual. + if member.Role == "owner" { + return nil + } + + // 3. SCIM-exempt users are not modified. + if member.ScimExempt { + return nil + } + + // 4. Load all SCIM groups the user belongs to. + groups, err := srv.store.ListUserSCIMGroups(ctx, userID, orgID) + if err != nil { + return err + } + + // 5. Find the highest mapped role. + highestRank := 0 + for _, g := range groups { + if g.MappedRole.Valid { + rank, ok := roleHierarchy[g.MappedRole.String] + if ok && rank > highestRank { + highestRank = rank + } + } + } + + // 6. Determine effective role. + effectiveRole := defaultRole + if highestRank > 0 { + for role, rank := range roleHierarchy { + if rank == highestRank { + effectiveRole = role + break + } + } + } + + // 7. Update if changed. + if effectiveRole != member.Role { + slog.InfoContext(ctx, "scim role recomputation changed role", + slog.String("org_id", orgID.String()), + slog.String("user_id", userID.String()), + slog.String("old_role", member.Role), + slog.String("new_role", effectiveRole), + ) + return srv.store.UpdateOrgMemberRole(ctx, orgID, userID, effectiveRole) + } + + return nil +} diff --git a/internal/api/scim_roles_test.go b/internal/api/scim_roles_test.go new file mode 100644 index 00000000..5d2aff0b --- /dev/null +++ b/internal/api/scim_roles_test.go @@ -0,0 +1,245 @@ +// ABOUTME: Integration tests for SCIM role recomputation from group mappings. +// ABOUTME: Uses real Postgres via testutil.NewTestDB to exercise the full store path. +package api + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/config" + "github.com/scarson/cvert-ops/internal/testutil" +) + +// newSCIMTestServer creates a minimal *Server with only the store populated. +// Sufficient for testing recomputeSCIMRole and notification group sync. +func newSCIMTestServer(t *testing.T, db *testutil.TestDB) *Server { + t.Helper() + cfg := &config.Config{ //nolint:exhaustruct // test: only relevant fields set + Argon2MaxConcurrent: 1, + } + srv, err := NewServer(db.Store, cfg, ServerDeps{}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + t.Cleanup(srv.Close) + return srv +} + +// scimRoleSetup creates an org, SSO connection, SCIM config, and an org member +// with the given role. Returns the org ID and user ID. +func scimRoleSetup(t *testing.T, db *testutil.TestDB, ctx context.Context, role string) (orgID, userID uuid.UUID) { + t.Helper() + + org := db.MustCreateOrg(t, ctx, "scimrole-"+uuid.New().String()[:8]) + user := db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "TestUser", "hash", 1) + + if err := db.CreateOrgMember(ctx, org.ID, user.ID, role); err != nil { + t.Fatalf("setup: CreateOrgMember: %v", err) + } + + return org.ID, user.ID +} + +// setupSCIMGroupWithMapping creates a SCIM group with a mapped role and adds the user. +func setupSCIMGroupWithMapping(t *testing.T, db *testutil.TestDB, ctx context.Context, orgID, userID uuid.UUID, groupName string, mappedRole *string) uuid.UUID { + t.Helper() + + group, err := db.CreateSCIMGroup(ctx, orgID, nil, groupName) + if err != nil { + t.Fatalf("setup: CreateSCIMGroup(%q): %v", groupName, err) + } + + if mappedRole != nil { + if err := db.UpdateSCIMGroupMapping(ctx, orgID, group.ID, mappedRole, nil); err != nil { + t.Fatalf("setup: UpdateSCIMGroupMapping: %v", err) + } + } + + if err := db.AddSCIMGroupMember(ctx, group.ID, userID, orgID); err != nil { + t.Fatalf("setup: AddSCIMGroupMember: %v", err) + } + + return group.ID +} + +func TestRoleRecompute_SingleGroup(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + orgID, userID := scimRoleSetup(t, db, ctx, "viewer") + adminRole := "admin" + setupSCIMGroupWithMapping(t, db, ctx, orgID, userID, "Admins", &adminRole) + + if err := srv.recomputeSCIMRole(ctx, orgID, userID, "viewer"); err != nil { + t.Fatalf("recomputeSCIMRole: %v", err) + } + + member, err := db.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member.Role != "admin" { + t.Errorf("role = %q, want %q", member.Role, "admin") + } +} + +func TestRoleRecompute_MultipleGroups_HighestWins(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + orgID, userID := scimRoleSetup(t, db, ctx, "viewer") + + memberRole := "member" + adminRole := "admin" + setupSCIMGroupWithMapping(t, db, ctx, orgID, userID, "Members", &memberRole) + setupSCIMGroupWithMapping(t, db, ctx, orgID, userID, "Admins", &adminRole) + + if err := srv.recomputeSCIMRole(ctx, orgID, userID, "viewer"); err != nil { + t.Fatalf("recomputeSCIMRole: %v", err) + } + + member, err := db.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member.Role != "admin" { + t.Errorf("role = %q, want %q (highest mapped role)", member.Role, "admin") + } +} + +func TestRoleRecompute_NoMappedGroups(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + orgID, userID := scimRoleSetup(t, db, ctx, "admin") + + // Group with no mapped_role (NULL). + setupSCIMGroupWithMapping(t, db, ctx, orgID, userID, "NoMapping", nil) + + if err := srv.recomputeSCIMRole(ctx, orgID, userID, "viewer"); err != nil { + t.Fatalf("recomputeSCIMRole: %v", err) + } + + member, err := db.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member.Role != "viewer" { + t.Errorf("role = %q, want %q (default_role fallback)", member.Role, "viewer") + } +} + +func TestRoleRecompute_NeverSetsOwner(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + orgID, userID := scimRoleSetup(t, db, ctx, "viewer") + + // Even if someone mapped a group to admin (highest SCIM-assignable role), + // owner should never be assigned by SCIM. + adminRole := "admin" + setupSCIMGroupWithMapping(t, db, ctx, orgID, userID, "Admins", &adminRole) + + if err := srv.recomputeSCIMRole(ctx, orgID, userID, "viewer"); err != nil { + t.Fatalf("recomputeSCIMRole: %v", err) + } + + member, err := db.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member.Role == "owner" { + t.Error("SCIM role recomputation must never assign owner role") + } +} + +func TestRoleRecompute_SCIMExempt_Skipped(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + orgID, userID := scimRoleSetup(t, db, ctx, "viewer") + + // Mark user as SCIM-exempt. + if err := db.UpdateOrgMemberSCIMExempt(ctx, orgID, userID, true); err != nil { + t.Fatalf("setup: UpdateOrgMemberSCIMExempt: %v", err) + } + + adminRole := "admin" + setupSCIMGroupWithMapping(t, db, ctx, orgID, userID, "Admins", &adminRole) + + if err := srv.recomputeSCIMRole(ctx, orgID, userID, "viewer"); err != nil { + t.Fatalf("recomputeSCIMRole: %v", err) + } + + member, err := db.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member.Role != "viewer" { + t.Errorf("role = %q, want %q (SCIM-exempt user should not be modified)", member.Role, "viewer") + } +} + +func TestRoleRecompute_OwnerNotDowngraded(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + orgID, userID := scimRoleSetup(t, db, ctx, "owner") + + viewerRole := "viewer" + setupSCIMGroupWithMapping(t, db, ctx, orgID, userID, "Viewers", &viewerRole) + + if err := srv.recomputeSCIMRole(ctx, orgID, userID, "viewer"); err != nil { + t.Fatalf("recomputeSCIMRole: %v", err) + } + + member, err := db.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member.Role != "owner" { + t.Errorf("role = %q, want %q (owner must not be downgraded by SCIM)", member.Role, "owner") + } +} + +func TestRoleRecompute_RemovedFromAllGroups(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv := newSCIMTestServer(t, db) + + orgID, userID := scimRoleSetup(t, db, ctx, "admin") + + // Add to a group with mapped role, then remove. + adminRole := "admin" + groupID := setupSCIMGroupWithMapping(t, db, ctx, orgID, userID, "Admins", &adminRole) + if err := db.RemoveSCIMGroupMember(ctx, orgID, groupID, userID); err != nil { + t.Fatalf("RemoveSCIMGroupMember: %v", err) + } + + if err := srv.recomputeSCIMRole(ctx, orgID, userID, "viewer"); err != nil { + t.Fatalf("recomputeSCIMRole: %v", err) + } + + member, err := db.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member.Role != "viewer" { + t.Errorf("role = %q, want %q (should fall back to defaultRole)", member.Role, "viewer") + } +} diff --git a/internal/api/scim_types.go b/internal/api/scim_types.go new file mode 100644 index 00000000..e272045b --- /dev/null +++ b/internal/api/scim_types.go @@ -0,0 +1,169 @@ +// ABOUTME: SCIM 2.0 request/response types and JSON helpers (RFC 7643/7644). +// ABOUTME: Used by all SCIM chi handlers. Separate from huma types. +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// SCIMError represents an RFC 7644 §3.12 error response. +type SCIMError struct { + Schemas []string `json:"schemas"` + Status string `json:"status"` + SCIMType string `json:"scimType,omitempty"` + Detail string `json:"detail"` +} + +// SCIMUser represents a SCIM 2.0 User resource (RFC 7643 §4.1). +type SCIMUser struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + ExternalID string `json:"externalId"` + UserName string `json:"userName"` + DisplayName string `json:"displayName"` + Active bool `json:"active"` + Meta SCIMMeta `json:"meta"` +} + +// SCIMGroup represents a SCIM 2.0 Group resource (RFC 7643 §4.2). +type SCIMGroup struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + ExternalID string `json:"externalId,omitempty"` + DisplayName string `json:"displayName"` + Members []SCIMGroupMember `json:"members"` + Meta SCIMMeta `json:"meta"` +} + +// SCIMGroupMember represents a member reference within a SCIM Group. +type SCIMGroupMember struct { + Value string `json:"value"` + Display string `json:"display,omitempty"` + Ref string `json:"$ref,omitempty"` +} + +// SCIMListResponse represents a SCIM 2.0 ListResponse (RFC 7644 §3.4.2). +type SCIMListResponse struct { + Schemas []string `json:"schemas"` + TotalResults int `json:"totalResults"` + ItemsPerPage int `json:"itemsPerPage"` + StartIndex int `json:"startIndex"` + Resources []any `json:"Resources"` +} + +// SCIMMeta represents resource metadata in SCIM responses. +type SCIMMeta struct { + ResourceType string `json:"resourceType"` + Created string `json:"created"` + LastModified string `json:"lastModified"` + Location string `json:"location"` +} + +// SCIMPatchOp represents a SCIM 2.0 PATCH request (RFC 7644 §3.5.2). +type SCIMPatchOp struct { + Schemas []string `json:"schemas"` + Operations []SCIMPatchOperation `json:"Operations"` +} + +// SCIMPatchOperation represents a single operation within a SCIM PATCH request. +type SCIMPatchOperation struct { + Op string `json:"op"` + Path string `json:"path,omitempty"` + Value json.RawMessage `json:"value"` +} + +// SCIMFilterExpr represents a parsed SCIM filter expression (attr op value). +type SCIMFilterExpr struct { + Attr string + Op string + Value string +} + +const scimContentType = "application/scim+json" + +// writeSCIMError writes an RFC 7644 §3.12 error response. +func writeSCIMError(w http.ResponseWriter, status int, scimType, detail string) { + e := SCIMError{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:Error"}, + Status: fmt.Sprintf("%d", status), + Detail: detail, + } + if scimType != "" { + e.SCIMType = scimType + } + writeSCIMJSON(w, status, e) +} + +// writeSCIMJSON writes any value as JSON with Content-Type: application/scim+json. +func writeSCIMJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", scimContentType) + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) //nolint:errcheck // best-effort response write +} + +// parseSCIMBool parses a JSON boolean or Entra ID string boolean ("True"/"False"). +func parseSCIMBool(v json.RawMessage) (bool, error) { + // Try native JSON boolean first. + var b bool + if err := json.Unmarshal(v, &b); err == nil { + return b, nil + } + + // Try string boolean (Entra ID sends "True"/"False"). + var s string + if err := json.Unmarshal(v, &s); err == nil { + switch strings.ToLower(s) { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid boolean string: %q", s) + } + } + + return false, fmt.Errorf("cannot parse %s as boolean", string(v)) +} + +// parseSCIMFilter parses a minimal SCIM filter string (RFC 7644 §3.4.2.2). +// Only the "eq" operator is supported. Compound filters are split on " and ". +func parseSCIMFilter(filter string) ([]SCIMFilterExpr, error) { + filter = strings.TrimSpace(filter) + if filter == "" { + return nil, nil + } + + parts := strings.Split(filter, " and ") + exprs := make([]SCIMFilterExpr, 0, len(parts)) + + for _, part := range parts { + part = strings.TrimSpace(part) + // Split into exactly 3 tokens: attr, op, value + tokens := strings.SplitN(part, " ", 3) + if len(tokens) != 3 { + return nil, fmt.Errorf("invalid filter expression: %q", part) + } + + attr := tokens[0] + op := strings.ToLower(tokens[1]) + value := tokens[2] + + if op != "eq" { + return nil, fmt.Errorf("unsupported filter operator: %q", op) + } + + // Strip surrounding quotes from value if present. + value = strings.Trim(value, "\"") + + exprs = append(exprs, SCIMFilterExpr{ + Attr: attr, + Op: op, + Value: value, + }) + } + + return exprs, nil +} diff --git a/internal/api/scim_types_test.go b/internal/api/scim_types_test.go new file mode 100644 index 00000000..6f68b09a --- /dev/null +++ b/internal/api/scim_types_test.go @@ -0,0 +1,274 @@ +// ABOUTME: Tests for SCIM 2.0 response types, error helper, bool parser, and filter parser. +// ABOUTME: Validates RFC 7644 compliance for error format and content type. +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWriteSCIMError(t *testing.T) { + t.Parallel() + + t.Run("full error with scimType", func(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + writeSCIMError(w, http.StatusConflict, "uniqueness", "userName already exists") + + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want %d", w.Code, http.StatusConflict) + } + + ct := w.Header().Get("Content-Type") + if ct != "application/scim+json" { + t.Errorf("Content-Type = %q, want %q", ct, "application/scim+json") + } + + var body SCIMError + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if len(body.Schemas) != 1 || body.Schemas[0] != "urn:ietf:params:scim:api:messages:2.0:Error" { + t.Errorf("schemas = %v, want error schema", body.Schemas) + } + if body.Status != "409" { + t.Errorf("status = %q, want %q", body.Status, "409") + } + if body.SCIMType != "uniqueness" { + t.Errorf("scimType = %q, want %q", body.SCIMType, "uniqueness") + } + if body.Detail != "userName already exists" { + t.Errorf("detail = %q, want %q", body.Detail, "userName already exists") + } + }) + + t.Run("scimType omitted when empty", func(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + writeSCIMError(w, http.StatusForbidden, "", "SCIM provisioning is disabled") + + raw := w.Body.Bytes() + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("decode: %v", err) + } + if _, exists := m["scimType"]; exists { + t.Error("scimType should be omitted when empty") + } + }) +} + +func TestWriteSCIMJSON(t *testing.T) { + t.Parallel() + + w := httptest.NewRecorder() + writeSCIMJSON(w, http.StatusCreated, map[string]string{"key": "value"}) + + if w.Code != http.StatusCreated { + t.Errorf("status = %d, want %d", w.Code, http.StatusCreated) + } + + ct := w.Header().Get("Content-Type") + if ct != "application/scim+json" { + t.Errorf("Content-Type = %q, want %q", ct, "application/scim+json") + } + + var m map[string]string + if err := json.NewDecoder(w.Body).Decode(&m); err != nil { + t.Fatalf("decode: %v", err) + } + if m["key"] != "value" { + t.Errorf("body key = %q, want %q", m["key"], "value") + } +} + +func TestParseSCIMBool_JSONBool(t *testing.T) { + t.Parallel() + + cases := []struct { + input json.RawMessage + want bool + }{ + {json.RawMessage(`true`), true}, + {json.RawMessage(`false`), false}, + } + + for _, tc := range cases { + got, err := parseSCIMBool(tc.input) + if err != nil { + t.Errorf("parseSCIMBool(%s) error: %v", tc.input, err) + } + if got != tc.want { + t.Errorf("parseSCIMBool(%s) = %v, want %v", tc.input, got, tc.want) + } + } +} + +func TestParseSCIMBool_StringBool(t *testing.T) { + t.Parallel() + + cases := []struct { + input json.RawMessage + want bool + }{ + {json.RawMessage(`"True"`), true}, + {json.RawMessage(`"False"`), false}, + {json.RawMessage(`"true"`), true}, + {json.RawMessage(`"false"`), false}, + } + + for _, tc := range cases { + got, err := parseSCIMBool(tc.input) + if err != nil { + t.Errorf("parseSCIMBool(%s) error: %v", tc.input, err) + } + if got != tc.want { + t.Errorf("parseSCIMBool(%s) = %v, want %v", tc.input, got, tc.want) + } + } +} + +func TestParseSCIMBool_Invalid(t *testing.T) { + t.Parallel() + + cases := []json.RawMessage{ + json.RawMessage(`"invalid"`), + json.RawMessage(`123`), + } + + for _, input := range cases { + _, err := parseSCIMBool(input) + if err == nil { + t.Errorf("parseSCIMBool(%s) expected error, got nil", input) + } + } +} + +func TestParseSCIMFilter_SingleEq(t *testing.T) { + t.Parallel() + + exprs, err := parseSCIMFilter(`userName eq "sam@example.com"`) + if err != nil { + t.Fatalf("parseSCIMFilter error: %v", err) + } + if len(exprs) != 1 { + t.Fatalf("got %d expressions, want 1", len(exprs)) + } + if exprs[0].Attr != "userName" { + t.Errorf("attr = %q, want %q", exprs[0].Attr, "userName") + } + if exprs[0].Op != "eq" { + t.Errorf("op = %q, want %q", exprs[0].Op, "eq") + } + if exprs[0].Value != "sam@example.com" { + t.Errorf("value = %q, want %q", exprs[0].Value, "sam@example.com") + } +} + +func TestParseSCIMFilter_Compound(t *testing.T) { + t.Parallel() + + exprs, err := parseSCIMFilter(`userName eq "sam" and active eq "true"`) + if err != nil { + t.Fatalf("parseSCIMFilter error: %v", err) + } + if len(exprs) != 2 { + t.Fatalf("got %d expressions, want 2", len(exprs)) + } + if exprs[0].Attr != "userName" || exprs[0].Value != "sam" { + t.Errorf("expr[0] = %+v, want userName eq sam", exprs[0]) + } + if exprs[1].Attr != "active" || exprs[1].Value != "true" { + t.Errorf("expr[1] = %+v, want active eq true", exprs[1]) + } +} + +func TestParseSCIMFilter_UnsupportedOp(t *testing.T) { + t.Parallel() + + _, err := parseSCIMFilter(`userName sw "sam"`) + if err == nil { + t.Fatal("expected error for unsupported operator") + } + if got := err.Error(); !contains(got, "sw") { + t.Errorf("error %q should mention unsupported operator %q", got, "sw") + } +} + +func TestParseSCIMFilter_Empty(t *testing.T) { + t.Parallel() + + exprs, err := parseSCIMFilter("") + if err != nil { + t.Fatalf("parseSCIMFilter error: %v", err) + } + if len(exprs) != 0 { + t.Errorf("got %d expressions, want 0", len(exprs)) + } +} + +func TestSCIMUser_JSON(t *testing.T) { + t.Parallel() + + user := SCIMUser{ + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + ID: "abc-123", + ExternalID: "ext-456", + UserName: "sam@example.com", + DisplayName: "Sam", + Active: true, + Meta: SCIMMeta{ + ResourceType: "User", + Created: "2026-01-01T00:00:00Z", + LastModified: "2026-01-02T00:00:00Z", + Location: "/scim/v2/Users/abc-123", + }, + } + + data, err := json.Marshal(user) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + schemas, ok := m["schemas"].([]any) + if !ok || len(schemas) != 1 { + t.Errorf("schemas = %v, want 1-element array", m["schemas"]) + } + + meta, ok := m["meta"].(map[string]any) + if !ok { + t.Fatalf("meta is not an object: %T", m["meta"]) + } + if meta["resourceType"] != "User" { + t.Errorf("meta.resourceType = %v, want %q", meta["resourceType"], "User") + } + + if m["userName"] != "sam@example.com" { + t.Errorf("userName = %v, want %q", m["userName"], "sam@example.com") + } + if m["active"] != true { + t.Errorf("active = %v, want true", m["active"]) + } +} + +// contains is a small helper to avoid importing strings in the test file +// (since the production code already imports it). +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/api/scim_users.go b/internal/api/scim_users.go new file mode 100644 index 00000000..f6c84736 --- /dev/null +++ b/internal/api/scim_users.go @@ -0,0 +1,1110 @@ +// ABOUTME: SCIM 2.0 User resource handlers (RFC 7643/7644). +// ABOUTME: Implements create, get, list, replace (PUT), patch, and delete for user provisioning. +package api + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/audit" + "github.com/scarson/cvert-ops/internal/secure" + "github.com/scarson/cvert-ops/internal/tier" +) + +// scimUserRequest is the JSON body for POST and PUT /Users. +type scimUserRequest struct { + Schemas []string `json:"schemas"` + ExternalID string `json:"externalId"` + UserName string `json:"userName"` + DisplayName string `json:"displayName"` + Active *bool `json:"active,omitempty"` +} + +// scimProvider returns the SCIM identity provider string for the given config ID. +func scimProvider(scimConfigID uuid.UUID) string { + return fmt.Sprintf("scim:%s", scimConfigID) +} + +// scimScheme returns "https" or "http" based on TLS state and X-Forwarded-Proto. +// Only trusts X-Forwarded-Proto if it's a valid scheme. +func scimScheme(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-Proto"); fwd == "https" || fwd == "http" { + return fwd + } + if r.TLS != nil { + return "https" + } + return "http" +} + +// scimUserLocation returns the SCIM resource location for a user. +func scimUserLocation(r *http.Request, orgID, userID uuid.UUID) string { + return fmt.Sprintf("%s://%s/api/v1/orgs/%s/scim/v2/Users/%s", + scimScheme(r), r.Host, orgID, userID) +} + +// buildSCIMUser constructs a SCIMUser response from component data. +func buildSCIMUser(r *http.Request, orgID, userID uuid.UUID, email, displayName, externalID string, active bool, createdAt, updatedAt time.Time) SCIMUser { + return SCIMUser{ + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + ID: userID.String(), + ExternalID: externalID, + UserName: email, + DisplayName: displayName, + Active: active, + Meta: SCIMMeta{ + ResourceType: "User", + Created: createdAt.UTC().Format(time.RFC3339), + LastModified: updatedAt.UTC().Format(time.RFC3339), + Location: scimUserLocation(r, orgID, userID), + }, + } +} + +// scimAuditMeta returns the standard SCIM audit metadata map. +func scimAuditMeta(scimConfigID uuid.UUID) map[string]any { + return map[string]any{"source": "scim", "scim_config_id": scimConfigID.String()} +} + +// scimCreateUser handles POST /Users — provision a new user. +func (srv *Server) scimCreateUser(w http.ResponseWriter, r *http.Request) { + orgID := r.Context().Value(ctxOrgID).(uuid.UUID) + scimConfigID := r.Context().Value(ctxSCIMConfigID).(uuid.UUID) + provider := scimProvider(scimConfigID) + ctx := r.Context() + + var req scimUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSCIMError(w, http.StatusBadRequest, "", "invalid JSON body") + return + } + + if strings.TrimSpace(req.UserName) == "" { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "userName is required") + return + } + + if strings.TrimSpace(req.ExternalID) == "" { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "externalId is required") + return + } + + slog.InfoContext(ctx, "scim create user", + slog.String("org_id", orgID.String()), + slog.String("scim_config_id", scimConfigID.String()), + slog.String("external_id", req.ExternalID), + slog.String("user_name", req.UserName), + ) + + // Step 1: Check user_identities for existing SCIM link. + existingUser, err := srv.store.GetUserByProviderID(ctx, provider, req.ExternalID) + if err != nil { + slog.ErrorContext(ctx, "scim create: lookup by provider id", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + if existingUser != nil { + // Found by externalId. Check org membership. + member, err := srv.store.GetOrgMemberFull(ctx, orgID, existingUser.ID) + if err != nil { + slog.ErrorContext(ctx, "scim create: get member for existing user", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + if member != nil { + if !member.DeactivatedAt.Valid { + // Active member — idempotent return. + identity, _ := srv.store.GetIdentityByProviderAndUser(ctx, provider, existingUser.ID) + extID := req.ExternalID + if identity != nil { + extID = identity.ProviderUserID + } + writeSCIMJSON(w, http.StatusOK, buildSCIMUser(r, orgID, existingUser.ID, + existingUser.Email, existingUser.DisplayName, extID, + true, member.CreatedAt, member.UpdatedAt)) + return + } + + // Deactivated member. + if member.ScimExempt { + slog.WarnContext(ctx, "scim create: exempt user, skipping reactivation", + slog.String("user_id", existingUser.ID.String())) + srv.fireSCIMEvent(ctx, secure.EventSCIMExemptSuppressed, &orgID) + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: existingUser.ID.String(), + Success: true, + Metadata: map[string]any{"source": "scim", "suppressed": true, "reason": "scim_exempt"}, + }) + identity, _ := srv.store.GetIdentityByProviderAndUser(ctx, provider, existingUser.ID) + extID := req.ExternalID + if identity != nil { + extID = identity.ProviderUserID + } + writeSCIMJSON(w, http.StatusOK, buildSCIMUser(r, orgID, existingUser.ID, + existingUser.Email, existingUser.DisplayName, extID, + false, member.CreatedAt, member.UpdatedAt)) + return + } + + // Reactivate. + if err := srv.store.ReactivateOrgMember(ctx, orgID, existingUser.ID); err != nil { + slog.ErrorContext(ctx, "scim create: reactivate member", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Re-read for updated timestamps. + member, _ = srv.store.GetOrgMemberFull(ctx, orgID, existingUser.ID) + identity, _ := srv.store.GetIdentityByProviderAndUser(ctx, provider, existingUser.ID) + extID := req.ExternalID + if identity != nil { + extID = identity.ProviderUserID + } + srv.fireSCIMEvent(ctx, secure.EventSCIMUserProvisioned, &orgID) + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: existingUser.ID.String(), + Success: true, + NewState: map[string]any{"active": true}, + Metadata: scimAuditMeta(scimConfigID), + }) + createdAt := existingUser.CreatedAt + updatedAt := existingUser.CreatedAt + if member != nil { + createdAt = member.CreatedAt + updatedAt = member.UpdatedAt + } + writeSCIMJSON(w, http.StatusOK, buildSCIMUser(r, orgID, existingUser.ID, + existingUser.Email, existingUser.DisplayName, extID, + true, createdAt, updatedAt)) + return + } + // User exists by provider ID but not a member — fall through to create membership. + } + + // Step 2: Lookup users by email. + if existingUser == nil { + existingUser, err = srv.store.GetUserByEmail(ctx, req.UserName) + if err != nil { + slog.ErrorContext(ctx, "scim create: lookup by email", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + } + + if existingUser != nil { + // Found by email (or by provider but not a member). + member, err := srv.store.GetOrgMemberFull(ctx, orgID, existingUser.ID) + if err != nil { + slog.ErrorContext(ctx, "scim create: get member for email user", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Link SCIM identity (withBypassTx — global table). + if err := srv.store.UpsertUserIdentity(ctx, existingUser.ID, provider, req.ExternalID, req.UserName); err != nil { + slog.ErrorContext(ctx, "scim create: link identity", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + if member != nil { + // Already a member. + if member.DeactivatedAt.Valid && !member.ScimExempt { + // Reactivate. + if err := srv.store.ReactivateOrgMember(ctx, orgID, existingUser.ID); err != nil { + slog.ErrorContext(ctx, "scim create: reactivate email user", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: existingUser.ID.String(), + Success: true, + NewState: map[string]any{"active": true}, + Metadata: scimAuditMeta(scimConfigID), + }) + } else if member.DeactivatedAt.Valid && member.ScimExempt { + slog.WarnContext(ctx, "scim create: exempt user, skipping reactivation", + slog.String("user_id", existingUser.ID.String())) + srv.fireSCIMEvent(ctx, secure.EventSCIMExemptSuppressed, &orgID) + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: existingUser.ID.String(), + Success: true, + Metadata: map[string]any{"source": "scim", "suppressed": true, "reason": "scim_exempt"}, + }) + } + + // Re-read for response. + member, _ = srv.store.GetOrgMemberFull(ctx, orgID, existingUser.ID) + active := member != nil && !member.DeactivatedAt.Valid + createdAt := existingUser.CreatedAt + updatedAt := existingUser.CreatedAt + if member != nil { + createdAt = member.CreatedAt + updatedAt = member.UpdatedAt + } + writeSCIMJSON(w, http.StatusOK, buildSCIMUser(r, orgID, existingUser.ID, + existingUser.Email, existingUser.DisplayName, req.ExternalID, + active, createdAt, updatedAt)) + return + } + + // Not a member — check tier limit and create membership. + if err := srv.checkSCIMMemberLimit(ctx, orgID); err != nil { + writeSCIMError(w, http.StatusForbidden, "", "member limit reached for this organization's tier") + return + } + + // Get default role from SCIM config. + defaultRole := srv.getSCIMDefaultRole(ctx, orgID) + + if err := srv.store.CreateOrgMember(ctx, orgID, existingUser.ID, defaultRole); err != nil { + slog.ErrorContext(ctx, "scim create: create membership for email user", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + srv.fireSCIMEvent(ctx, secure.EventSCIMUserProvisioned, &orgID) + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "create", + EntityType: "member", + EntityID: existingUser.ID.String(), + Success: true, + NewState: map[string]any{"role": defaultRole}, + Metadata: scimAuditMeta(scimConfigID), + }) + + member, _ = srv.store.GetOrgMemberFull(ctx, orgID, existingUser.ID) + createdAt := existingUser.CreatedAt + updatedAt := existingUser.CreatedAt + if member != nil { + createdAt = member.CreatedAt + updatedAt = member.UpdatedAt + } + writeSCIMJSON(w, http.StatusCreated, buildSCIMUser(r, orgID, existingUser.ID, + existingUser.Email, existingUser.DisplayName, req.ExternalID, + true, createdAt, updatedAt)) + return + } + + // Step 3: Create new user. + if err := srv.checkSCIMMemberLimit(ctx, orgID); err != nil { + writeSCIMError(w, http.StatusForbidden, "", "member limit reached for this organization's tier") + return + } + + displayName := req.DisplayName + if strings.TrimSpace(displayName) == "" { + displayName = req.UserName + } + + // Tx 1: create user + identity (withBypassTx — global tables). + newUser, err := srv.store.CreateUser(ctx, req.UserName, displayName, "", 0) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") { + writeSCIMError(w, http.StatusConflict, "uniqueness", "userName already exists") + return + } + slog.ErrorContext(ctx, "scim create: create user", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + if err := srv.store.UpsertUserIdentity(ctx, newUser.ID, provider, req.ExternalID, req.UserName); err != nil { + slog.ErrorContext(ctx, "scim create: create identity", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Tx 2: create org membership (withOrgTx — org-scoped). + defaultRole := srv.getSCIMDefaultRole(ctx, orgID) + + if err := srv.store.CreateOrgMember(ctx, orgID, newUser.ID, defaultRole); err != nil { + slog.ErrorContext(ctx, "scim create: create membership", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + srv.fireSCIMEvent(ctx, secure.EventSCIMUserProvisioned, &orgID) + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "create", + EntityType: "member", + EntityID: newUser.ID.String(), + Success: true, + NewState: map[string]any{"role": defaultRole}, + Metadata: scimAuditMeta(scimConfigID), + }) + + member, _ := srv.store.GetOrgMemberFull(ctx, orgID, newUser.ID) + createdAt := newUser.CreatedAt + updatedAt := newUser.CreatedAt + if member != nil { + createdAt = member.CreatedAt + updatedAt = member.UpdatedAt + } + writeSCIMJSON(w, http.StatusCreated, buildSCIMUser(r, orgID, newUser.ID, + newUser.Email, newUser.DisplayName, req.ExternalID, + true, createdAt, updatedAt)) +} + +// scimGetUser handles GET /Users/{id}. +func (srv *Server) scimGetUser(w http.ResponseWriter, r *http.Request) { + orgID := r.Context().Value(ctxOrgID).(uuid.UUID) + scimConfigID := r.Context().Value(ctxSCIMConfigID).(uuid.UUID) + provider := scimProvider(scimConfigID) + ctx := r.Context() + + slog.InfoContext(ctx, "scim get user", + slog.String("org_id", orgID.String()), + slog.String("scim_config_id", scimConfigID.String()), + ) + + userIDStr := chi.URLParam(r, "id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "", "invalid user id") + return + } + + member, err := srv.store.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + slog.ErrorContext(ctx, "scim get user: lookup", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if member == nil { + writeSCIMError(w, http.StatusNotFound, "", "user not found") + return + } + + // Get externalId from identity if exists. + externalID := "" + identity, _ := srv.store.GetIdentityByProviderAndUser(ctx, provider, userID) + if identity != nil { + externalID = identity.ProviderUserID + } + + active := !member.DeactivatedAt.Valid + writeSCIMJSON(w, http.StatusOK, buildSCIMUser(r, orgID, userID, + member.Email, member.DisplayName, externalID, + active, member.CreatedAt, member.UpdatedAt)) +} + +// scimListUsers handles GET /Users with optional SCIM filtering. +func (srv *Server) scimListUsers(w http.ResponseWriter, r *http.Request) { + orgID := r.Context().Value(ctxOrgID).(uuid.UUID) + scimConfigID := r.Context().Value(ctxSCIMConfigID).(uuid.UUID) + provider := scimProvider(scimConfigID) + ctx := r.Context() + + slog.InfoContext(ctx, "scim list users", + slog.String("org_id", orgID.String()), + slog.String("scim_config_id", scimConfigID.String()), + ) + + // Parse pagination params. + startIndex := 1 + count := 100 + if si := r.URL.Query().Get("startIndex"); si != "" { + if v, err := strconv.Atoi(si); err == nil && v >= 1 { + startIndex = v + } + } + if c := r.URL.Query().Get("count"); c != "" { + if v, err := strconv.Atoi(c); err == nil && v >= 0 { + count = v + } + } + + // Parse filters. + filterStr := r.URL.Query().Get("filter") + filters, err := parseSCIMFilter(filterStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidFilter", err.Error()) + return + } + + // Validate filter attributes. + for _, f := range filters { + switch strings.ToLower(f.Attr) { + case "username", "externalid", "id", "active": + // supported + default: + writeSCIMError(w, http.StatusBadRequest, "invalidFilter", + fmt.Sprintf("unsupported filter attribute: %q", f.Attr)) + return + } + } + + // List all org members (including deactivated). + members, err := srv.store.ListOrgMembers(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim list users: list members", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Batch-load external IDs for all members. + userIDs := make([]uuid.UUID, len(members)) + for i, m := range members { + userIDs[i] = m.UserID + } + extIDMap := make(map[uuid.UUID]string) + identities, idErr := srv.store.ListIdentitiesByProviderAndUsers(ctx, provider, userIDs) + if idErr != nil { + slog.ErrorContext(ctx, "scim list users: batch load identities", "error", idErr) + } + for _, identity := range identities { + extIDMap[identity.UserID] = identity.ProviderUserID + } + + // Apply filters. + type scimMember struct { + UserID uuid.UUID + Email string + DisplayName string + Active bool + ExternalID string + CreatedAt time.Time + UpdatedAt time.Time + } + + var filtered []scimMember + for _, m := range members { + active := !m.DeactivatedAt.Valid + extID := extIDMap[m.UserID] + + // Apply filters. + match := true + for _, f := range filters { + switch strings.ToLower(f.Attr) { + case "username": + if !strings.EqualFold(m.Email, f.Value) { + match = false + } + case "externalid": + if extID != f.Value { + match = false + } + case "id": + if m.UserID.String() != f.Value { + match = false + } + case "active": + filterActive := strings.EqualFold(f.Value, "true") + if active != filterActive { + match = false + } + } + } + + if match { + filtered = append(filtered, scimMember{ + UserID: m.UserID, + Email: m.Email, + DisplayName: m.DisplayName, + Active: active, + ExternalID: extID, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + }) + } + } + + totalResults := len(filtered) + + // Apply pagination (SCIM startIndex is 1-based). + offset := startIndex - 1 + if offset < 0 { + offset = 0 + } + if offset > len(filtered) { + offset = len(filtered) + } + end := offset + count + if end > len(filtered) { + end = len(filtered) + } + page := filtered[offset:end] + + resources := make([]any, 0, len(page)) + for _, m := range page { + resources = append(resources, buildSCIMUser(r, orgID, m.UserID, + m.Email, m.DisplayName, m.ExternalID, + m.Active, m.CreatedAt, m.UpdatedAt)) + } + + writeSCIMJSON(w, http.StatusOK, SCIMListResponse{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + TotalResults: totalResults, + ItemsPerPage: len(page), + StartIndex: startIndex, + Resources: resources, + }) +} + +// scimReplaceUser handles PUT /Users/{id} — full resource replacement. +func (srv *Server) scimReplaceUser(w http.ResponseWriter, r *http.Request) { + orgID := r.Context().Value(ctxOrgID).(uuid.UUID) + scimConfigID := r.Context().Value(ctxSCIMConfigID).(uuid.UUID) + provider := scimProvider(scimConfigID) + ctx := r.Context() + + slog.InfoContext(ctx, "scim replace user", + slog.String("org_id", orgID.String()), + slog.String("scim_config_id", scimConfigID.String()), + ) + + userIDStr := chi.URLParam(r, "id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "", "invalid user id") + return + } + + var req scimUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeSCIMError(w, http.StatusBadRequest, "", "invalid JSON body") + return + } + + if strings.TrimSpace(req.UserName) == "" { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "userName is required") + return + } + + member, err := srv.store.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + slog.ErrorContext(ctx, "scim replace user: lookup", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if member == nil { + writeSCIMError(w, http.StatusNotFound, "", "user not found") + return + } + + // SCIM-exempt: return current state without modification. + if member.ScimExempt { + slog.WarnContext(ctx, "scim replace user: exempt user, skipping", + slog.String("user_id", userID.String())) + srv.fireSCIMEvent(ctx, secure.EventSCIMExemptSuppressed, &orgID) + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: userID.String(), + Success: true, + Metadata: map[string]any{"source": "scim", "suppressed": true, "reason": "scim_exempt"}, + }) + identity, _ := srv.store.GetIdentityByProviderAndUser(ctx, provider, userID) + extID := "" + if identity != nil { + extID = identity.ProviderUserID + } + writeSCIMJSON(w, http.StatusOK, buildSCIMUser(r, orgID, userID, + member.Email, member.DisplayName, extID, + !member.DeactivatedAt.Valid, member.CreatedAt, member.UpdatedAt)) + return + } + + // Determine active flag — default to true if omitted. + active := true + if req.Active != nil { + active = *req.Active + } + + // displayName falls back to userName if omitted. + displayName := req.DisplayName + if strings.TrimSpace(displayName) == "" { + displayName = req.UserName + } + + // Update user profile (withBypassTx — global table). + if err := srv.store.UpdateUserProfile(ctx, userID, req.UserName, displayName); err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") { + writeSCIMError(w, http.StatusConflict, "uniqueness", "userName already exists") + return + } + slog.ErrorContext(ctx, "scim replace user: update profile", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Update externalId if provided. + if req.ExternalID != "" { + if err := srv.store.UpsertUserIdentity(ctx, userID, provider, req.ExternalID, req.UserName); err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") { + writeSCIMError(w, http.StatusConflict, "uniqueness", "externalId already linked to another user") + return + } + slog.ErrorContext(ctx, "scim replace user: update identity", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + } + + // Handle active/inactive state. + wasActive := !member.DeactivatedAt.Valid + if active && !wasActive { + if err := srv.store.ReactivateOrgMember(ctx, orgID, userID); err != nil { + slog.ErrorContext(ctx, "scim replace user: reactivate", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + } else if !active && wasActive { + // Sole-owner protection. + if member.Role == "owner" { + count, err := srv.store.CountActiveOrgOwners(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim replace user: count owners", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if count <= 1 { + srv.fireSCIMEvent(ctx, secure.EventSCIMSoleOwnerProtected, &orgID) + writeSCIMError(w, http.StatusBadRequest, "", "cannot deactivate the sole owner") + return + } + } + if err := srv.store.DeactivateOrgMember(ctx, orgID, userID); err != nil { + slog.ErrorContext(ctx, "scim replace user: deactivate", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + srv.fireSCIMEvent(ctx, secure.EventSCIMUserDeprovisioned, &orgID) + } + + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: userID.String(), + Success: true, + Metadata: scimAuditMeta(scimConfigID), + }) + + // Re-read for response. + member, _ = srv.store.GetOrgMemberFull(ctx, orgID, userID) + extID := req.ExternalID + if extID == "" { + identity, _ := srv.store.GetIdentityByProviderAndUser(ctx, provider, userID) + if identity != nil { + extID = identity.ProviderUserID + } + } + memberActive := member != nil && !member.DeactivatedAt.Valid + createdAt := time.Now() + updatedAt := time.Now() + if member != nil { + createdAt = member.CreatedAt + updatedAt = member.UpdatedAt + } + writeSCIMJSON(w, http.StatusOK, buildSCIMUser(r, orgID, userID, + req.UserName, displayName, extID, + memberActive, createdAt, updatedAt)) +} + +// scimPatchUser handles PATCH /Users/{id} — partial update. +func (srv *Server) scimPatchUser(w http.ResponseWriter, r *http.Request) { + orgID := r.Context().Value(ctxOrgID).(uuid.UUID) + scimConfigID := r.Context().Value(ctxSCIMConfigID).(uuid.UUID) + provider := scimProvider(scimConfigID) + ctx := r.Context() + + slog.InfoContext(ctx, "scim patch user", + slog.String("org_id", orgID.String()), + slog.String("scim_config_id", scimConfigID.String()), + ) + + userIDStr := chi.URLParam(r, "id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "", "invalid user id") + return + } + + var patchOp SCIMPatchOp + if err := json.NewDecoder(r.Body).Decode(&patchOp); err != nil { + writeSCIMError(w, http.StatusBadRequest, "", "invalid JSON body") + return + } + + member, err := srv.store.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + slog.ErrorContext(ctx, "scim patch user: lookup", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if member == nil { + writeSCIMError(w, http.StatusNotFound, "", "user not found") + return + } + + // SCIM-exempt: return current state without modification. + if member.ScimExempt { + slog.WarnContext(ctx, "scim patch user: exempt user, skipping", + slog.String("user_id", userID.String())) + srv.fireSCIMEvent(ctx, secure.EventSCIMExemptSuppressed, &orgID) + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: userID.String(), + Success: true, + Metadata: map[string]any{"source": "scim", "suppressed": true, "reason": "scim_exempt"}, + }) + identity, _ := srv.store.GetIdentityByProviderAndUser(ctx, provider, userID) + extID := "" + if identity != nil { + extID = identity.ProviderUserID + } + writeSCIMJSON(w, http.StatusOK, buildSCIMUser(r, orgID, userID, + member.Email, member.DisplayName, extID, + !member.DeactivatedAt.Valid, member.CreatedAt, member.UpdatedAt)) + return + } + + // Process each operation. + for _, op := range patchOp.Operations { + opLower := strings.ToLower(op.Op) + if opLower != "replace" && opLower != "add" { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", + fmt.Sprintf("unsupported operation: %q", op.Op)) + return + } + + path := strings.ToLower(op.Path) + + switch path { + case "active": + active, err := parseSCIMBool(op.Value) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", + fmt.Sprintf("invalid active value: %v", err)) + return + } + wasActive := !member.DeactivatedAt.Valid + if !active && wasActive { + // Sole-owner protection. + if member.Role == "owner" { + count, err := srv.store.CountActiveOrgOwners(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim patch: count owners", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if count <= 1 { + srv.fireSCIMEvent(ctx, secure.EventSCIMSoleOwnerProtected, &orgID) + writeSCIMError(w, http.StatusBadRequest, "", "cannot deactivate the sole owner") + return + } + } + if err := srv.store.DeactivateOrgMember(ctx, orgID, userID); err != nil { + slog.ErrorContext(ctx, "scim patch: deactivate", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + srv.fireSCIMEvent(ctx, secure.EventSCIMUserDeprovisioned, &orgID) + } else if active && !wasActive { + if err := srv.store.ReactivateOrgMember(ctx, orgID, userID); err != nil { + slog.ErrorContext(ctx, "scim patch: reactivate", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + } + + case "username": + var userName string + if err := json.Unmarshal(op.Value, &userName); err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "userName must be a string") + return + } + if strings.TrimSpace(userName) == "" { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "userName cannot be empty") + return + } + if err := srv.store.UpdateUserEmail(ctx, userID, userName); err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") { + writeSCIMError(w, http.StatusConflict, "uniqueness", "userName already exists") + return + } + slog.ErrorContext(ctx, "scim patch: update email", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + case "displayname": + var displayName string + if err := json.Unmarshal(op.Value, &displayName); err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "displayName must be a string") + return + } + if err := srv.store.UpdateUserDisplayName(ctx, userID, displayName); err != nil { + slog.ErrorContext(ctx, "scim patch: update display name", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + case "externalid": + var externalID string + if err := json.Unmarshal(op.Value, &externalID); err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "externalId must be a string") + return + } + if err := srv.store.UpsertUserIdentity(ctx, userID, provider, externalID, member.Email); err != nil { + if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") { + writeSCIMError(w, http.StatusConflict, "uniqueness", "externalId already linked to another user") + return + } + slog.ErrorContext(ctx, "scim patch: update external id", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + case "": + // Entra ID sometimes sends replace ops without a path, with the value as an object. + // e.g., {"op": "Replace", "value": {"active": false}} + var valMap map[string]json.RawMessage + if err := json.Unmarshal(op.Value, &valMap); err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", "pathless operation value must be an object") + return + } + for attr, val := range valMap { + switch strings.ToLower(attr) { + case "active": + active, err := parseSCIMBool(val) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "invalidValue", + fmt.Sprintf("invalid active value: %v", err)) + return + } + wasActive := !member.DeactivatedAt.Valid + if !active && wasActive { + if member.Role == "owner" { + count, err := srv.store.CountActiveOrgOwners(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim patch: count owners (pathless)", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if count <= 1 { + srv.fireSCIMEvent(ctx, secure.EventSCIMSoleOwnerProtected, &orgID) + writeSCIMError(w, http.StatusBadRequest, "", "cannot deactivate the sole owner") + return + } + } + if err := srv.store.DeactivateOrgMember(ctx, orgID, userID); err != nil { + slog.ErrorContext(ctx, "scim patch: deactivate (pathless)", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + srv.fireSCIMEvent(ctx, secure.EventSCIMUserDeprovisioned, &orgID) + } else if active && !wasActive { + if err := srv.store.ReactivateOrgMember(ctx, orgID, userID); err != nil { + slog.ErrorContext(ctx, "scim patch: reactivate (pathless)", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + } + default: + // Ignore unsupported pathless attributes — IdPs may send extras. + } + } + + default: + // Ignore unsupported paths — SCIM spec says unknown paths should not error. + slog.WarnContext(ctx, "scim patch: ignoring unsupported path", + slog.String("path", op.Path)) + } + } + + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: userID.String(), + Success: true, + Metadata: scimAuditMeta(scimConfigID), + }) + + // Re-read for response. + member, _ = srv.store.GetOrgMemberFull(ctx, orgID, userID) + user, _ := srv.store.GetUserByID(ctx, userID) + identity, _ := srv.store.GetIdentityByProviderAndUser(ctx, provider, userID) + extID := "" + if identity != nil { + extID = identity.ProviderUserID + } + email := "" + dName := "" + if user != nil { + email = user.Email + dName = user.DisplayName + } + memberActive := member != nil && !member.DeactivatedAt.Valid + createdAt := time.Now() + updatedAt := time.Now() + if member != nil { + createdAt = member.CreatedAt + updatedAt = member.UpdatedAt + } + writeSCIMJSON(w, http.StatusOK, buildSCIMUser(r, orgID, userID, + email, dName, extID, + memberActive, createdAt, updatedAt)) +} + +// scimDeleteUser handles DELETE /Users/{id} — soft deactivation. +func (srv *Server) scimDeleteUser(w http.ResponseWriter, r *http.Request) { + orgID := r.Context().Value(ctxOrgID).(uuid.UUID) + scimConfigID := r.Context().Value(ctxSCIMConfigID).(uuid.UUID) + ctx := r.Context() + + slog.InfoContext(ctx, "scim delete user", + slog.String("org_id", orgID.String()), + slog.String("scim_config_id", scimConfigID.String()), + ) + + userIDStr := chi.URLParam(r, "id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + writeSCIMError(w, http.StatusBadRequest, "", "invalid user id") + return + } + + member, err := srv.store.GetOrgMemberFull(ctx, orgID, userID) + if err != nil { + slog.ErrorContext(ctx, "scim delete user: lookup", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + // Not found — 204 (idempotent). + if member == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + // SCIM-exempt: return 204 without modification. + if member.ScimExempt { + slog.WarnContext(ctx, "scim delete user: exempt user, skipping", + slog.String("user_id", userID.String())) + srv.fireSCIMEvent(ctx, secure.EventSCIMExemptSuppressed, &orgID) + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: userID.String(), + Success: true, + Metadata: map[string]any{"source": "scim", "suppressed": true, "reason": "scim_exempt"}, + }) + w.WriteHeader(http.StatusNoContent) + return + } + + // Already deactivated — 204 (idempotent). + if member.DeactivatedAt.Valid { + w.WriteHeader(http.StatusNoContent) + return + } + + // Sole-owner protection. + if member.Role == "owner" { + count, err := srv.store.CountActiveOrgOwners(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim delete user: count owners", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + if count <= 1 { + srv.fireSCIMEvent(ctx, secure.EventSCIMSoleOwnerProtected, &orgID) + writeSCIMError(w, http.StatusBadRequest, "", "cannot deactivate the sole owner") + return + } + } + + if err := srv.store.DeactivateOrgMember(ctx, orgID, userID); err != nil { + slog.ErrorContext(ctx, "scim delete user: deactivate", "error", err) + writeSCIMError(w, http.StatusInternalServerError, "", "internal error") + return + } + + srv.fireSCIMEvent(ctx, secure.EventSCIMUserDeprovisioned, &orgID) + srv.scimAuditLog(r, orgID, scimConfigID, audit.Entry{ + OrgID: orgID, + Action: "update", + EntityType: "member", + EntityID: userID.String(), + Success: true, + NewState: map[string]any{"active": false}, + Metadata: scimAuditMeta(scimConfigID), + }) + + w.WriteHeader(http.StatusNoContent) +} + +// checkSCIMMemberLimit checks whether the org has reached its tier member limit. +// Returns nil if within limits, non-nil error if limit exceeded. +func (srv *Server) checkSCIMMemberLimit(ctx context.Context, orgID uuid.UUID) error { + tierName, overrides, err := srv.store.GetOrgTier(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim: get org tier", "error", err) + return nil // fail open — let provisioning proceed + } + resolver := &tier.Resolver{Tier: tierName, Overrides: overrides} + limit := resolver.ResolveInt(tier.LimitMembers) + if limit < 0 { + return nil // unlimited + } + + count, err := srv.store.CountActiveOrgMembers(ctx, orgID) + if err != nil { + slog.ErrorContext(ctx, "scim: count active members", "error", err) + return nil // fail open + } + + if count >= limit { + return fmt.Errorf("member limit %d reached", limit) + } + return nil +} + +// getSCIMDefaultRole returns the default role from the SCIM config, or "viewer" if unavailable. +func (srv *Server) getSCIMDefaultRole(ctx context.Context, orgID uuid.UUID) string { + cfg, err := srv.store.GetSCIMConfig(ctx, orgID) + if err != nil || cfg == nil { + return "viewer" + } + return cfg.DefaultRole +} + +// scimAuditLog writes an audit log entry for SCIM operations. +// SCIM operations have no human actor (actor_id = nil). +func (srv *Server) scimAuditLog(r *http.Request, _, _ uuid.UUID, entry audit.Entry) { + entry.ActorID = nil + srv.auditLog(r, entry) +} diff --git a/internal/api/scim_users_test.go b/internal/api/scim_users_test.go new file mode 100644 index 00000000..9e5cec78 --- /dev/null +++ b/internal/api/scim_users_test.go @@ -0,0 +1,961 @@ +// ABOUTME: Integration tests for SCIM 2.0 User handlers (create, get, list, put, patch, delete). +// ABOUTME: Uses testcontainer Postgres with full migration stack and SCIM auth middleware. +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/scarson/cvert-ops/internal/auth" + "github.com/scarson/cvert-ops/internal/config" + "github.com/scarson/cvert-ops/internal/secure" + "github.com/scarson/cvert-ops/internal/testutil" +) + +// scimUserTestEnv bundles a test server with SCIM user handlers mounted. +type scimUserTestEnv struct { + ts *httptest.Server + srv *Server + db *testutil.TestDB + orgID uuid.UUID + scimConfigID uuid.UUID + rawToken string +} + +// newSCIMUserTestEnv sets up a test environment with SCIM user handlers. +func newSCIMUserTestEnv(t *testing.T) *scimUserTestEnv { + t.Helper() + + db := testutil.NewTestDB(t) + ctx := context.Background() + + org, err := db.CreateOrg(ctx, "scim-user-test-org") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + + ssoConn, err := db.CreateSSOConnection(ctx, org.ID, "Test IdP", + "https://idp.example.com", "client-id", []byte("encrypted"), nil, true) + if err != nil { + t.Fatalf("CreateSSOConnection: %v", err) + } + + rawToken, tokenHash, tokenPrefix, err := auth.GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken: %v", err) + } + + scimCfg, err := db.CreateSCIMConfig(ctx, org.ID, ssoConn.ID, true, tokenHash, tokenPrefix, "viewer") + if err != nil { + t.Fatalf("CreateSCIMConfig: %v", err) + } + + ew := secure.NewEventWriter(db.Store) + cfg := &config.Config{ //nolint:exhaustruct,gosec // test: only relevant fields set; G101 false positive on test-only JWT secret + JWTSecret: "scim-user-test-secret-32bytes-min", + } + srv, err := NewServer(db.Store, cfg, ServerDeps{EventWriter: ew}) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + t.Cleanup(srv.Close) + + r := chi.NewRouter() + r.Route("/api/v1/orgs/{org_id}/scim/v2", func(sub chi.Router) { + sub.Use(srv.requireSCIMAuth) + sub.Post("/Users", srv.scimCreateUser) + sub.Get("/Users", srv.scimListUsers) + sub.Get("/Users/{id}", srv.scimGetUser) + sub.Put("/Users/{id}", srv.scimReplaceUser) + sub.Patch("/Users/{id}", srv.scimPatchUser) + sub.Delete("/Users/{id}", srv.scimDeleteUser) + }) + + ts := httptest.NewServer(r) + t.Cleanup(ts.Close) + + return &scimUserTestEnv{ + ts: ts, + srv: srv, + db: db, + orgID: org.ID, + scimConfigID: scimCfg.ID, + rawToken: rawToken, + } +} + +// scimRequest makes an HTTP request to the SCIM endpoint. +// path should include the path after /scim/v2 (e.g., "/Users" or "/Users/{id}"). +// Query parameters in path are properly handled by net/url. +func (env *scimUserTestEnv) scimRequest(t *testing.T, method, path string, body any) *http.Response { + t.Helper() + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + bodyReader = bytes.NewReader(b) + } + rawURL := fmt.Sprintf("%s/api/v1/orgs/%s/scim/v2%s", env.ts.URL, env.orgID, path) + req, err := http.NewRequestWithContext(context.Background(), method, rawURL, bodyReader) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+env.rawToken) + if body != nil { + req.Header.Set("Content-Type", "application/scim+json") + } + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + return resp +} + +// scimRequestWithFilter makes a GET request with a filter query param. +func (env *scimUserTestEnv) scimRequestWithFilter(t *testing.T, path, filter string) *http.Response { + t.Helper() + rawURL := fmt.Sprintf("%s/api/v1/orgs/%s/scim/v2%s", env.ts.URL, env.orgID, path) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+env.rawToken) + q := req.URL.Query() + q.Set("filter", filter) + req.URL.RawQuery = q.Encode() + resp, err := env.ts.Client().Do(req) //nolint:gosec // G704: httptest URL + if err != nil { + t.Fatalf("request: %v", err) + } + return resp +} + +// decodeSCIMUser decodes a SCIMUser from the response body. +func decodeSCIMUser(t *testing.T, resp *http.Response) SCIMUser { + t.Helper() + var user SCIMUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + t.Fatalf("decode SCIM user: %v", err) + } + return user +} + +// decodeSCIMList decodes a SCIMListResponse from the response body. +func decodeSCIMList(t *testing.T, resp *http.Response) SCIMListResponse { + t.Helper() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + var list SCIMListResponse + if err := json.Unmarshal(body, &list); err != nil { + t.Fatalf("decode SCIM list: %v (body: %s)", err, string(body)) + } + return list +} + +// assertContentType checks the Content-Type is application/scim+json. +func assertSCIMContentType(t *testing.T, resp *http.Response) { + t.Helper() + ct := resp.Header.Get("Content-Type") + if ct != "application/scim+json" { + t.Errorf("Content-Type = %q, want %q", ct, "application/scim+json") + } +} + +// ── User Provisioning Tests ──────────────────────────────────────────────────── + +func TestSCIMCreateUser_NewUser(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + + reqBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "ext-new-001", + "userName": "newuser@example.com", + "displayName": "New User", + "active": true, + } + + resp := env.scimRequest(t, http.MethodPost, "/Users", reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 201 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) + + user := decodeSCIMUser(t, resp) + if len(user.Schemas) != 1 || user.Schemas[0] != "urn:ietf:params:scim:schemas:core:2.0:User" { + t.Errorf("schemas = %v, want User schema", user.Schemas) + } + if user.UserName != "newuser@example.com" { + t.Errorf("userName = %q, want %q", user.UserName, "newuser@example.com") + } + if user.DisplayName != "New User" { + t.Errorf("displayName = %q, want %q", user.DisplayName, "New User") + } + if user.ExternalID != "ext-new-001" { + t.Errorf("externalId = %q, want %q", user.ExternalID, "ext-new-001") + } + if !user.Active { + t.Error("active = false, want true") + } + if user.ID == "" { + t.Error("id is empty") + } + if user.Meta.ResourceType != "User" { + t.Errorf("meta.resourceType = %q, want %q", user.Meta.ResourceType, "User") + } +} + +func TestSCIMCreateUser_ExistingByExternalId_Active(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + // Pre-create user, identity, and membership. + user := env.db.MustCreateUser(t, ctx, "existing-ext@example.com", "Existing", "", 0) + provider := fmt.Sprintf("scim:%s", env.scimConfigID) + if err := env.db.UpsertUserIdentity(ctx, user.ID, provider, "ext-exist-001", user.Email); err != nil { + t.Fatalf("UpsertUserIdentity: %v", err) + } + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + reqBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "ext-exist-001", + "userName": "existing-ext@example.com", + "displayName": "Existing", + "active": true, + } + + resp := env.scimRequest(t, http.MethodPost, "/Users", reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) + + scimUser := decodeSCIMUser(t, resp) + if scimUser.ID != user.ID.String() { + t.Errorf("id = %q, want %q", scimUser.ID, user.ID.String()) + } + if !scimUser.Active { + t.Error("active = false, want true") + } +} + +func TestSCIMCreateUser_ExistingByExternalId_Deactivated(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "deactivated-ext@example.com", "Deactivated", "", 0) + provider := fmt.Sprintf("scim:%s", env.scimConfigID) + if err := env.db.UpsertUserIdentity(ctx, user.ID, provider, "ext-deact-001", user.Email); err != nil { + t.Fatalf("UpsertUserIdentity: %v", err) + } + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + if err := env.db.DeactivateOrgMember(ctx, env.orgID, user.ID); err != nil { + t.Fatalf("DeactivateOrgMember: %v", err) + } + + reqBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "ext-deact-001", + "userName": "deactivated-ext@example.com", + "displayName": "Deactivated", + "active": true, + } + + resp := env.scimRequest(t, http.MethodPost, "/Users", reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + scimUser := decodeSCIMUser(t, resp) + if !scimUser.Active { + t.Error("active = false, want true (should have been reactivated)") + } +} + +func TestSCIMCreateUser_ExistingByEmail_OrgMember(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "email-member@example.com", "Email Member", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + reqBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "ext-email-member-001", + "userName": "email-member@example.com", + "displayName": "Email Member", + "active": true, + } + + resp := env.scimRequest(t, http.MethodPost, "/Users", reqBody) + defer resp.Body.Close() //nolint:errcheck + + // Already a member, linking identity → 200. + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + scimUser := decodeSCIMUser(t, resp) + if scimUser.ID != user.ID.String() { + t.Errorf("id = %q, want %q", scimUser.ID, user.ID.String()) + } + + // Verify SCIM identity was linked. + provider := fmt.Sprintf("scim:%s", env.scimConfigID) + linked, err := env.db.GetUserByProviderID(ctx, provider, "ext-email-member-001") + if err != nil { + t.Fatalf("GetUserByProviderID: %v", err) + } + if linked == nil { + t.Error("SCIM identity was not linked") + } +} + +func TestSCIMCreateUser_ExistingByEmail_NotMember(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "email-nonmember@example.com", "Not Member", "", 0) + _ = user + + reqBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "ext-nonmember-001", + "userName": "email-nonmember@example.com", + "displayName": "Not Member", + "active": true, + } + + resp := env.scimRequest(t, http.MethodPost, "/Users", reqBody) + defer resp.Body.Close() //nolint:errcheck + + // Creates membership → 201. + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 201 (body: %s)", resp.StatusCode, string(body)) + } + + scimUser := decodeSCIMUser(t, resp) + if scimUser.ID != user.ID.String() { + t.Errorf("id = %q, want %q", scimUser.ID, user.ID.String()) + } +} + +func TestSCIMCreateUser_TierMemberLimit(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + // Create enough members to exhaust the free tier limit (5). + for i := 0; i < 5; i++ { + u := env.db.MustCreateUser(t, ctx, + fmt.Sprintf("tier-limit-%d@example.com", i), + fmt.Sprintf("Limit User %d", i), "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, u.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + } + + reqBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "ext-over-limit", + "userName": "over-limit@example.com", + "displayName": "Over Limit", + "active": true, + } + + resp := env.scimRequest(t, http.MethodPost, "/Users", reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusForbidden { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 403 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) +} + +func TestSCIMCreateUser_SCIMExempt_Deactivated(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "exempt@example.com", "Exempt", "", 0) + provider := fmt.Sprintf("scim:%s", env.scimConfigID) + if err := env.db.UpsertUserIdentity(ctx, user.ID, provider, "ext-exempt-001", user.Email); err != nil { + t.Fatalf("UpsertUserIdentity: %v", err) + } + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + if err := env.db.UpdateOrgMemberSCIMExempt(ctx, env.orgID, user.ID, true); err != nil { + t.Fatalf("UpdateOrgMemberSCIMExempt: %v", err) + } + if err := env.db.DeactivateOrgMember(ctx, env.orgID, user.ID); err != nil { + t.Fatalf("DeactivateOrgMember: %v", err) + } + + reqBody := map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "externalId": "ext-exempt-001", + "userName": "exempt@example.com", + "displayName": "Exempt", + "active": true, + } + + resp := env.scimRequest(t, http.MethodPost, "/Users", reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + scimUser := decodeSCIMUser(t, resp) + // Exempt user should NOT be reactivated. + if scimUser.Active { + t.Error("active = true, want false (exempt user should stay deactivated)") + } +} + +// ── User Read/List Tests ─────────────────────────────────────────────────────── + +func TestSCIMGetUser_Found(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "getuser@example.com", "Get User", "", 0) + provider := fmt.Sprintf("scim:%s", env.scimConfigID) + if err := env.db.UpsertUserIdentity(ctx, user.ID, provider, "ext-get-001", user.Email); err != nil { + t.Fatalf("UpsertUserIdentity: %v", err) + } + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + resp := env.scimRequest(t, http.MethodGet, "/Users/"+user.ID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) + + scimUser := decodeSCIMUser(t, resp) + if scimUser.UserName != "getuser@example.com" { + t.Errorf("userName = %q, want %q", scimUser.UserName, "getuser@example.com") + } + if scimUser.DisplayName != "Get User" { + t.Errorf("displayName = %q, want %q", scimUser.DisplayName, "Get User") + } + if scimUser.ExternalID != "ext-get-001" { + t.Errorf("externalId = %q, want %q", scimUser.ExternalID, "ext-get-001") + } + if !scimUser.Active { + t.Error("active = false, want true") + } +} + +func TestSCIMGetUser_NotFound(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + + fakeID := uuid.New() + resp := env.scimRequest(t, http.MethodGet, "/Users/"+fakeID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404", resp.StatusCode) + } + assertSCIMContentType(t, resp) +} + +func TestSCIMGetUser_CrossOrg(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + // Create user in a different org. + otherOrg, err := env.db.CreateOrg(ctx, "other-org") + if err != nil { + t.Fatalf("CreateOrg: %v", err) + } + user := env.db.MustCreateUser(t, ctx, "crossorg@example.com", "Cross Org", "", 0) + if err := env.db.CreateOrgMember(ctx, otherOrg.ID, user.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + // Try to get this user via our org's SCIM. + resp := env.scimRequest(t, http.MethodGet, "/Users/"+user.ID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404 (cross-org isolation)", resp.StatusCode) + } + assertSCIMContentType(t, resp) +} + +func TestSCIMListUsers_FilterByUserName(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "filteruser@example.com", "Filter User", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + // Add another user that should NOT match the filter. + other := env.db.MustCreateUser(t, ctx, "other-filter@example.com", "Other", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, other.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + resp := env.scimRequestWithFilter(t, "/Users", `userName eq "filteruser@example.com"`) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) + + list := decodeSCIMList(t, resp) + if list.TotalResults != 1 { + t.Errorf("totalResults = %d, want 1", list.TotalResults) + } + if len(list.Resources) != 1 { + t.Fatalf("Resources length = %d, want 1", len(list.Resources)) + } +} + +func TestSCIMListUsers_FilterById(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "filterid@example.com", "Filter By ID", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + resp := env.scimRequestWithFilter(t, "/Users", + fmt.Sprintf(`id eq "%s"`, user.ID.String())) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + list := decodeSCIMList(t, resp) + if list.TotalResults != 1 { + t.Errorf("totalResults = %d, want 1", list.TotalResults) + } +} + +func TestSCIMListUsers_EmptyResult(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + + resp := env.scimRequestWithFilter(t, "/Users", + `userName eq "nonexistent@example.com"`) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + assertSCIMContentType(t, resp) + + list := decodeSCIMList(t, resp) + if list.TotalResults != 0 { + t.Errorf("totalResults = %d, want 0", list.TotalResults) + } + if list.Resources == nil { + t.Error("Resources is nil, want empty array") + } +} + +func TestSCIMListUsers_Pagination(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + // Create 5 users. + for i := 0; i < 5; i++ { + u := env.db.MustCreateUser(t, ctx, + fmt.Sprintf("page-%d@example.com", i), + fmt.Sprintf("Page User %d", i), "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, u.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + } + + // Request page 1 with count=2. + resp := env.scimRequest(t, http.MethodGet, "/Users?startIndex=1&count=2", nil) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + list := decodeSCIMList(t, resp) + if list.TotalResults != 5 { + t.Errorf("totalResults = %d, want 5", list.TotalResults) + } + if list.ItemsPerPage != 2 { + t.Errorf("itemsPerPage = %d, want 2", list.ItemsPerPage) + } + if list.StartIndex != 1 { + t.Errorf("startIndex = %d, want 1", list.StartIndex) + } + if len(list.Resources) != 2 { + t.Errorf("Resources length = %d, want 2", len(list.Resources)) + } + + // Request page 2. + resp2 := env.scimRequest(t, http.MethodGet, "/Users?startIndex=3&count=2", nil) + defer resp2.Body.Close() //nolint:errcheck + + list2 := decodeSCIMList(t, resp2) + if list2.StartIndex != 3 { + t.Errorf("startIndex = %d, want 3", list2.StartIndex) + } + if len(list2.Resources) != 2 { + t.Errorf("Resources length = %d, want 2", len(list2.Resources)) + } +} + +// ── User Update Tests ────────────────────────────────────────────────────────── + +func TestSCIMReplaceUser_Success(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "replace@example.com", "Original Name", "", 0) + provider := fmt.Sprintf("scim:%s", env.scimConfigID) + if err := env.db.UpsertUserIdentity(ctx, user.ID, provider, "ext-replace-001", user.Email); err != nil { + t.Fatalf("UpsertUserIdentity: %v", err) + } + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + active := true + reqBody := scimUserRequest{ + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + ExternalID: "ext-replace-001", + UserName: "replaced@example.com", + DisplayName: "Replaced Name", + Active: &active, + } + + resp := env.scimRequest(t, http.MethodPut, "/Users/"+user.ID.String(), reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) + + scimUser := decodeSCIMUser(t, resp) + if scimUser.UserName != "replaced@example.com" { + t.Errorf("userName = %q, want %q", scimUser.UserName, "replaced@example.com") + } + if scimUser.DisplayName != "Replaced Name" { + t.Errorf("displayName = %q, want %q", scimUser.DisplayName, "Replaced Name") + } + + // Verify in DB. + updated, err := env.db.GetUserByID(ctx, user.ID) + if err != nil || updated == nil { + t.Fatalf("GetUserByID: %v", err) + } + if updated.Email != "replaced@example.com" { + t.Errorf("DB email = %q, want %q", updated.Email, "replaced@example.com") + } + if updated.DisplayName != "Replaced Name" { + t.Errorf("DB displayName = %q, want %q", updated.DisplayName, "Replaced Name") + } +} + +func TestSCIMPatchUser_ReplaceActive(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "patch-active@example.com", "Patch Active", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + reqBody := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + { + Op: "replace", + Path: "active", + Value: json.RawMessage(`false`), + }, + }, + } + + resp := env.scimRequest(t, http.MethodPatch, "/Users/"+user.ID.String(), reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + scimUser := decodeSCIMUser(t, resp) + if scimUser.Active { + t.Error("active = true, want false") + } + + // Verify in DB. + member, err := env.db.GetOrgMemberFull(ctx, env.orgID, user.ID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if !member.DeactivatedAt.Valid { + t.Error("deactivated_at is NULL, want non-NULL") + } +} + +func TestSCIMPatchUser_CaseInsensitiveOp(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "patch-case@example.com", "Case Test", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + // Entra ID sends "Replace" with capital R. + reqBody := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + { + Op: "Replace", + Path: "active", + Value: json.RawMessage(`false`), + }, + }, + } + + resp := env.scimRequest(t, http.MethodPatch, "/Users/"+user.ID.String(), reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + scimUser := decodeSCIMUser(t, resp) + if scimUser.Active { + t.Error("active = true, want false (case-insensitive op)") + } +} + +func TestSCIMPatchUser_StringBoolean(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "patch-strbool@example.com", "Str Bool", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "viewer"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + // Entra ID sends "False" as a string instead of a JSON boolean. + reqBody := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + { + Op: "replace", + Path: "active", + Value: json.RawMessage(`"False"`), + }, + }, + } + + resp := env.scimRequest(t, http.MethodPatch, "/Users/"+user.ID.String(), reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 200 (body: %s)", resp.StatusCode, string(body)) + } + + scimUser := decodeSCIMUser(t, resp) + if scimUser.Active { + t.Error("active = true, want false (string boolean coercion)") + } +} + +func TestSCIMPatchUser_SoleOwner(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + owner := env.db.MustCreateUser(t, ctx, "sole-owner-patch@example.com", "Sole Owner", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, owner.ID, "owner"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + reqBody := SCIMPatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []SCIMPatchOperation{ + { + Op: "replace", + Path: "active", + Value: json.RawMessage(`false`), + }, + }, + } + + resp := env.scimRequest(t, http.MethodPatch, "/Users/"+owner.ID.String(), reqBody) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 400 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) +} + +// ── User Deprovision Tests ───────────────────────────────────────────────────── + +func TestSCIMDeleteUser_Success(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "delete@example.com", "Delete Me", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + resp := env.scimRequest(t, http.MethodDelete, "/Users/"+user.ID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 204 (body: %s)", resp.StatusCode, string(body)) + } + + // Verify deactivated in DB. + member, err := env.db.GetOrgMemberFull(ctx, env.orgID, user.ID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member == nil || !member.DeactivatedAt.Valid { + t.Error("member should be deactivated after DELETE") + } +} + +func TestSCIMDeleteUser_NotFound(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + + fakeID := uuid.New() + resp := env.scimRequest(t, http.MethodDelete, "/Users/"+fakeID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + + // Idempotent — 204 even for non-existent users. + if resp.StatusCode != http.StatusNoContent { + t.Errorf("status = %d, want 204 (idempotent)", resp.StatusCode) + } +} + +func TestSCIMDeleteUser_SCIMExempt(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + user := env.db.MustCreateUser(t, ctx, "exempt-delete@example.com", "Exempt Delete", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, user.ID, "member"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + if err := env.db.UpdateOrgMemberSCIMExempt(ctx, env.orgID, user.ID, true); err != nil { + t.Fatalf("UpdateOrgMemberSCIMExempt: %v", err) + } + + resp := env.scimRequest(t, http.MethodDelete, "/Users/"+user.ID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("status = %d, want 204", resp.StatusCode) + } + + // Verify NOT deactivated. + member, err := env.db.GetOrgMemberFull(ctx, env.orgID, user.ID) + if err != nil { + t.Fatalf("GetOrgMemberFull: %v", err) + } + if member == nil { + t.Fatal("member is nil") + } + if member.DeactivatedAt.Valid { + t.Error("exempt user should NOT be deactivated") + } +} + +func TestSCIMDeleteUser_SoleOwner(t *testing.T) { + t.Parallel() + env := newSCIMUserTestEnv(t) + ctx := context.Background() + + owner := env.db.MustCreateUser(t, ctx, "sole-owner-delete@example.com", "Sole Owner", "", 0) + if err := env.db.CreateOrgMember(ctx, env.orgID, owner.ID, "owner"); err != nil { + t.Fatalf("CreateOrgMember: %v", err) + } + + resp := env.scimRequest(t, http.MethodDelete, "/Users/"+owner.ID.String(), nil) + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck + t.Fatalf("status = %d, want 400 (body: %s)", resp.StatusCode, string(body)) + } + assertSCIMContentType(t, resp) +} diff --git a/internal/api/server.go b/internal/api/server.go index 12ff601e..75fba161 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -57,6 +57,7 @@ type Server struct { googleOIDC *oidc.Provider // nil when Google OIDC is not configured googleOAuth *oauth2.Config // nil when Google OIDC is not configured orgRL *orgRateLimiter // per-org API rate limiter + scimRL *scimRateLimiter // per-org SCIM rate limiter (separate from API) tierCache *tierCache // short-lived cache for org tier + overrides oidcProviders sync.Map // issuer URL → *oidc.Provider; lazy-loaded per SSO connection alertCache *alert.RuleCache // nil when alert evaluation is not configured @@ -164,6 +165,9 @@ func (srv *Server) Close() { if srv.orgRL != nil { srv.orgRL.Stop() } + if srv.scimRL != nil { + srv.scimRL.Stop() + } if srv.tierCache != nil { srv.tierCache.Stop() } @@ -367,6 +371,19 @@ func (srv *Server) Handler() http.Handler { r.With(srv.RequireOrgRole(RoleOwner)).Delete("/", srv.deleteSSOHandler) r.With(srv.RequireOrgRole(RoleOwner)).Put("/domains", srv.putSSODomainsHandler) r.With(srv.RequireOrgRole(RoleMember)).Get("/link", srv.oidcLinkInitHandler) + + // SCIM config admin endpoints (enterprise only) + r.Route("/scim", func(r chi.Router) { + r.With(srv.RequireOrgRole(RoleOwner)).Post("/", srv.createSCIMConfigHandler) + r.With(srv.RequireOrgRole(RoleAdmin)).Get("/", srv.getSCIMConfigHandler) + r.With(srv.RequireOrgRole(RoleOwner)).Patch("/", srv.patchSCIMConfigHandler) + r.With(srv.RequireOrgRole(RoleOwner)).Delete("/", srv.deleteSCIMConfigHandler) + r.With(srv.RequireOrgRole(RoleOwner)).Post("/rotate-token", srv.rotateSCIMTokenHandler) + r.With(srv.RequireOrgRole(RoleAdmin)).Get("/groups", srv.listSCIMGroupsHandler) + r.Route("/groups/{id}/mapping", func(r chi.Router) { + r.With(srv.RequireOrgRole(RoleAdmin)).Patch("/", srv.patchSCIMGroupMappingHandler) + }) + }) }) // Audit log (enterprise only, admin+) @@ -452,6 +469,33 @@ func (srv *Server) Handler() http.Handler { }) }) + // ── SCIM v2 endpoints (chi, separate auth — machine-to-machine) ───────── + apiRouter.Route("/orgs/{org_id}/scim/v2", func(r chi.Router) { + r.Use(srv.requireSCIMAuth) + r.Use(srv.scimRateLimit()) + + // Discovery + r.Get("/ServiceProviderConfig", srv.scimServiceProviderConfig) + r.Get("/Schemas", srv.scimSchemas) + r.Get("/ResourceTypes", srv.scimResourceTypes) + + // Users + r.Post("/Users", srv.scimCreateUser) + r.Get("/Users", srv.scimListUsers) + r.Get("/Users/{id}", srv.scimGetUser) + r.Put("/Users/{id}", srv.scimReplaceUser) + r.Patch("/Users/{id}", srv.scimPatchUser) + r.Delete("/Users/{id}", srv.scimDeleteUser) + + // Groups + r.Post("/Groups", srv.scimCreateGroup) + r.Get("/Groups", srv.scimListGroups) + r.Get("/Groups/{id}", srv.scimGetGroup) + r.Put("/Groups/{id}", srv.scimReplaceGroup) + r.Patch("/Groups/{id}", srv.scimPatchGroup) + r.Delete("/Groups/{id}", srv.scimDeleteGroup) + }) + r.Mount("/api/v1", apiRouter) // ── SPA fallback (serves embedded frontend) ───────────────────────────── diff --git a/internal/api/sso.go b/internal/api/sso.go index 5b79a7c0..7a5a657a 100644 --- a/internal/api/sso.go +++ b/internal/api/sso.go @@ -454,6 +454,19 @@ func (srv *Server) deleteSSOHandler(w http.ResponseWriter, r *http.Request) { return } + // Block deletion if a SCIM config is linked to this SSO connection. + scimCfg, err := srv.store.LookupSCIMConfigBySSOConnectionID(r.Context(), current.ID) + if err != nil { + slog.ErrorContext(r.Context(), "sso delete: check scim config", "error", err) + writeProblem(w, http.StatusInternalServerError, "internal error") + return + } + if scimCfg != nil { + writeProblem(w, http.StatusConflict, + "SSO connection has active SCIM provisioning. Disable and delete the SCIM configuration before removing SSO, or update the SSO connection in place.") + return + } + if err := srv.store.DeleteSSOConnection(r.Context(), orgID); err != nil { slog.ErrorContext(r.Context(), "sso delete: store", "error", err) writeProblem(w, http.StatusInternalServerError, "internal error") diff --git a/internal/api/sso_test.go b/internal/api/sso_test.go index 0e889f0f..82e88c1e 100644 --- a/internal/api/sso_test.go +++ b/internal/api/sso_test.go @@ -1118,3 +1118,94 @@ func TestAudit_SSOOperations(t *testing.T) { } }) } + +// TestSSODelete_BlockedBySCIM verifies that DELETE SSO returns 409 when a SCIM config +// is linked to the SSO connection. +func TestSSODelete_BlockedBySCIM(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv, ts := newSSOServer(t, db) + + reg := doRegister(t, ctx, ts, "sso-scim@example.com", "test-password-1234") + loginResp := doLogin(t, ctx, ts, "sso-scim@example.com", "test-password-1234") + defer loginResp.Body.Close() //nolint:errcheck,gosec // G104 + token := cookieValue(loginResp, "access_token") + + orgID := mustParseUUID(t, reg.OrgID) + + // Set enterprise tier. + if err := db.UpdateOrgTier(ctx, orgID, "enterprise"); err != nil { + t.Fatalf("update tier: %v", err) + } + srv.tierCache.Invalidate(orgID) + + // Create SSO connection. + createBody := `{"display_name":"SCIM IdP","issuer_url":"https://idp.scim.com","client_id":"scim-client","client_secret":"scim-secret"}` + resp := doCreateSSO(t, ctx, ts, token, reg.OrgID, createBody) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("create SSO: got %d, want 201. Body: %s", resp.StatusCode, body) + } + + // Get SSO connection ID from the response. + var created map[string]any + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatalf("decode create: %v", err) + } + ssoConnID := mustParseUUID(t, created["id"].(string)) + + // Create a SCIM config linked to this SSO connection. + _, err := db.CreateSCIMConfig(ctx, orgID, ssoConnID, true, "tokenhash123", "tok_", "viewer") + if err != nil { + t.Fatalf("create scim config: %v", err) + } + + // DELETE SSO — should be blocked with 409. + resp2 := doDeleteSSO(t, ctx, ts, token, reg.OrgID) + defer resp2.Body.Close() //nolint:errcheck,gosec // G104 + if resp2.StatusCode != http.StatusConflict { + body, _ := io.ReadAll(resp2.Body) + t.Errorf("delete SSO with SCIM: got %d, want 409. Body: %s", resp2.StatusCode, body) + } +} + +// TestSSODelete_NoSCIM_StillWorks verifies that DELETE SSO still returns 204 +// when no SCIM config exists. +func TestSSODelete_NoSCIM_StillWorks(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + srv, ts := newSSOServer(t, db) + + reg := doRegister(t, ctx, ts, "sso-noscim@example.com", "test-password-1234") + loginResp := doLogin(t, ctx, ts, "sso-noscim@example.com", "test-password-1234") + defer loginResp.Body.Close() //nolint:errcheck,gosec // G104 + token := cookieValue(loginResp, "access_token") + + orgID := mustParseUUID(t, reg.OrgID) + + // Set enterprise tier. + if err := db.UpdateOrgTier(ctx, orgID, "enterprise"); err != nil { + t.Fatalf("update tier: %v", err) + } + srv.tierCache.Invalidate(orgID) + + // Create SSO connection (no SCIM config). + createBody := `{"display_name":"Plain IdP","issuer_url":"https://idp.plain.com","client_id":"plain-client","client_secret":"plain-secret"}` + resp := doCreateSSO(t, ctx, ts, token, reg.OrgID, createBody) + defer resp.Body.Close() //nolint:errcheck,gosec // G104 + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("create SSO: got %d, want 201. Body: %s", resp.StatusCode, body) + } + + // DELETE SSO — should succeed with 204. + resp2 := doDeleteSSO(t, ctx, ts, token, reg.OrgID) + defer resp2.Body.Close() //nolint:errcheck,gosec // G104 + if resp2.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp2.Body) + t.Fatalf("delete SSO without SCIM: got %d, want 204. Body: %s", resp2.StatusCode, body) + } +} diff --git a/internal/auth/scimtoken.go b/internal/auth/scimtoken.go new file mode 100644 index 00000000..e2065cab --- /dev/null +++ b/internal/auth/scimtoken.go @@ -0,0 +1,33 @@ +// ABOUTME: SCIM bearer token generation and hashing. +// ABOUTME: Tokens use "cvert_scim_" prefix (distinct from API key "cvo_" prefix). +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// SCIMTokenPrefix is the human-readable prefix on all SCIM bearer tokens. +const SCIMTokenPrefix = "cvert_scim_" //nolint:gosec // G101 false positive: prefix format constant, not a credential + +// GenerateSCIMToken creates a new SCIM bearer token. +// Returns: raw token (shown once), sha256 hex hash (stored), display prefix (first 8 chars). +func GenerateSCIMToken() (rawToken, tokenHash, tokenPrefix string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", "", fmt.Errorf("generate scim token: %w", err) + } + rawToken = SCIMTokenPrefix + hex.EncodeToString(b) + tokenHash = HashSCIMToken(rawToken) + tokenPrefix = rawToken[:8] + return rawToken, tokenHash, tokenPrefix, nil +} + +// HashSCIMToken returns the sha256 hex hash of a raw SCIM token. +// Use subtle.ConstantTimeCompare when comparing against stored hashes. +func HashSCIMToken(rawToken string) string { + sum := sha256.Sum256([]byte(rawToken)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/auth/scimtoken_test.go b/internal/auth/scimtoken_test.go new file mode 100644 index 00000000..6e026a57 --- /dev/null +++ b/internal/auth/scimtoken_test.go @@ -0,0 +1,83 @@ +// ABOUTME: Tests for SCIM bearer token generation and hashing. +// ABOUTME: Verifies token format, deterministic hashing, and uniqueness. +package auth + +import ( + "encoding/hex" + "strings" + "testing" +) + +func TestGenerateSCIMToken(t *testing.T) { + t.Parallel() + + raw, hash, prefix, err := GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken() error = %v", err) + } + + // Token starts with the SCIM prefix. + if !strings.HasPrefix(raw, SCIMTokenPrefix) { + t.Errorf("raw token %q does not start with %q", raw, SCIMTokenPrefix) + } + + // Length: 11 (prefix "cvert_scim_") + 64 (hex of 32 bytes) = 75. + if len(raw) != 75 { + t.Errorf("raw token length = %d, want 75", len(raw)) + } + + // Hash is 64 hex chars (sha256). + if len(hash) != 64 { + t.Errorf("hash length = %d, want 64", len(hash)) + } + if _, err := hex.DecodeString(hash); err != nil { + t.Errorf("hash is not valid hex: %v", err) + } + + // Prefix is the first 8 chars of the raw token. + if prefix != raw[:8] { + t.Errorf("prefix = %q, want %q", prefix, raw[:8]) + } +} + +func TestHashSCIMToken(t *testing.T) { + t.Parallel() + + token := "cvert_scim_deadbeef" //nolint:gosec // G101 false positive: test fixture, not a credential + + // Deterministic: same input produces same output. + h1 := HashSCIMToken(token) + h2 := HashSCIMToken(token) + if h1 != h2 { + t.Errorf("HashSCIMToken not deterministic: %q != %q", h1, h2) + } + + // Output is 64 hex chars. + if len(h1) != 64 { + t.Errorf("hash length = %d, want 64", len(h1)) + } + if _, err := hex.DecodeString(h1); err != nil { + t.Errorf("hash is not valid hex: %v", err) + } +} + +func TestGenerateSCIMToken_Uniqueness(t *testing.T) { + t.Parallel() + + raw1, hash1, _, err := GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken() #1 error = %v", err) + } + + raw2, hash2, _, err := GenerateSCIMToken() + if err != nil { + t.Fatalf("GenerateSCIMToken() #2 error = %v", err) + } + + if raw1 == raw2 { + t.Error("two generated tokens have the same raw value") + } + if hash1 == hash2 { + t.Error("two generated tokens have the same hash") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8a74a57d..f3c0ed78 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -129,6 +129,9 @@ type Config struct { TrustedProxies string `env:"TRUSTED_PROXIES"` RateLimitEvictTTL time.Duration `env:"RATE_LIMIT_EVICT_TTL" envDefault:"15m"` + // ── SCIM ──────────────────────────────────────────────────────────────────── + SCIMRateLimit float64 `env:"SCIM_RATE_LIMIT" envDefault:"50"` // requests per second per org + // ── SSO ───────────────────────────────────────────────────────────────────── SSOEncryptionKey string `env:"SSO_ENCRYPTION_KEY"` // 32-byte hex key; required if SSO is used SSOEncryptionKeyPrevious string `env:"SSO_ENCRYPTION_KEY_PREVIOUS"` // previous key for rotation diff --git a/internal/secure/events.go b/internal/secure/events.go index 1fbcf2c1..f0dc50c8 100644 --- a/internal/secure/events.go +++ b/internal/secure/events.go @@ -11,21 +11,21 @@ const ( EventAuthAccountUnlocked = "auth.account_unlocked" EventAuthPasswordResetReq = "auth.password_reset_requested" EventAuthPasswordChanged = "auth.password_changed" - EventAuthTokenReuseDetected = "auth.token_reuse_detected" //nolint:gosec // G101 false positive: event type label, not a credential - EventAuthAPIKeyCreated = "auth.api_key_created" //nolint:gosec // G101 false positive: event type label, not a credential + EventAuthTokenReuseDetected = "auth.token_reuse_detected" //nolint:gosec // G101 false positive: event type label, not a credential + EventAuthAPIKeyCreated = "auth.api_key_created" //nolint:gosec // G101 false positive: event type label, not a credential EventAuthAPIKeyUsedAfterRevoke = "auth.api_key_used_after_revocation" //nolint:gosec // G101 false positive: event type label, not a credential EventAdminUserDisabled = "admin.user_disabled" EventAdminConfigReloaded = "admin.config_reloaded" EventAdminBulkRetryTriggered = "admin.bulk_retry_triggered" // MFA authentication events - EventMFAChallengeRequested = "mfa.challenge_requested" - EventMFAVerifySuccess = "mfa.verify_success" - EventMFAVerifyFailed = "mfa.verify_failed" - EventMFAChallengeExhausted = "mfa.challenge_exhausted" - EventMFAEmailOTPRateLimited = "mfa.email_otp_rate_limited" + EventMFAChallengeRequested = "mfa.challenge_requested" + EventMFAVerifySuccess = "mfa.verify_success" + EventMFAVerifyFailed = "mfa.verify_failed" + EventMFAChallengeExhausted = "mfa.challenge_exhausted" + EventMFAEmailOTPRateLimited = "mfa.email_otp_rate_limited" EventMFARememberDeviceIssued = "mfa.remember_device_issued" - EventMFARememberDeviceUsed = "mfa.remember_device_used" + EventMFARememberDeviceUsed = "mfa.remember_device_used" // Recovery code events EventMFARecoveryCodesGenerated = "mfa.recovery_codes_generated" @@ -33,20 +33,32 @@ const ( EventMFARecoveryCodeFailed = "mfa.recovery_code_failed" // Enrollment/management events - EventMFAMethodEnrolled = "mfa.method_enrolled" - EventMFAMethodRemoved = "mfa.method_removed" - EventMFAAllMethodsRemoved = "mfa.all_methods_removed" - EventMFAEnrollmentFailed = "mfa.enrollment_failed" - EventMFADisableBlocked = "mfa.disable_blocked" + EventMFAMethodEnrolled = "mfa.method_enrolled" + EventMFAMethodRemoved = "mfa.method_removed" + EventMFAAllMethodsRemoved = "mfa.all_methods_removed" + EventMFAEnrollmentFailed = "mfa.enrollment_failed" + EventMFADisableBlocked = "mfa.disable_blocked" // Admin action events - EventMFAAdminReset = "mfa.admin_reset" - EventMFAAdminRequireMember = "mfa.admin_require_member" - EventMFAAdminUnrequireMember = "mfa.admin_unrequire_member" - EventMFAOrgRequireAllEnabled = "mfa.org_require_all_enabled" - EventMFAOrgRequireAllDisabled = "mfa.org_require_all_disabled" + EventMFAAdminReset = "mfa.admin_reset" + EventMFAAdminRequireMember = "mfa.admin_require_member" + EventMFAAdminUnrequireMember = "mfa.admin_unrequire_member" + EventMFAOrgRequireAllEnabled = "mfa.org_require_all_enabled" + EventMFAOrgRequireAllDisabled = "mfa.org_require_all_disabled" EventAuthPasswordResetForced = "auth.password_reset_forced" EventAuthPasswordResetForcedCompleted = "auth.password_reset_forced_completed" + + // SCIM provisioning events + EventSCIMAuthFailed = "scim.auth_failed" + EventSCIMAuthOrgMismatch = "scim.auth_org_mismatch" + EventSCIMAuthDisabled = "scim.auth_disabled" + EventSCIMTokenCreated = "scim.token_created" //nolint:gosec // G101 false positive: event type label, not a credential + EventSCIMTokenRotated = "scim.token_rotated" //nolint:gosec // G101 false positive: event type label, not a credential + EventSCIMUserProvisioned = "scim.user_provisioned" + EventSCIMUserDeprovisioned = "scim.user_deprovisioned" + EventSCIMSoleOwnerProtected = "scim.sole_owner_protected" + EventSCIMExemptSuppressed = "scim.exempt_suppressed" + EventSCIMRateLimited = "scim.rate_limited" ) // Severity levels for security events. @@ -58,18 +70,18 @@ const ( // eventSeverity maps each event type to its default severity level. var eventSeverity = map[string]string{ - EventAuthLoginFailed: SeverityInfo, - EventAuthLoginSuccess: SeverityInfo, - EventAuthAccountLocked: SeverityWarning, - EventAuthAccountUnlocked: SeverityInfo, - EventAuthPasswordResetReq: SeverityInfo, - EventAuthPasswordChanged: SeverityInfo, - EventAuthTokenReuseDetected: SeverityCritical, - EventAuthAPIKeyCreated: SeverityInfo, - EventAuthAPIKeyUsedAfterRevoke: SeverityWarning, - EventAdminUserDisabled: SeverityWarning, - EventAdminConfigReloaded: SeverityInfo, - EventAdminBulkRetryTriggered: SeverityInfo, + EventAuthLoginFailed: SeverityInfo, + EventAuthLoginSuccess: SeverityInfo, + EventAuthAccountLocked: SeverityWarning, + EventAuthAccountUnlocked: SeverityInfo, + EventAuthPasswordResetReq: SeverityInfo, + EventAuthPasswordChanged: SeverityInfo, + EventAuthTokenReuseDetected: SeverityCritical, + EventAuthAPIKeyCreated: SeverityInfo, + EventAuthAPIKeyUsedAfterRevoke: SeverityWarning, + EventAdminUserDisabled: SeverityWarning, + EventAdminConfigReloaded: SeverityInfo, + EventAdminBulkRetryTriggered: SeverityInfo, EventMFAChallengeRequested: SeverityInfo, EventMFAVerifySuccess: SeverityInfo, EventMFAVerifyFailed: SeverityWarning, @@ -92,6 +104,16 @@ var eventSeverity = map[string]string{ EventMFAOrgRequireAllDisabled: SeverityWarning, EventAuthPasswordResetForced: SeverityCritical, EventAuthPasswordResetForcedCompleted: SeverityInfo, + EventSCIMAuthFailed: SeverityWarning, + EventSCIMAuthOrgMismatch: SeverityWarning, + EventSCIMAuthDisabled: SeverityWarning, + EventSCIMTokenCreated: SeverityInfo, + EventSCIMTokenRotated: SeverityInfo, + EventSCIMUserProvisioned: SeverityInfo, + EventSCIMUserDeprovisioned: SeverityInfo, + EventSCIMSoleOwnerProtected: SeverityWarning, + EventSCIMExemptSuppressed: SeverityWarning, + EventSCIMRateLimited: SeverityWarning, } // Severity returns the default severity level for the given event type. diff --git a/internal/secure/events_test.go b/internal/secure/events_test.go index 1d9b109a..4efde6ca 100644 --- a/internal/secure/events_test.go +++ b/internal/secure/events_test.go @@ -45,6 +45,17 @@ func TestEventSeverityMapIsExhaustive(t *testing.T) { EventMFAOrgRequireAllDisabled, EventAuthPasswordResetForced, EventAuthPasswordResetForcedCompleted, + // SCIM provisioning events + EventSCIMAuthFailed, + EventSCIMAuthOrgMismatch, + EventSCIMAuthDisabled, + EventSCIMTokenCreated, + EventSCIMTokenRotated, + EventSCIMUserProvisioned, + EventSCIMUserDeprovisioned, + EventSCIMSoleOwnerProtected, + EventSCIMExemptSuppressed, + EventSCIMRateLimited, } for _, e := range allEvents { if _, ok := Severity(e); !ok { diff --git a/internal/store/auth.go b/internal/store/auth.go index 0ffa90f6..3ea20aaf 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -319,3 +319,75 @@ func (s *Store) GetLoginLockoutState(ctx context.Context, email string) (*LoginL } return &state, nil } + +// UpdateUserEmail updates a user's email address. +// Uses withBypassTx — users is a global table with no RLS. +func (s *Store) UpdateUserEmail(ctx context.Context, id uuid.UUID, email string) error { + return s.withBypassTx(ctx, func(q *generated.Queries) error { + if err := q.UpdateUserEmail(ctx, generated.UpdateUserEmailParams{ + ID: id, + Email: email, + }); err != nil { + return fmt.Errorf("update user email: %w", err) + } + return nil + }) +} + +// UpdateUserDisplayName updates a user's display name. +// Uses withBypassTx — users is a global table with no RLS. +func (s *Store) UpdateUserDisplayName(ctx context.Context, id uuid.UUID, displayName string) error { + return s.withBypassTx(ctx, func(q *generated.Queries) error { + if err := q.UpdateUserDisplayName(ctx, generated.UpdateUserDisplayNameParams{ + ID: id, + DisplayName: displayName, + }); err != nil { + return fmt.Errorf("update user display name: %w", err) + } + return nil + }) +} + +// UpdateUserProfile updates a user's email and display name in one statement. +// Uses withBypassTx — users is a global table with no RLS. +func (s *Store) UpdateUserProfile(ctx context.Context, id uuid.UUID, email, displayName string) error { + return s.withBypassTx(ctx, func(q *generated.Queries) error { + if err := q.UpdateUserProfile(ctx, generated.UpdateUserProfileParams{ + ID: id, + Email: email, + DisplayName: displayName, + }); err != nil { + return fmt.Errorf("update user profile: %w", err) + } + return nil + }) +} + +// ListIdentitiesByProviderAndUsers returns all identity rows for a provider +// and set of user IDs. Used by SCIM list users to batch-load external IDs. +func (s *Store) ListIdentitiesByProviderAndUsers(ctx context.Context, provider string, userIDs []uuid.UUID) ([]generated.UserIdentity, error) { + rows, err := s.q.ListIdentitiesByProviderAndUsers(ctx, generated.ListIdentitiesByProviderAndUsersParams{ + Provider: provider, + Column2: userIDs, + }) + if err != nil { + return nil, fmt.Errorf("list identities by provider and users: %w", err) + } + return rows, nil +} + +// GetIdentityByProviderAndUser returns the identity row for a provider+user, +// or (nil, nil) if not found. +func (s *Store) GetIdentityByProviderAndUser(ctx context.Context, provider string, userID uuid.UUID) (*generated.UserIdentity, error) { + row, err := s.q.GetIdentityByProviderAndUser(ctx, generated.GetIdentityByProviderAndUserParams{ + Provider: provider, + UserID: userID, + }) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get identity by provider and user: %w", err) + } + return &row, nil +} diff --git a/internal/store/generated/auth.sql.go b/internal/store/generated/auth.sql.go index 77666f3f..d849f422 100644 --- a/internal/store/generated/auth.sql.go +++ b/internal/store/generated/auth.sql.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/lib/pq" ) const clearForcePasswordReset = `-- name: ClearForcePasswordReset :exec @@ -111,6 +112,32 @@ func (q *Queries) DeleteExpiredRefreshTokens(ctx context.Context) (int64, error) return result.RowsAffected() } +const getIdentityByProviderAndUser = `-- name: GetIdentityByProviderAndUser :one +SELECT id, user_id, provider, provider_user_id, email, created_at FROM user_identities +WHERE provider = $1 AND user_id = $2 +LIMIT 1 +` + +type GetIdentityByProviderAndUserParams struct { + Provider string + UserID uuid.UUID +} + +// Returns the identity row for a given provider and user, or no rows. +func (q *Queries) GetIdentityByProviderAndUser(ctx context.Context, arg GetIdentityByProviderAndUserParams) (UserIdentity, error) { + row := q.db.QueryRowContext(ctx, getIdentityByProviderAndUser, arg.Provider, arg.UserID) + var i UserIdentity + err := row.Scan( + &i.ID, + &i.UserID, + &i.Provider, + &i.ProviderUserID, + &i.Email, + &i.CreatedAt, + ) + return i, err +} + const getLoginLockoutState = `-- name: GetLoginLockoutState :one SELECT failed_login_count, locked_at FROM users WHERE email = $1 ` @@ -288,6 +315,48 @@ func (q *Queries) IsUserEnabled(ctx context.Context, id uuid.UUID) (bool, error) return enabled, err } +const listIdentitiesByProviderAndUsers = `-- name: ListIdentitiesByProviderAndUsers :many +SELECT id, user_id, provider, provider_user_id, email, created_at FROM user_identities +WHERE provider = $1 AND user_id = ANY($2::uuid[]) +` + +type ListIdentitiesByProviderAndUsersParams struct { + Provider string + Column2 []uuid.UUID +} + +// Returns identity rows for a given provider and set of user IDs. +// Used by SCIM list users to batch-load external IDs. +func (q *Queries) ListIdentitiesByProviderAndUsers(ctx context.Context, arg ListIdentitiesByProviderAndUsersParams) ([]UserIdentity, error) { + rows, err := q.db.QueryContext(ctx, listIdentitiesByProviderAndUsers, arg.Provider, pq.Array(arg.Column2)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UserIdentity + for rows.Next() { + var i UserIdentity + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Provider, + &i.ProviderUserID, + &i.Email, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const markRefreshTokenUsed = `-- name: MarkRefreshTokenUsed :exec UPDATE refresh_tokens SET used_at = now(), replaced_by_jti = $2 @@ -381,6 +450,52 @@ func (q *Queries) UpdatePasswordHash(ctx context.Context, arg UpdatePasswordHash return err } +const updateUserDisplayName = `-- name: UpdateUserDisplayName :exec +UPDATE users SET display_name = $2 WHERE id = $1 +` + +type UpdateUserDisplayNameParams struct { + ID uuid.UUID + DisplayName string +} + +// Updates a user's display name. Used by SCIM user provisioning. +func (q *Queries) UpdateUserDisplayName(ctx context.Context, arg UpdateUserDisplayNameParams) error { + _, err := q.db.ExecContext(ctx, updateUserDisplayName, arg.ID, arg.DisplayName) + return err +} + +const updateUserEmail = `-- name: UpdateUserEmail :exec +UPDATE users SET email = $2 WHERE id = $1 +` + +type UpdateUserEmailParams struct { + ID uuid.UUID + Email string +} + +// Updates a user's email. Used by SCIM user provisioning. +func (q *Queries) UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) error { + _, err := q.db.ExecContext(ctx, updateUserEmail, arg.ID, arg.Email) + return err +} + +const updateUserProfile = `-- name: UpdateUserProfile :exec +UPDATE users SET email = $2, display_name = $3 WHERE id = $1 +` + +type UpdateUserProfileParams struct { + ID uuid.UUID + Email string + DisplayName string +} + +// Updates a user's email and display name. Used by SCIM PUT (full replacement). +func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error { + _, err := q.db.ExecContext(ctx, updateUserProfile, arg.ID, arg.Email, arg.DisplayName) + return err +} + const upsertUserIdentity = `-- name: UpsertUserIdentity :exec INSERT INTO user_identities (user_id, provider, provider_user_id, email) VALUES ($1, $2, $3, $4) diff --git a/internal/store/generated/groups.sql.go b/internal/store/generated/groups.sql.go index 27a29a65..517ef6ad 100644 --- a/internal/store/generated/groups.sql.go +++ b/internal/store/generated/groups.sql.go @@ -28,6 +28,23 @@ func (q *Queries) AddGroupMember(ctx context.Context, arg AddGroupMemberParams) return err } +const addGroupMemberSCIMManaged = `-- name: AddGroupMemberSCIMManaged :exec +INSERT INTO group_members (group_id, user_id, org_id, scim_managed) +VALUES ($1, $2, $3, true) +ON CONFLICT (group_id, user_id) DO NOTHING +` + +type AddGroupMemberSCIMManagedParams struct { + GroupID uuid.UUID + UserID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) AddGroupMemberSCIMManaged(ctx context.Context, arg AddGroupMemberSCIMManagedParams) error { + _, err := q.db.ExecContext(ctx, addGroupMemberSCIMManaged, arg.GroupID, arg.UserID, arg.OrgID) + return err +} + const createGroup = `-- name: CreateGroup :one INSERT INTO groups (org_id, name, description) VALUES ($1, $2, $3) RETURNING id, org_id, name, description, created_at, deleted_at @@ -78,8 +95,47 @@ func (q *Queries) GetGroup(ctx context.Context, arg GetGroupParams) (Group, erro return i, err } +const getGroupIfActive = `-- name: GetGroupIfActive :one +SELECT id, org_id, name, description, created_at, deleted_at FROM groups WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL +` + +type GetGroupIfActiveParams struct { + ID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) GetGroupIfActive(ctx context.Context, arg GetGroupIfActiveParams) (Group, error) { + row := q.db.QueryRowContext(ctx, getGroupIfActive, arg.ID, arg.OrgID) + var i Group + err := row.Scan( + &i.ID, + &i.OrgID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.DeletedAt, + ) + return i, err +} + +const isGroupMemberSCIMManaged = `-- name: IsGroupMemberSCIMManaged :one +SELECT scim_managed FROM group_members WHERE group_id = $1 AND user_id = $2 +` + +type IsGroupMemberSCIMManagedParams struct { + GroupID uuid.UUID + UserID uuid.UUID +} + +func (q *Queries) IsGroupMemberSCIMManaged(ctx context.Context, arg IsGroupMemberSCIMManagedParams) (bool, error) { + row := q.db.QueryRowContext(ctx, isGroupMemberSCIMManaged, arg.GroupID, arg.UserID) + var scim_managed bool + err := row.Scan(&scim_managed) + return scim_managed, err +} + const listGroupMembers = `-- name: ListGroupMembers :many -SELECT gm.id, gm.org_id, gm.group_id, gm.user_id, gm.created_at, u.email, u.display_name +SELECT gm.id, gm.org_id, gm.group_id, gm.user_id, gm.created_at, gm.scim_managed, u.email, u.display_name FROM group_members gm JOIN users u ON u.id = gm.user_id WHERE gm.group_id = $1 AND gm.org_id = $2 ORDER BY u.display_name @@ -96,6 +152,7 @@ type ListGroupMembersRow struct { GroupID uuid.UUID UserID uuid.UUID CreatedAt time.Time + ScimManaged bool Email string DisplayName string } @@ -115,6 +172,7 @@ func (q *Queries) ListGroupMembers(ctx context.Context, arg ListGroupMembersPara &i.GroupID, &i.UserID, &i.CreatedAt, + &i.ScimManaged, &i.Email, &i.DisplayName, ); err != nil { @@ -180,6 +238,22 @@ func (q *Queries) RemoveGroupMember(ctx context.Context, arg RemoveGroupMemberPa return err } +const removeSCIMManagedGroupMember = `-- name: RemoveSCIMManagedGroupMember :exec +DELETE FROM group_members +WHERE group_id = $1 AND user_id = $2 AND org_id = $3 AND scim_managed = true +` + +type RemoveSCIMManagedGroupMemberParams struct { + GroupID uuid.UUID + UserID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) RemoveSCIMManagedGroupMember(ctx context.Context, arg RemoveSCIMManagedGroupMemberParams) error { + _, err := q.db.ExecContext(ctx, removeSCIMManagedGroupMember, arg.GroupID, arg.UserID, arg.OrgID) + return err +} + const softDeleteGroup = `-- name: SoftDeleteGroup :exec UPDATE groups SET deleted_at = now() WHERE id = $1 AND org_id = $2 ` diff --git a/internal/store/generated/models.go b/internal/store/generated/models.go index 4bdb1529..31caf57d 100644 --- a/internal/store/generated/models.go +++ b/internal/store/generated/models.go @@ -321,11 +321,12 @@ type Group struct { } type GroupMember struct { - ID uuid.UUID - OrgID uuid.UUID - GroupID uuid.UUID - UserID uuid.UUID - CreatedAt time.Time + ID uuid.UUID + OrgID uuid.UUID + GroupID uuid.UUID + UserID uuid.UUID + CreatedAt time.Time + ScimManaged bool } type JobQueue struct { @@ -424,11 +425,13 @@ type OrgInvitation struct { } type OrgMember struct { - OrgID uuid.UUID - UserID uuid.UUID - Role string - CreatedAt time.Time - UpdatedAt time.Time + OrgID uuid.UUID + UserID uuid.UUID + Role string + CreatedAt time.Time + UpdatedAt time.Time + DeactivatedAt sql.NullTime + ScimExempt bool } type Organization struct { @@ -501,6 +504,36 @@ type ScheduledReport struct { UpdatedAt time.Time } +type ScimConfig struct { + ID uuid.UUID + OrgID uuid.UUID + SsoConnectionID uuid.UUID + Enabled bool + TokenHash string + TokenPrefix string + DefaultRole string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ScimGroup struct { + ID uuid.UUID + OrgID uuid.UUID + ExternalID sql.NullString + DisplayName string + MappedRole sql.NullString + MappedGroupID uuid.NullUUID + CreatedAt time.Time + UpdatedAt time.Time +} + +type ScimGroupMember struct { + ScimGroupID uuid.UUID + UserID uuid.UUID + OrgID uuid.UUID + CreatedAt time.Time +} + type SecurityEvent struct { ID uuid.UUID EventType string diff --git a/internal/store/generated/org.sql.go b/internal/store/generated/org.sql.go index 3a322519..82ae1a2a 100644 --- a/internal/store/generated/org.sql.go +++ b/internal/store/generated/org.sql.go @@ -23,6 +23,30 @@ func (q *Queries) AcceptInvitation(ctx context.Context, id uuid.UUID) error { return err } +const countActiveOrgMembers = `-- name: CountActiveOrgMembers :one +SELECT COUNT(*)::int FROM org_members +WHERE org_id = $1 AND deactivated_at IS NULL +` + +func (q *Queries) CountActiveOrgMembers(ctx context.Context, orgID uuid.UUID) (int32, error) { + row := q.db.QueryRowContext(ctx, countActiveOrgMembers, orgID) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + +const countActiveOrgOwners = `-- name: CountActiveOrgOwners :one +SELECT COUNT(*)::int FROM org_members +WHERE org_id = $1 AND role = 'owner' AND deactivated_at IS NULL +` + +func (q *Queries) CountActiveOrgOwners(ctx context.Context, orgID uuid.UUID) (int32, error) { + row := q.db.QueryRowContext(ctx, countActiveOrgOwners, orgID) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + const countAlertRulesByOrg = `-- name: CountAlertRulesByOrg :one SELECT COUNT(*) FROM alert_rules WHERE org_id = $1 AND deleted_at IS NULL ` @@ -150,6 +174,21 @@ func (q *Queries) CreateOrgMember(ctx context.Context, arg CreateOrgMemberParams return err } +const deactivateOrgMember = `-- name: DeactivateOrgMember :exec +UPDATE org_members SET deactivated_at = now(), updated_at = now() +WHERE org_id = $1 AND user_id = $2 +` + +type DeactivateOrgMemberParams struct { + OrgID uuid.UUID + UserID uuid.UUID +} + +func (q *Queries) DeactivateOrgMember(ctx context.Context, arg DeactivateOrgMemberParams) error { + _, err := q.db.ExecContext(ctx, deactivateOrgMember, arg.OrgID, arg.UserID) + return err +} + const deleteOrgInvitation = `-- name: DeleteOrgInvitation :execresult DELETE FROM org_invitations WHERE id = $1 AND org_id = $2 ` @@ -246,6 +285,49 @@ func (q *Queries) GetOrgInvitationByID(ctx context.Context, arg GetOrgInvitation return i, err } +const getOrgMemberFull = `-- name: GetOrgMemberFull :one +SELECT om.org_id, om.user_id, om.role, om.created_at, om.updated_at, + om.deactivated_at, om.scim_exempt, + u.email, u.display_name +FROM org_members om +JOIN users u ON u.id = om.user_id +WHERE om.org_id = $1 AND om.user_id = $2 +` + +type GetOrgMemberFullParams struct { + OrgID uuid.UUID + UserID uuid.UUID +} + +type GetOrgMemberFullRow struct { + OrgID uuid.UUID + UserID uuid.UUID + Role string + CreatedAt time.Time + UpdatedAt time.Time + DeactivatedAt sql.NullTime + ScimExempt bool + Email string + DisplayName string +} + +func (q *Queries) GetOrgMemberFull(ctx context.Context, arg GetOrgMemberFullParams) (GetOrgMemberFullRow, error) { + row := q.db.QueryRowContext(ctx, getOrgMemberFull, arg.OrgID, arg.UserID) + var i GetOrgMemberFullRow + err := row.Scan( + &i.OrgID, + &i.UserID, + &i.Role, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeactivatedAt, + &i.ScimExempt, + &i.Email, + &i.DisplayName, + ) + return i, err +} + const getOrgMemberRole = `-- name: GetOrgMemberRole :one SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 LIMIT 1 ` @@ -262,6 +344,27 @@ func (q *Queries) GetOrgMemberRole(ctx context.Context, arg GetOrgMemberRolePara return role, err } +const getOrgMemberRoleAndStatus = `-- name: GetOrgMemberRoleAndStatus :one +SELECT role, deactivated_at FROM org_members WHERE org_id = $1 AND user_id = $2 LIMIT 1 +` + +type GetOrgMemberRoleAndStatusParams struct { + OrgID uuid.UUID + UserID uuid.UUID +} + +type GetOrgMemberRoleAndStatusRow struct { + Role string + DeactivatedAt sql.NullTime +} + +func (q *Queries) GetOrgMemberRoleAndStatus(ctx context.Context, arg GetOrgMemberRoleAndStatusParams) (GetOrgMemberRoleAndStatusRow, error) { + row := q.db.QueryRowContext(ctx, getOrgMemberRoleAndStatus, arg.OrgID, arg.UserID) + var i GetOrgMemberRoleAndStatusRow + err := row.Scan(&i.Role, &i.DeactivatedAt) + return i, err +} + const getOrgOwnerCount = `-- name: GetOrgOwnerCount :one SELECT COUNT(*) FROM org_members WHERE org_id = $1 AND role = 'owner' ` @@ -381,20 +484,22 @@ func (q *Queries) ListOrgInvitations(ctx context.Context, orgID uuid.UUID) ([]Or } const listOrgMembers = `-- name: ListOrgMembers :many -SELECT om.org_id, om.user_id, om.role, om.created_at, om.updated_at, u.email, u.display_name FROM org_members om +SELECT om.org_id, om.user_id, om.role, om.created_at, om.updated_at, om.deactivated_at, om.scim_exempt, u.email, u.display_name FROM org_members om JOIN users u ON u.id = om.user_id WHERE om.org_id = $1 ORDER BY om.created_at ` type ListOrgMembersRow struct { - OrgID uuid.UUID - UserID uuid.UUID - Role string - CreatedAt time.Time - UpdatedAt time.Time - Email string - DisplayName string + OrgID uuid.UUID + UserID uuid.UUID + Role string + CreatedAt time.Time + UpdatedAt time.Time + DeactivatedAt sql.NullTime + ScimExempt bool + Email string + DisplayName string } func (q *Queries) ListOrgMembers(ctx context.Context, orgID uuid.UUID) ([]ListOrgMembersRow, error) { @@ -412,6 +517,8 @@ func (q *Queries) ListOrgMembers(ctx context.Context, orgID uuid.UUID) ([]ListOr &i.Role, &i.CreatedAt, &i.UpdatedAt, + &i.DeactivatedAt, + &i.ScimExempt, &i.Email, &i.DisplayName, ); err != nil { @@ -464,6 +571,21 @@ func (q *Queries) ListUserOrgs(ctx context.Context, userID uuid.UUID) ([]ListUse return items, nil } +const reactivateOrgMember = `-- name: ReactivateOrgMember :exec +UPDATE org_members SET deactivated_at = NULL, updated_at = now() +WHERE org_id = $1 AND user_id = $2 +` + +type ReactivateOrgMemberParams struct { + OrgID uuid.UUID + UserID uuid.UUID +} + +func (q *Queries) ReactivateOrgMember(ctx context.Context, arg ReactivateOrgMemberParams) error { + _, err := q.db.ExecContext(ctx, reactivateOrgMember, arg.OrgID, arg.UserID) + return err +} + const updateOrg = `-- name: UpdateOrg :one UPDATE organizations SET name = $2 WHERE id = $1 AND deleted_at IS NULL RETURNING id, name, created_at, deleted_at, tier, tier_overrides, suspended_at, mfa_required_all, mfa_remember_device_allowed, mfa_remember_device_days @@ -547,6 +669,22 @@ func (q *Queries) UpdateOrgMemberRole(ctx context.Context, arg UpdateOrgMemberRo return err } +const updateOrgMemberSCIMExempt = `-- name: UpdateOrgMemberSCIMExempt :exec +UPDATE org_members SET scim_exempt = $3, updated_at = now() +WHERE org_id = $1 AND user_id = $2 +` + +type UpdateOrgMemberSCIMExemptParams struct { + OrgID uuid.UUID + UserID uuid.UUID + ScimExempt bool +} + +func (q *Queries) UpdateOrgMemberSCIMExempt(ctx context.Context, arg UpdateOrgMemberSCIMExemptParams) error { + _, err := q.db.ExecContext(ctx, updateOrgMemberSCIMExempt, arg.OrgID, arg.UserID, arg.ScimExempt) + return err +} + const updateOrgTier = `-- name: UpdateOrgTier :exec UPDATE organizations SET tier = $2 WHERE id = $1 ` diff --git a/internal/store/generated/scim_config.sql.go b/internal/store/generated/scim_config.sql.go new file mode 100644 index 00000000..acdb188b --- /dev/null +++ b/internal/store/generated/scim_config.sql.go @@ -0,0 +1,157 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: scim_config.sql + +package store + +import ( + "context" + + "github.com/google/uuid" +) + +const createSCIMConfig = `-- name: CreateSCIMConfig :one + +INSERT INTO scim_configs (org_id, sso_connection_id, enabled, token_hash, token_prefix, default_role) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, org_id, sso_connection_id, enabled, token_hash, token_prefix, default_role, created_at, updated_at +` + +type CreateSCIMConfigParams struct { + OrgID uuid.UUID + SsoConnectionID uuid.UUID + Enabled bool + TokenHash string + TokenPrefix string + DefaultRole string +} + +// ABOUTME: sqlc queries for SCIM config CRUD. +// ABOUTME: Token lookup uses withBypassTx (pre-org-context auth). Config CRUD uses withOrgTx. +func (q *Queries) CreateSCIMConfig(ctx context.Context, arg CreateSCIMConfigParams) (ScimConfig, error) { + row := q.db.QueryRowContext(ctx, createSCIMConfig, + arg.OrgID, + arg.SsoConnectionID, + arg.Enabled, + arg.TokenHash, + arg.TokenPrefix, + arg.DefaultRole, + ) + var i ScimConfig + err := row.Scan( + &i.ID, + &i.OrgID, + &i.SsoConnectionID, + &i.Enabled, + &i.TokenHash, + &i.TokenPrefix, + &i.DefaultRole, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteSCIMConfig = `-- name: DeleteSCIMConfig :exec +DELETE FROM scim_configs WHERE org_id = $1 +` + +func (q *Queries) DeleteSCIMConfig(ctx context.Context, orgID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteSCIMConfig, orgID) + return err +} + +const getSCIMConfigByOrgID = `-- name: GetSCIMConfigByOrgID :one +SELECT id, org_id, sso_connection_id, enabled, token_hash, token_prefix, default_role, created_at, updated_at FROM scim_configs WHERE org_id = $1 +` + +func (q *Queries) GetSCIMConfigByOrgID(ctx context.Context, orgID uuid.UUID) (ScimConfig, error) { + row := q.db.QueryRowContext(ctx, getSCIMConfigByOrgID, orgID) + var i ScimConfig + err := row.Scan( + &i.ID, + &i.OrgID, + &i.SsoConnectionID, + &i.Enabled, + &i.TokenHash, + &i.TokenPrefix, + &i.DefaultRole, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSCIMConfigBySSOConnectionID = `-- name: GetSCIMConfigBySSOConnectionID :one +SELECT id, org_id, sso_connection_id, enabled, token_hash, token_prefix, default_role, created_at, updated_at FROM scim_configs WHERE sso_connection_id = $1 +` + +func (q *Queries) GetSCIMConfigBySSOConnectionID(ctx context.Context, ssoConnectionID uuid.UUID) (ScimConfig, error) { + row := q.db.QueryRowContext(ctx, getSCIMConfigBySSOConnectionID, ssoConnectionID) + var i ScimConfig + err := row.Scan( + &i.ID, + &i.OrgID, + &i.SsoConnectionID, + &i.Enabled, + &i.TokenHash, + &i.TokenPrefix, + &i.DefaultRole, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSCIMConfigByTokenHash = `-- name: GetSCIMConfigByTokenHash :one +SELECT id, org_id, sso_connection_id, enabled, token_hash, token_prefix, default_role, created_at, updated_at FROM scim_configs WHERE token_hash = $1 +` + +func (q *Queries) GetSCIMConfigByTokenHash(ctx context.Context, tokenHash string) (ScimConfig, error) { + row := q.db.QueryRowContext(ctx, getSCIMConfigByTokenHash, tokenHash) + var i ScimConfig + err := row.Scan( + &i.ID, + &i.OrgID, + &i.SsoConnectionID, + &i.Enabled, + &i.TokenHash, + &i.TokenPrefix, + &i.DefaultRole, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateSCIMConfig = `-- name: UpdateSCIMConfig :exec +UPDATE scim_configs SET enabled = $2, default_role = $3, updated_at = now() +WHERE org_id = $1 +` + +type UpdateSCIMConfigParams struct { + OrgID uuid.UUID + Enabled bool + DefaultRole string +} + +func (q *Queries) UpdateSCIMConfig(ctx context.Context, arg UpdateSCIMConfigParams) error { + _, err := q.db.ExecContext(ctx, updateSCIMConfig, arg.OrgID, arg.Enabled, arg.DefaultRole) + return err +} + +const updateSCIMConfigToken = `-- name: UpdateSCIMConfigToken :exec +UPDATE scim_configs SET token_hash = $2, token_prefix = $3, updated_at = now() +WHERE org_id = $1 +` + +type UpdateSCIMConfigTokenParams struct { + OrgID uuid.UUID + TokenHash string + TokenPrefix string +} + +func (q *Queries) UpdateSCIMConfigToken(ctx context.Context, arg UpdateSCIMConfigTokenParams) error { + _, err := q.db.ExecContext(ctx, updateSCIMConfigToken, arg.OrgID, arg.TokenHash, arg.TokenPrefix) + return err +} diff --git a/internal/store/generated/scim_groups.sql.go b/internal/store/generated/scim_groups.sql.go new file mode 100644 index 00000000..71068c04 --- /dev/null +++ b/internal/store/generated/scim_groups.sql.go @@ -0,0 +1,375 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: scim_groups.sql + +package store + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +const addSCIMGroupMember = `-- name: AddSCIMGroupMember :exec +INSERT INTO scim_group_members (scim_group_id, user_id, org_id) +VALUES ($1, $2, $3) +ON CONFLICT (scim_group_id, user_id) DO NOTHING +` + +type AddSCIMGroupMemberParams struct { + ScimGroupID uuid.UUID + UserID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) AddSCIMGroupMember(ctx context.Context, arg AddSCIMGroupMemberParams) error { + _, err := q.db.ExecContext(ctx, addSCIMGroupMember, arg.ScimGroupID, arg.UserID, arg.OrgID) + return err +} + +const countOtherSCIMGroupsWithSameMapping = `-- name: CountOtherSCIMGroupsWithSameMapping :one +SELECT COUNT(*)::int FROM scim_group_members sgm +JOIN scim_groups sg ON sgm.scim_group_id = sg.id +WHERE sgm.user_id = $1 + AND sg.mapped_group_id = $2 + AND sg.id != $3 + AND sgm.org_id = $4 +` + +type CountOtherSCIMGroupsWithSameMappingParams struct { + UserID uuid.UUID + MappedGroupID uuid.NullUUID + ID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) CountOtherSCIMGroupsWithSameMapping(ctx context.Context, arg CountOtherSCIMGroupsWithSameMappingParams) (int32, error) { + row := q.db.QueryRowContext(ctx, countOtherSCIMGroupsWithSameMapping, + arg.UserID, + arg.MappedGroupID, + arg.ID, + arg.OrgID, + ) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + +const createSCIMGroup = `-- name: CreateSCIMGroup :one + +INSERT INTO scim_groups (org_id, external_id, display_name) +VALUES ($1, $2, $3) RETURNING id, org_id, external_id, display_name, mapped_role, mapped_group_id, created_at, updated_at +` + +type CreateSCIMGroupParams struct { + OrgID uuid.UUID + ExternalID sql.NullString + DisplayName string +} + +// ABOUTME: sqlc queries for SCIM group and membership management. +// ABOUTME: All queries are org-scoped via RLS. scim_group_members denormalize org_id. +func (q *Queries) CreateSCIMGroup(ctx context.Context, arg CreateSCIMGroupParams) (ScimGroup, error) { + row := q.db.QueryRowContext(ctx, createSCIMGroup, arg.OrgID, arg.ExternalID, arg.DisplayName) + var i ScimGroup + err := row.Scan( + &i.ID, + &i.OrgID, + &i.ExternalID, + &i.DisplayName, + &i.MappedRole, + &i.MappedGroupID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteSCIMGroup = `-- name: DeleteSCIMGroup :exec +DELETE FROM scim_groups WHERE id = $1 AND org_id = $2 +` + +type DeleteSCIMGroupParams struct { + ID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) DeleteSCIMGroup(ctx context.Context, arg DeleteSCIMGroupParams) error { + _, err := q.db.ExecContext(ctx, deleteSCIMGroup, arg.ID, arg.OrgID) + return err +} + +const getSCIMGroupByDisplayName = `-- name: GetSCIMGroupByDisplayName :one +SELECT id, org_id, external_id, display_name, mapped_role, mapped_group_id, created_at, updated_at FROM scim_groups WHERE org_id = $1 AND display_name = $2 +` + +type GetSCIMGroupByDisplayNameParams struct { + OrgID uuid.UUID + DisplayName string +} + +func (q *Queries) GetSCIMGroupByDisplayName(ctx context.Context, arg GetSCIMGroupByDisplayNameParams) (ScimGroup, error) { + row := q.db.QueryRowContext(ctx, getSCIMGroupByDisplayName, arg.OrgID, arg.DisplayName) + var i ScimGroup + err := row.Scan( + &i.ID, + &i.OrgID, + &i.ExternalID, + &i.DisplayName, + &i.MappedRole, + &i.MappedGroupID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSCIMGroupByExternalID = `-- name: GetSCIMGroupByExternalID :one +SELECT id, org_id, external_id, display_name, mapped_role, mapped_group_id, created_at, updated_at FROM scim_groups WHERE org_id = $1 AND external_id = $2 +` + +type GetSCIMGroupByExternalIDParams struct { + OrgID uuid.UUID + ExternalID sql.NullString +} + +func (q *Queries) GetSCIMGroupByExternalID(ctx context.Context, arg GetSCIMGroupByExternalIDParams) (ScimGroup, error) { + row := q.db.QueryRowContext(ctx, getSCIMGroupByExternalID, arg.OrgID, arg.ExternalID) + var i ScimGroup + err := row.Scan( + &i.ID, + &i.OrgID, + &i.ExternalID, + &i.DisplayName, + &i.MappedRole, + &i.MappedGroupID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSCIMGroupByID = `-- name: GetSCIMGroupByID :one +SELECT id, org_id, external_id, display_name, mapped_role, mapped_group_id, created_at, updated_at FROM scim_groups WHERE id = $1 AND org_id = $2 +` + +type GetSCIMGroupByIDParams struct { + ID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) GetSCIMGroupByID(ctx context.Context, arg GetSCIMGroupByIDParams) (ScimGroup, error) { + row := q.db.QueryRowContext(ctx, getSCIMGroupByID, arg.ID, arg.OrgID) + var i ScimGroup + err := row.Scan( + &i.ID, + &i.OrgID, + &i.ExternalID, + &i.DisplayName, + &i.MappedRole, + &i.MappedGroupID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listSCIMGroupMembers = `-- name: ListSCIMGroupMembers :many +SELECT user_id FROM scim_group_members WHERE scim_group_id = $1 AND org_id = $2 +` + +type ListSCIMGroupMembersParams struct { + ScimGroupID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) ListSCIMGroupMembers(ctx context.Context, arg ListSCIMGroupMembersParams) ([]uuid.UUID, error) { + rows, err := q.db.QueryContext(ctx, listSCIMGroupMembers, arg.ScimGroupID, arg.OrgID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var user_id uuid.UUID + if err := rows.Scan(&user_id); err != nil { + return nil, err + } + items = append(items, user_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSCIMGroups = `-- name: ListSCIMGroups :many +SELECT sg.id, sg.org_id, sg.external_id, sg.display_name, sg.mapped_role, sg.mapped_group_id, sg.created_at, sg.updated_at, COUNT(sgm.user_id)::int AS member_count +FROM scim_groups sg +LEFT JOIN scim_group_members sgm ON sgm.scim_group_id = sg.id +WHERE sg.org_id = $1 +GROUP BY sg.id +ORDER BY sg.display_name +` + +type ListSCIMGroupsRow struct { + ID uuid.UUID + OrgID uuid.UUID + ExternalID sql.NullString + DisplayName string + MappedRole sql.NullString + MappedGroupID uuid.NullUUID + CreatedAt time.Time + UpdatedAt time.Time + MemberCount int32 +} + +func (q *Queries) ListSCIMGroups(ctx context.Context, orgID uuid.UUID) ([]ListSCIMGroupsRow, error) { + rows, err := q.db.QueryContext(ctx, listSCIMGroups, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListSCIMGroupsRow + for rows.Next() { + var i ListSCIMGroupsRow + if err := rows.Scan( + &i.ID, + &i.OrgID, + &i.ExternalID, + &i.DisplayName, + &i.MappedRole, + &i.MappedGroupID, + &i.CreatedAt, + &i.UpdatedAt, + &i.MemberCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUserSCIMGroups = `-- name: ListUserSCIMGroups :many +SELECT sg.id, sg.org_id, sg.external_id, sg.display_name, sg.mapped_role, sg.mapped_group_id, sg.created_at, sg.updated_at FROM scim_groups sg +JOIN scim_group_members sgm ON sgm.scim_group_id = sg.id +WHERE sgm.user_id = $1 AND sg.org_id = $2 +` + +type ListUserSCIMGroupsParams struct { + UserID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) ListUserSCIMGroups(ctx context.Context, arg ListUserSCIMGroupsParams) ([]ScimGroup, error) { + rows, err := q.db.QueryContext(ctx, listUserSCIMGroups, arg.UserID, arg.OrgID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ScimGroup + for rows.Next() { + var i ScimGroup + if err := rows.Scan( + &i.ID, + &i.OrgID, + &i.ExternalID, + &i.DisplayName, + &i.MappedRole, + &i.MappedGroupID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeSCIMGroupMember = `-- name: RemoveSCIMGroupMember :exec +DELETE FROM scim_group_members WHERE scim_group_id = $1 AND user_id = $2 AND org_id = $3 +` + +type RemoveSCIMGroupMemberParams struct { + ScimGroupID uuid.UUID + UserID uuid.UUID + OrgID uuid.UUID +} + +func (q *Queries) RemoveSCIMGroupMember(ctx context.Context, arg RemoveSCIMGroupMemberParams) error { + _, err := q.db.ExecContext(ctx, removeSCIMGroupMember, arg.ScimGroupID, arg.UserID, arg.OrgID) + return err +} + +const setSCIMGroupMembers_Delete = `-- name: SetSCIMGroupMembers_Delete :exec +DELETE FROM scim_group_members WHERE scim_group_id = $1 +` + +func (q *Queries) SetSCIMGroupMembers_Delete(ctx context.Context, scimGroupID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, setSCIMGroupMembers_Delete, scimGroupID) + return err +} + +const updateSCIMGroup = `-- name: UpdateSCIMGroup :exec +UPDATE scim_groups SET display_name = $2, external_id = $3, updated_at = now() +WHERE id = $1 AND org_id = $4 +` + +type UpdateSCIMGroupParams struct { + ID uuid.UUID + DisplayName string + ExternalID sql.NullString + OrgID uuid.UUID +} + +func (q *Queries) UpdateSCIMGroup(ctx context.Context, arg UpdateSCIMGroupParams) error { + _, err := q.db.ExecContext(ctx, updateSCIMGroup, + arg.ID, + arg.DisplayName, + arg.ExternalID, + arg.OrgID, + ) + return err +} + +const updateSCIMGroupMapping = `-- name: UpdateSCIMGroupMapping :exec +UPDATE scim_groups SET mapped_role = $2, mapped_group_id = $3, updated_at = now() +WHERE id = $1 AND org_id = $4 +` + +type UpdateSCIMGroupMappingParams struct { + ID uuid.UUID + MappedRole sql.NullString + MappedGroupID uuid.NullUUID + OrgID uuid.UUID +} + +func (q *Queries) UpdateSCIMGroupMapping(ctx context.Context, arg UpdateSCIMGroupMappingParams) error { + _, err := q.db.ExecContext(ctx, updateSCIMGroupMapping, + arg.ID, + arg.MappedRole, + arg.MappedGroupID, + arg.OrgID, + ) + return err +} diff --git a/internal/store/group.go b/internal/store/group.go index 85f7de23..4ff916ce 100644 --- a/internal/store/group.go +++ b/internal/store/group.go @@ -141,3 +141,57 @@ func (s *Store) ListGroupMembers(ctx context.Context, orgID, groupID uuid.UUID) }) return rows, err } + +// GetGroupIfActive returns the group if it exists and is not soft-deleted, +// or (nil, nil) if not found or deleted. +func (s *Store) GetGroupIfActive(ctx context.Context, orgID uuid.UUID, id uuid.UUID) (*generated.Group, error) { + var result *generated.Group + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + row, err := q.GetGroupIfActive(ctx, generated.GetGroupIfActiveParams{ + ID: id, + OrgID: orgID, + }) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + result = &row + return nil + }) + if err != nil { + return nil, fmt.Errorf("get group if active: %w", err) + } + return result, nil +} + +// AddGroupMemberSCIMManaged adds a user to the group with scim_managed=true. +// Idempotent — ON CONFLICT DO NOTHING preserves existing memberships (including manual ones). +func (s *Store) AddGroupMemberSCIMManaged(ctx context.Context, groupID, userID, orgID uuid.UUID) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + if err := q.AddGroupMemberSCIMManaged(ctx, generated.AddGroupMemberSCIMManagedParams{ + GroupID: groupID, + UserID: userID, + OrgID: orgID, + }); err != nil { + return fmt.Errorf("add group member scim managed: %w", err) + } + return nil + }) +} + +// RemoveSCIMManagedGroupMember removes a user from a group only if their +// membership is scim_managed=true. Manual memberships are left intact. +func (s *Store) RemoveSCIMManagedGroupMember(ctx context.Context, groupID, userID, orgID uuid.UUID) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + if err := q.RemoveSCIMManagedGroupMember(ctx, generated.RemoveSCIMManagedGroupMemberParams{ + GroupID: groupID, + UserID: userID, + OrgID: orgID, + }); err != nil { + return fmt.Errorf("remove scim managed group member: %w", err) + } + return nil + }) +} diff --git a/internal/store/org.go b/internal/store/org.go index ad92c982..49bd7252 100644 --- a/internal/store/org.go +++ b/internal/store/org.go @@ -197,6 +197,31 @@ func (s *Store) GetOrgMemberRole(ctx context.Context, orgID, userID uuid.UUID) ( return result, nil } +// GetOrgMemberRoleAndStatus returns the role and deactivation status of userID in orgID, +// or (nil, nil) if not a member. +// Executes with RLS bypass — called from RequireOrgRole middleware before org context is set. +func (s *Store) GetOrgMemberRoleAndStatus(ctx context.Context, orgID, userID uuid.UUID) (*generated.GetOrgMemberRoleAndStatusRow, error) { + var result *generated.GetOrgMemberRoleAndStatusRow + err := s.withBypassTx(ctx, func(q *generated.Queries) error { + row, err := q.GetOrgMemberRoleAndStatus(ctx, generated.GetOrgMemberRoleAndStatusParams{ + OrgID: orgID, + UserID: userID, + }) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + result = &row + return nil + }) + if err != nil { + return nil, fmt.Errorf("get org member role and status: %w", err) + } + return result, nil +} + // ListOrgMembers returns all members of an org ordered by join time. func (s *Store) ListOrgMembers(ctx context.Context, orgID uuid.UUID) ([]generated.ListOrgMembersRow, error) { var rows []generated.ListOrgMembersRow @@ -525,3 +550,95 @@ func (s *Store) CountMemberSlotsUsedByOrg(ctx context.Context, orgID uuid.UUID) }) return n, err } + +// DeactivateOrgMember sets deactivated_at on the org member row. +func (s *Store) DeactivateOrgMember(ctx context.Context, orgID, userID uuid.UUID) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + if err := q.DeactivateOrgMember(ctx, generated.DeactivateOrgMemberParams{ + OrgID: orgID, + UserID: userID, + }); err != nil { + return fmt.Errorf("deactivate org member: %w", err) + } + return nil + }) +} + +// ReactivateOrgMember clears deactivated_at on the org member row. +func (s *Store) ReactivateOrgMember(ctx context.Context, orgID, userID uuid.UUID) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + if err := q.ReactivateOrgMember(ctx, generated.ReactivateOrgMemberParams{ + OrgID: orgID, + UserID: userID, + }); err != nil { + return fmt.Errorf("reactivate org member: %w", err) + } + return nil + }) +} + +// GetOrgMemberFull returns full member info including deactivation status and user details. +// Returns (nil, nil) if the member is not found. +func (s *Store) GetOrgMemberFull(ctx context.Context, orgID, userID uuid.UUID) (*generated.GetOrgMemberFullRow, error) { + var result *generated.GetOrgMemberFullRow + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + row, err := q.GetOrgMemberFull(ctx, generated.GetOrgMemberFullParams{ + OrgID: orgID, + UserID: userID, + }) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + result = &row + return nil + }) + if err != nil { + return nil, fmt.Errorf("get org member full: %w", err) + } + return result, nil +} + +// CountActiveOrgMembers returns the count of non-deactivated members in the org. +func (s *Store) CountActiveOrgMembers(ctx context.Context, orgID uuid.UUID) (int, error) { + var n int32 + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + var err error + n, err = q.CountActiveOrgMembers(ctx, orgID) + if err != nil { + return fmt.Errorf("count active org members: %w", err) + } + return nil + }) + return int(n), err +} + +// CountActiveOrgOwners returns the count of non-deactivated owners in the org. +func (s *Store) CountActiveOrgOwners(ctx context.Context, orgID uuid.UUID) (int, error) { + var n int32 + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + var err error + n, err = q.CountActiveOrgOwners(ctx, orgID) + if err != nil { + return fmt.Errorf("count active org owners: %w", err) + } + return nil + }) + return int(n), err +} + +// UpdateOrgMemberSCIMExempt sets the scim_exempt flag on the org member row. +func (s *Store) UpdateOrgMemberSCIMExempt(ctx context.Context, orgID, userID uuid.UUID, exempt bool) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + if err := q.UpdateOrgMemberSCIMExempt(ctx, generated.UpdateOrgMemberSCIMExemptParams{ + OrgID: orgID, + UserID: userID, + ScimExempt: exempt, + }); err != nil { + return fmt.Errorf("update org member scim exempt: %w", err) + } + return nil + }) +} diff --git a/internal/store/org_deactivation_test.go b/internal/store/org_deactivation_test.go new file mode 100644 index 00000000..24f8b7f4 --- /dev/null +++ b/internal/store/org_deactivation_test.go @@ -0,0 +1,197 @@ +// ABOUTME: Integration tests for org member deactivation, reactivation, and SCIM exempt flag. +// ABOUTME: Uses testutil.NewTestDB; each test runs in its own container (t.Parallel). +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/scarson/cvert-ops/internal/testutil" +) + +func TestDeactivateOrgMember(t *testing.T) { + t.Parallel() + s := testutil.NewTestDB(t) + ctx := context.Background() + + org := s.MustCreateOrg(t, ctx, "DeactivateOrg") + user := s.MustCreateUser(t, ctx, "deact@example.com", "Deact", "", 0) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, user.ID, "member")) + + require.NoError(t, s.DeactivateOrgMember(ctx, org.ID, user.ID)) + + member, err := s.GetOrgMemberFull(ctx, org.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, member) + require.True(t, member.DeactivatedAt.Valid, "DeactivatedAt should be set") + require.WithinDuration(t, time.Now(), member.DeactivatedAt.Time, time.Minute, + "DeactivatedAt should be recent") +} + +func TestReactivateOrgMember(t *testing.T) { + t.Parallel() + s := testutil.NewTestDB(t) + ctx := context.Background() + + org := s.MustCreateOrg(t, ctx, "ReactivateOrg") + user := s.MustCreateUser(t, ctx, "react@example.com", "React", "", 0) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, user.ID, "member")) + + require.NoError(t, s.DeactivateOrgMember(ctx, org.ID, user.ID)) + require.NoError(t, s.ReactivateOrgMember(ctx, org.ID, user.ID)) + + member, err := s.GetOrgMemberFull(ctx, org.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, member) + require.False(t, member.DeactivatedAt.Valid, "DeactivatedAt should be cleared after reactivation") +} + +func TestCountActiveOrgMembers(t *testing.T) { + t.Parallel() + s := testutil.NewTestDB(t) + ctx := context.Background() + + org := s.MustCreateOrg(t, ctx, "CountActiveOrg") + u1 := s.MustCreateUser(t, ctx, "active1@example.com", "Active1", "", 0) + u2 := s.MustCreateUser(t, ctx, "active2@example.com", "Active2", "", 0) + u3 := s.MustCreateUser(t, ctx, "deact3@example.com", "Deact3", "", 0) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, u1.ID, "member")) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, u2.ID, "member")) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, u3.ID, "member")) + + // Deactivate one member. + require.NoError(t, s.DeactivateOrgMember(ctx, org.ID, u3.ID)) + + count, err := s.CountActiveOrgMembers(ctx, org.ID) + require.NoError(t, err) + require.Equal(t, 2, count) +} + +func TestCountActiveOrgOwners_SoleOwner(t *testing.T) { + t.Parallel() + s := testutil.NewTestDB(t) + ctx := context.Background() + + org := s.MustCreateOrg(t, ctx, "SoleOwnerOrg") + owner := s.MustCreateUser(t, ctx, "soleowner@example.com", "SoleOwner", "", 0) + member := s.MustCreateUser(t, ctx, "normalmember@example.com", "Member", "", 0) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, owner.ID, "owner")) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, member.ID, "member")) + + count, err := s.CountActiveOrgOwners(ctx, org.ID) + require.NoError(t, err) + require.Equal(t, 1, count) + + // Deactivate the sole owner. + require.NoError(t, s.DeactivateOrgMember(ctx, org.ID, owner.ID)) + + count, err = s.CountActiveOrgOwners(ctx, org.ID) + require.NoError(t, err) + require.Equal(t, 0, count) +} + +func TestCountActiveOrgOwners_MultipleOwners(t *testing.T) { + t.Parallel() + s := testutil.NewTestDB(t) + ctx := context.Background() + + org := s.MustCreateOrg(t, ctx, "MultiOwnerOrg") + o1 := s.MustCreateUser(t, ctx, "owner1m@example.com", "Owner1", "", 0) + o2 := s.MustCreateUser(t, ctx, "owner2m@example.com", "Owner2", "", 0) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, o1.ID, "owner")) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, o2.ID, "owner")) + + // Deactivate one owner. + require.NoError(t, s.DeactivateOrgMember(ctx, org.ID, o1.ID)) + + count, err := s.CountActiveOrgOwners(ctx, org.ID) + require.NoError(t, err) + require.Equal(t, 1, count) +} + +func TestUpdateOrgMemberSCIMExempt(t *testing.T) { + t.Parallel() + s := testutil.NewTestDB(t) + ctx := context.Background() + + org := s.MustCreateOrg(t, ctx, "SCIMExemptOrg") + user := s.MustCreateUser(t, ctx, "scimexempt@example.com", "SCIMExempt", "", 0) + require.NoError(t, s.CreateOrgMember(ctx, org.ID, user.ID, "member")) + + // Set exempt to true. + require.NoError(t, s.UpdateOrgMemberSCIMExempt(ctx, org.ID, user.ID, true)) + + member, err := s.GetOrgMemberFull(ctx, org.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, member) + require.True(t, member.ScimExempt) + + // Set exempt back to false. + require.NoError(t, s.UpdateOrgMemberSCIMExempt(ctx, org.ID, user.ID, false)) + + member, err = s.GetOrgMemberFull(ctx, org.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, member) + require.False(t, member.ScimExempt) +} + +func TestDeactivation_RLSIsolation(t *testing.T) { + t.Parallel() + s := testutil.NewTestDB(t) + ctx := context.Background() + + orgA := s.MustCreateOrg(t, ctx, "RLS_OrgA") + orgB := s.MustCreateOrg(t, ctx, "RLS_OrgB") + user := s.MustCreateUser(t, ctx, "rlsuser@example.com", "RLSUser", "", 0) + require.NoError(t, s.CreateOrgMember(ctx, orgA.ID, user.ID, "member")) + require.NoError(t, s.CreateOrgMember(ctx, orgB.ID, user.ID, "member")) + + // Deactivate via org A scope — should work. + require.NoError(t, s.DeactivateOrgMember(ctx, orgA.ID, user.ID)) + + memberA, err := s.GetOrgMemberFull(ctx, orgA.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, memberA) + require.True(t, memberA.DeactivatedAt.Valid, "member in org A should be deactivated") + + // Try deactivating via org B scope (for the user in org A) — should have no effect. + // The user is in org B too, but we're checking that org B scoped operation + // can't affect org A membership. + require.NoError(t, s.DeactivateOrgMember(ctx, orgB.ID, user.ID)) + + // Verify org B membership is now deactivated (that's expected — it's the user's + // own membership in org B). + memberB, err := s.GetOrgMemberFull(ctx, orgB.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, memberB) + require.True(t, memberB.DeactivatedAt.Valid, "member in org B should be deactivated") + + // Now reactivate org A membership and verify org B is still deactivated. + require.NoError(t, s.ReactivateOrgMember(ctx, orgA.ID, user.ID)) + + memberA, err = s.GetOrgMemberFull(ctx, orgA.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, memberA) + require.False(t, memberA.DeactivatedAt.Valid, "member in org A should be reactivated") + + memberB, err = s.GetOrgMemberFull(ctx, orgB.ID, user.ID) + require.NoError(t, err) + require.NotNil(t, memberB) + require.True(t, memberB.DeactivatedAt.Valid, "member in org B should still be deactivated") +} + +func TestGetOrgMemberFull_NotFound(t *testing.T) { + t.Parallel() + s := testutil.NewTestDB(t) + ctx := context.Background() + + org := s.MustCreateOrg(t, ctx, "NotFoundOrg") + + member, err := s.GetOrgMemberFull(ctx, org.ID, uuid.New()) + require.NoError(t, err) + require.Nil(t, member, "non-existent member should return nil, nil") +} diff --git a/internal/store/queries/auth.sql b/internal/store/queries/auth.sql index 514dfeb8..9acc2ff1 100644 --- a/internal/store/queries/auth.sql +++ b/internal/store/queries/auth.sql @@ -96,3 +96,27 @@ UPDATE users SET failed_login_count = 0, locked_at = NULL WHERE email = @email; -- name: GetLoginLockoutState :one -- Returns lockout state for a user by email. SELECT failed_login_count, locked_at FROM users WHERE email = @email; + +-- name: UpdateUserEmail :exec +-- Updates a user's email. Used by SCIM user provisioning. +UPDATE users SET email = $2 WHERE id = $1; + +-- name: UpdateUserDisplayName :exec +-- Updates a user's display name. Used by SCIM user provisioning. +UPDATE users SET display_name = $2 WHERE id = $1; + +-- name: UpdateUserProfile :exec +-- Updates a user's email and display name. Used by SCIM PUT (full replacement). +UPDATE users SET email = $2, display_name = $3 WHERE id = $1; + +-- name: GetIdentityByProviderAndUser :one +-- Returns the identity row for a given provider and user, or no rows. +SELECT * FROM user_identities +WHERE provider = $1 AND user_id = $2 +LIMIT 1; + +-- name: ListIdentitiesByProviderAndUsers :many +-- Returns identity rows for a given provider and set of user IDs. +-- Used by SCIM list users to batch-load external IDs. +SELECT * FROM user_identities +WHERE provider = $1 AND user_id = ANY($2::uuid[]); diff --git a/internal/store/queries/groups.sql b/internal/store/queries/groups.sql index f0239a2d..34d85d50 100644 --- a/internal/store/queries/groups.sql +++ b/internal/store/queries/groups.sql @@ -28,3 +28,18 @@ SELECT gm.*, u.email, u.display_name FROM group_members gm JOIN users u ON u.id = gm.user_id WHERE gm.group_id = $1 AND gm.org_id = $2 ORDER BY u.display_name; + +-- name: AddGroupMemberSCIMManaged :exec +INSERT INTO group_members (group_id, user_id, org_id, scim_managed) +VALUES ($1, $2, $3, true) +ON CONFLICT (group_id, user_id) DO NOTHING; + +-- name: RemoveSCIMManagedGroupMember :exec +DELETE FROM group_members +WHERE group_id = $1 AND user_id = $2 AND org_id = $3 AND scim_managed = true; + +-- name: IsGroupMemberSCIMManaged :one +SELECT scim_managed FROM group_members WHERE group_id = $1 AND user_id = $2; + +-- name: GetGroupIfActive :one +SELECT * FROM groups WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL; diff --git a/internal/store/queries/org.sql b/internal/store/queries/org.sql index d6947975..ff40db8e 100644 --- a/internal/store/queries/org.sql +++ b/internal/store/queries/org.sql @@ -18,6 +18,9 @@ ON CONFLICT (org_id, user_id) DO NOTHING; -- name: GetOrgMemberRole :one SELECT role FROM org_members WHERE org_id = $1 AND user_id = $2 LIMIT 1; +-- name: GetOrgMemberRoleAndStatus :one +SELECT role, deactivated_at FROM org_members WHERE org_id = $1 AND user_id = $2 LIMIT 1; + -- name: ListOrgMembers :many SELECT om.*, u.email, u.display_name FROM org_members om JOIN users u ON u.id = om.user_id @@ -99,3 +102,31 @@ AS bigint); -- name: ListAllOrgs :many SELECT id, tier, tier_overrides FROM organizations WHERE deleted_at IS NULL; + +-- name: DeactivateOrgMember :exec +UPDATE org_members SET deactivated_at = now(), updated_at = now() +WHERE org_id = $1 AND user_id = $2; + +-- name: ReactivateOrgMember :exec +UPDATE org_members SET deactivated_at = NULL, updated_at = now() +WHERE org_id = $1 AND user_id = $2; + +-- name: GetOrgMemberFull :one +SELECT om.org_id, om.user_id, om.role, om.created_at, om.updated_at, + om.deactivated_at, om.scim_exempt, + u.email, u.display_name +FROM org_members om +JOIN users u ON u.id = om.user_id +WHERE om.org_id = $1 AND om.user_id = $2; + +-- name: CountActiveOrgMembers :one +SELECT COUNT(*)::int FROM org_members +WHERE org_id = $1 AND deactivated_at IS NULL; + +-- name: CountActiveOrgOwners :one +SELECT COUNT(*)::int FROM org_members +WHERE org_id = $1 AND role = 'owner' AND deactivated_at IS NULL; + +-- name: UpdateOrgMemberSCIMExempt :exec +UPDATE org_members SET scim_exempt = $3, updated_at = now() +WHERE org_id = $1 AND user_id = $2; diff --git a/internal/store/queries/scim_config.sql b/internal/store/queries/scim_config.sql new file mode 100644 index 00000000..005fb407 --- /dev/null +++ b/internal/store/queries/scim_config.sql @@ -0,0 +1,26 @@ +-- ABOUTME: sqlc queries for SCIM config CRUD. +-- ABOUTME: Token lookup uses withBypassTx (pre-org-context auth). Config CRUD uses withOrgTx. + +-- name: CreateSCIMConfig :one +INSERT INTO scim_configs (org_id, sso_connection_id, enabled, token_hash, token_prefix, default_role) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; + +-- name: GetSCIMConfigByOrgID :one +SELECT * FROM scim_configs WHERE org_id = $1; + +-- name: GetSCIMConfigByTokenHash :one +SELECT * FROM scim_configs WHERE token_hash = $1; + +-- name: GetSCIMConfigBySSOConnectionID :one +SELECT * FROM scim_configs WHERE sso_connection_id = $1; + +-- name: UpdateSCIMConfig :exec +UPDATE scim_configs SET enabled = $2, default_role = $3, updated_at = now() +WHERE org_id = $1; + +-- name: UpdateSCIMConfigToken :exec +UPDATE scim_configs SET token_hash = $2, token_prefix = $3, updated_at = now() +WHERE org_id = $1; + +-- name: DeleteSCIMConfig :exec +DELETE FROM scim_configs WHERE org_id = $1; diff --git a/internal/store/queries/scim_groups.sql b/internal/store/queries/scim_groups.sql new file mode 100644 index 00000000..3fb1f825 --- /dev/null +++ b/internal/store/queries/scim_groups.sql @@ -0,0 +1,61 @@ +-- ABOUTME: sqlc queries for SCIM group and membership management. +-- ABOUTME: All queries are org-scoped via RLS. scim_group_members denormalize org_id. + +-- name: CreateSCIMGroup :one +INSERT INTO scim_groups (org_id, external_id, display_name) +VALUES ($1, $2, $3) RETURNING *; + +-- name: GetSCIMGroupByID :one +SELECT * FROM scim_groups WHERE id = $1 AND org_id = $2; + +-- name: GetSCIMGroupByDisplayName :one +SELECT * FROM scim_groups WHERE org_id = $1 AND display_name = $2; + +-- name: GetSCIMGroupByExternalID :one +SELECT * FROM scim_groups WHERE org_id = $1 AND external_id = $2; + +-- name: ListSCIMGroups :many +SELECT sg.*, COUNT(sgm.user_id)::int AS member_count +FROM scim_groups sg +LEFT JOIN scim_group_members sgm ON sgm.scim_group_id = sg.id +WHERE sg.org_id = $1 +GROUP BY sg.id +ORDER BY sg.display_name; + +-- name: UpdateSCIMGroup :exec +UPDATE scim_groups SET display_name = $2, external_id = $3, updated_at = now() +WHERE id = $1 AND org_id = $4; + +-- name: UpdateSCIMGroupMapping :exec +UPDATE scim_groups SET mapped_role = $2, mapped_group_id = $3, updated_at = now() +WHERE id = $1 AND org_id = $4; + +-- name: DeleteSCIMGroup :exec +DELETE FROM scim_groups WHERE id = $1 AND org_id = $2; + +-- name: AddSCIMGroupMember :exec +INSERT INTO scim_group_members (scim_group_id, user_id, org_id) +VALUES ($1, $2, $3) +ON CONFLICT (scim_group_id, user_id) DO NOTHING; + +-- name: RemoveSCIMGroupMember :exec +DELETE FROM scim_group_members WHERE scim_group_id = $1 AND user_id = $2 AND org_id = $3; + +-- name: ListSCIMGroupMembers :many +SELECT user_id FROM scim_group_members WHERE scim_group_id = $1 AND org_id = $2; + +-- name: ListUserSCIMGroups :many +SELECT sg.* FROM scim_groups sg +JOIN scim_group_members sgm ON sgm.scim_group_id = sg.id +WHERE sgm.user_id = $1 AND sg.org_id = $2; + +-- name: SetSCIMGroupMembers_Delete :exec +DELETE FROM scim_group_members WHERE scim_group_id = $1; + +-- name: CountOtherSCIMGroupsWithSameMapping :one +SELECT COUNT(*)::int FROM scim_group_members sgm +JOIN scim_groups sg ON sgm.scim_group_id = sg.id +WHERE sgm.user_id = $1 + AND sg.mapped_group_id = $2 + AND sg.id != $3 + AND sgm.org_id = $4; diff --git a/internal/store/scim_config.go b/internal/store/scim_config.go new file mode 100644 index 00000000..e409a9a5 --- /dev/null +++ b/internal/store/scim_config.go @@ -0,0 +1,132 @@ +// ABOUTME: Store methods for SCIM provisioning config CRUD. +// ABOUTME: Token hash lookup uses withBypassTx (pre-org-context auth). All other methods use withOrgTx. +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" + + generated "github.com/scarson/cvert-ops/internal/store/generated" +) + +// SCIMConfigRow is the SCIM config record returned by store methods. +type SCIMConfigRow = generated.ScimConfig + +// CreateSCIMConfig inserts a new SCIM config for an org. +// Returns error if org already has a config (UNIQUE constraint on org_id). +func (s *Store) CreateSCIMConfig(ctx context.Context, orgID, ssoConnID uuid.UUID, enabled bool, tokenHash, tokenPrefix, defaultRole string) (*SCIMConfigRow, error) { + var row SCIMConfigRow + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + var err error + row, err = q.CreateSCIMConfig(ctx, generated.CreateSCIMConfigParams{ + OrgID: orgID, + SsoConnectionID: ssoConnID, + Enabled: enabled, + TokenHash: tokenHash, + TokenPrefix: tokenPrefix, + DefaultRole: defaultRole, + }) + return err + }) + if err != nil { + return nil, fmt.Errorf("create scim config: %w", err) + } + return &row, nil +} + +// GetSCIMConfig returns the SCIM config for the given org, or (nil, nil) if none exists. +func (s *Store) GetSCIMConfig(ctx context.Context, orgID uuid.UUID) (*SCIMConfigRow, error) { + var result *SCIMConfigRow + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + row, err := q.GetSCIMConfigByOrgID(ctx, orgID) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + result = &row + return nil + }) + if err != nil { + return nil, fmt.Errorf("get scim config: %w", err) + } + return result, nil +} + +// LookupSCIMConfigByTokenHash finds a SCIM config by bearer token hash. +// Uses bypass RLS — called from SCIM auth middleware before org context is established. +// Returns (nil, nil) if not found. +func (s *Store) LookupSCIMConfigByTokenHash(ctx context.Context, tokenHash string) (*SCIMConfigRow, error) { + var result *SCIMConfigRow + err := s.withBypassTx(ctx, func(q *generated.Queries) error { + row, err := q.GetSCIMConfigByTokenHash(ctx, tokenHash) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + result = &row + return nil + }) + if err != nil { + return nil, fmt.Errorf("lookup scim config by token hash: %w", err) + } + return result, nil +} + +// LookupSCIMConfigBySSOConnectionID finds a SCIM config by SSO connection ID. +// Uses bypass RLS — for auth-path lookups. +// Returns (nil, nil) if not found. +func (s *Store) LookupSCIMConfigBySSOConnectionID(ctx context.Context, ssoConnID uuid.UUID) (*SCIMConfigRow, error) { + var result *SCIMConfigRow + err := s.withBypassTx(ctx, func(q *generated.Queries) error { + row, err := q.GetSCIMConfigBySSOConnectionID(ctx, ssoConnID) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + result = &row + return nil + }) + if err != nil { + return nil, fmt.Errorf("lookup scim config by sso connection: %w", err) + } + return result, nil +} + +// UpdateSCIMConfig updates the enabled flag and default role for an org's SCIM config. +func (s *Store) UpdateSCIMConfig(ctx context.Context, orgID uuid.UUID, enabled bool, defaultRole string) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + return q.UpdateSCIMConfig(ctx, generated.UpdateSCIMConfigParams{ + OrgID: orgID, + Enabled: enabled, + DefaultRole: defaultRole, + }) + }) +} + +// RotateSCIMToken replaces the token hash and prefix for an org's SCIM config. +func (s *Store) RotateSCIMToken(ctx context.Context, orgID uuid.UUID, tokenHash, tokenPrefix string) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + return q.UpdateSCIMConfigToken(ctx, generated.UpdateSCIMConfigTokenParams{ + OrgID: orgID, + TokenHash: tokenHash, + TokenPrefix: tokenPrefix, + }) + }) +} + +// DeleteSCIMConfig removes the org's SCIM config. +func (s *Store) DeleteSCIMConfig(ctx context.Context, orgID uuid.UUID) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + return q.DeleteSCIMConfig(ctx, orgID) + }) +} diff --git a/internal/store/scim_config_test.go b/internal/store/scim_config_test.go new file mode 100644 index 00000000..e70e039d --- /dev/null +++ b/internal/store/scim_config_test.go @@ -0,0 +1,213 @@ +// ABOUTME: Integration tests for SCIM config store methods. +// ABOUTME: Uses testutil.NewTestDB; each test runs in its own container (t.Parallel). +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/scarson/cvert-ops/internal/testutil" + "github.com/stretchr/testify/require" +) + +// scimSetup creates an org and SSO connection for SCIM config tests. +func scimSetup(t *testing.T, db *testutil.TestDB, ctx context.Context, orgName string) (orgID, ssoConnID uuid.UUID) { + t.Helper() + org := db.MustCreateOrg(t, ctx, orgName) + conn, err := db.CreateSSOConnection(ctx, org.ID, orgName+" IdP", "https://idp.example.com/"+orgName, "client-"+orgName, []byte("enc"), nil, true) + require.NoError(t, err) + return org.ID, conn.ID +} + +func TestCreateSCIMConfig(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgID, ssoConnID := scimSetup(t, db, ctx, "SCIMCreate") + + cfg, err := db.CreateSCIMConfig(ctx, orgID, ssoConnID, true, "hash123", "cvrt_", "viewer") + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, orgID, cfg.OrgID) + require.Equal(t, ssoConnID, cfg.SsoConnectionID) + require.True(t, cfg.Enabled) + require.Equal(t, "hash123", cfg.TokenHash) + require.Equal(t, "cvrt_", cfg.TokenPrefix) + require.Equal(t, "viewer", cfg.DefaultRole) + require.False(t, cfg.CreatedAt.IsZero()) +} + +func TestCreateSCIMConfig_Duplicate(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgID, ssoConnID := scimSetup(t, db, ctx, "SCIMDup") + + _, err := db.CreateSCIMConfig(ctx, orgID, ssoConnID, true, "hash1", "cvrt_", "viewer") + require.NoError(t, err) + + _, err = db.CreateSCIMConfig(ctx, orgID, ssoConnID, true, "hash2", "cvrt_", "viewer") + require.Error(t, err) +} + +func TestGetSCIMConfigByTokenHash(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgID, ssoConnID := scimSetup(t, db, ctx, "SCIMTokenHash") + + _, err := db.CreateSCIMConfig(ctx, orgID, ssoConnID, true, "known_hash", "cvrt_", "member") + require.NoError(t, err) + + cfg, err := db.LookupSCIMConfigByTokenHash(ctx, "known_hash") + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, orgID, cfg.OrgID) + require.True(t, cfg.Enabled) + + // Non-existent hash returns nil, nil. + cfg, err = db.LookupSCIMConfigByTokenHash(ctx, "nonexistent_hash") + require.NoError(t, err) + require.Nil(t, cfg) +} + +func TestGetSCIMConfigByOrgID(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgID, ssoConnID := scimSetup(t, db, ctx, "SCIMGetOrg") + + _, err := db.CreateSCIMConfig(ctx, orgID, ssoConnID, false, "hash_org", "cvrt_", "viewer") + require.NoError(t, err) + + cfg, err := db.GetSCIMConfig(ctx, orgID) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, orgID, cfg.OrgID) + require.Equal(t, "hash_org", cfg.TokenHash) + + // Random UUID returns nil, nil. + cfg, err = db.GetSCIMConfig(ctx, uuid.New()) + require.NoError(t, err) + require.Nil(t, cfg) +} + +func TestGetSCIMConfigBySSOConnectionID(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgID, ssoConnID := scimSetup(t, db, ctx, "SCIMGetSSO") + + _, err := db.CreateSCIMConfig(ctx, orgID, ssoConnID, true, "hash_sso", "cvrt_", "viewer") + require.NoError(t, err) + + cfg, err := db.LookupSCIMConfigBySSOConnectionID(ctx, ssoConnID) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, orgID, cfg.OrgID) + require.Equal(t, ssoConnID, cfg.SsoConnectionID) + + // Non-existent returns nil, nil. + cfg, err = db.LookupSCIMConfigBySSOConnectionID(ctx, uuid.New()) + require.NoError(t, err) + require.Nil(t, cfg) +} + +func TestUpdateSCIMConfig(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgID, ssoConnID := scimSetup(t, db, ctx, "SCIMUpdate") + + created, err := db.CreateSCIMConfig(ctx, orgID, ssoConnID, false, "hash_upd", "cvrt_", "viewer") + require.NoError(t, err) + + // Small delay to ensure updated_at advances. + time.Sleep(10 * time.Millisecond) + + err = db.UpdateSCIMConfig(ctx, orgID, true, "member") + require.NoError(t, err) + + cfg, err := db.GetSCIMConfig(ctx, orgID) + require.NoError(t, err) + require.NotNil(t, cfg) + require.True(t, cfg.Enabled) + require.Equal(t, "member", cfg.DefaultRole) + require.True(t, cfg.UpdatedAt.After(created.CreatedAt)) +} + +func TestUpdateSCIMConfigToken(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgID, ssoConnID := scimSetup(t, db, ctx, "SCIMRotate") + + created, err := db.CreateSCIMConfig(ctx, orgID, ssoConnID, true, "old_hash", "old_", "viewer") + require.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + + err = db.RotateSCIMToken(ctx, orgID, "new_hash", "new_") + require.NoError(t, err) + + cfg, err := db.GetSCIMConfig(ctx, orgID) + require.NoError(t, err) + require.NotNil(t, cfg) + require.Equal(t, "new_hash", cfg.TokenHash) + require.Equal(t, "new_", cfg.TokenPrefix) + require.True(t, cfg.UpdatedAt.After(created.CreatedAt)) +} + +func TestDeleteSCIMConfig(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgID, ssoConnID := scimSetup(t, db, ctx, "SCIMDelete") + + _, err := db.CreateSCIMConfig(ctx, orgID, ssoConnID, true, "hash_del", "cvrt_", "viewer") + require.NoError(t, err) + + err = db.DeleteSCIMConfig(ctx, orgID) + require.NoError(t, err) + + cfg, err := db.GetSCIMConfig(ctx, orgID) + require.NoError(t, err) + require.Nil(t, cfg) +} + +func TestSCIMConfig_RLSIsolation(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgA, ssoConnA := scimSetup(t, db, ctx, "SCIMRLSA") + orgB, ssoConnB := scimSetup(t, db, ctx, "SCIMRLSB") + + // Create configs via superuser store. + _, err := db.CreateSCIMConfig(ctx, orgA, ssoConnA, true, "hash_a", "a_", "viewer") + require.NoError(t, err) + _, err = db.CreateSCIMConfig(ctx, orgB, ssoConnB, true, "hash_b", "b_", "member") + require.NoError(t, err) + + // Query via AppStore (subject to RLS) — org A should only see its own. + cfgA, err := db.AppStore.GetSCIMConfig(ctx, orgA) + require.NoError(t, err) + require.NotNil(t, cfgA) + require.Equal(t, orgA, cfgA.OrgID) + + // Query via AppStore — org B should only see its own. + cfgB, err := db.AppStore.GetSCIMConfig(ctx, orgB) + require.NoError(t, err) + require.NotNil(t, cfgB) + require.Equal(t, orgB, cfgB.OrgID) +} diff --git a/internal/store/scim_groups.go b/internal/store/scim_groups.go new file mode 100644 index 00000000..e917a2dc --- /dev/null +++ b/internal/store/scim_groups.go @@ -0,0 +1,247 @@ +// ABOUTME: Store methods for SCIM group and membership CRUD operations. +// ABOUTME: All methods use withOrgTx for dual-layer tenant isolation (orgID param + RLS). +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" + + generated "github.com/scarson/cvert-ops/internal/store/generated" +) + +// CreateSCIMGroup inserts a new SCIM group for the given org. Returns the created row. +func (s *Store) CreateSCIMGroup(ctx context.Context, orgID uuid.UUID, externalID *string, displayName string) (*generated.ScimGroup, error) { + var extID sql.NullString + if externalID != nil { + extID = sql.NullString{String: *externalID, Valid: true} + } + + var row generated.ScimGroup + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + var err error + row, err = q.CreateSCIMGroup(ctx, generated.CreateSCIMGroupParams{ + OrgID: orgID, + ExternalID: extID, + DisplayName: displayName, + }) + return err + }) + if err != nil { + return nil, fmt.Errorf("create scim group: %w", err) + } + return &row, nil +} + +// GetSCIMGroup returns the SCIM group by ID within the given org, or (nil, nil) if not found. +func (s *Store) GetSCIMGroup(ctx context.Context, orgID uuid.UUID, id uuid.UUID) (*generated.ScimGroup, error) { + var result *generated.ScimGroup + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + row, err := q.GetSCIMGroupByID(ctx, generated.GetSCIMGroupByIDParams{ + ID: id, + OrgID: orgID, + }) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + result = &row + return nil + }) + if err != nil { + return nil, fmt.Errorf("get scim group: %w", err) + } + return result, nil +} + +// GetSCIMGroupByExternalID returns the SCIM group matching (org_id, external_id), +// or (nil, nil) if not found. +func (s *Store) GetSCIMGroupByExternalID(ctx context.Context, orgID uuid.UUID, externalID string) (*generated.ScimGroup, error) { + var result *generated.ScimGroup + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + row, err := q.GetSCIMGroupByExternalID(ctx, generated.GetSCIMGroupByExternalIDParams{ + OrgID: orgID, + ExternalID: sql.NullString{String: externalID, Valid: true}, + }) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + result = &row + return nil + }) + if err != nil { + return nil, fmt.Errorf("get scim group by external id: %w", err) + } + return result, nil +} + +// GetSCIMGroupByDisplayName returns the SCIM group matching (org_id, display_name), +// or (nil, nil) if not found. +func (s *Store) GetSCIMGroupByDisplayName(ctx context.Context, orgID uuid.UUID, displayName string) (*generated.ScimGroup, error) { + var result *generated.ScimGroup + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + row, err := q.GetSCIMGroupByDisplayName(ctx, generated.GetSCIMGroupByDisplayNameParams{ + OrgID: orgID, + DisplayName: displayName, + }) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + result = &row + return nil + }) + if err != nil { + return nil, fmt.Errorf("get scim group by display name: %w", err) + } + return result, nil +} + +// ListSCIMGroups returns all SCIM groups for the org with member counts, +// ordered by display_name. +func (s *Store) ListSCIMGroups(ctx context.Context, orgID uuid.UUID) ([]generated.ListSCIMGroupsRow, error) { + var rows []generated.ListSCIMGroupsRow + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + var err error + rows, err = q.ListSCIMGroups(ctx, orgID) + return err + }) + if err != nil { + return nil, fmt.Errorf("list scim groups: %w", err) + } + return rows, err +} + +// UpdateSCIMGroup updates the display name and external ID of a SCIM group. +func (s *Store) UpdateSCIMGroup(ctx context.Context, orgID uuid.UUID, id uuid.UUID, displayName string, externalID *string) error { + var extID sql.NullString + if externalID != nil { + extID = sql.NullString{String: *externalID, Valid: true} + } + + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + return q.UpdateSCIMGroup(ctx, generated.UpdateSCIMGroupParams{ + ID: id, + DisplayName: displayName, + ExternalID: extID, + OrgID: orgID, + }) + }) +} + +// UpdateSCIMGroupMapping updates the mapped role and notification group for a SCIM group. +func (s *Store) UpdateSCIMGroupMapping(ctx context.Context, orgID uuid.UUID, id uuid.UUID, mappedRole *string, mappedGroupID *uuid.UUID) error { + var role sql.NullString + if mappedRole != nil { + role = sql.NullString{String: *mappedRole, Valid: true} + } + var groupID uuid.NullUUID + if mappedGroupID != nil { + groupID = uuid.NullUUID{UUID: *mappedGroupID, Valid: true} + } + + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + return q.UpdateSCIMGroupMapping(ctx, generated.UpdateSCIMGroupMappingParams{ + ID: id, + MappedRole: role, + MappedGroupID: groupID, + OrgID: orgID, + }) + }) +} + +// DeleteSCIMGroup deletes a SCIM group by ID. Members are cascade-deleted. +func (s *Store) DeleteSCIMGroup(ctx context.Context, orgID uuid.UUID, id uuid.UUID) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + return q.DeleteSCIMGroup(ctx, generated.DeleteSCIMGroupParams{ + ID: id, + OrgID: orgID, + }) + }) +} + +// AddSCIMGroupMember adds a user to a SCIM group. Idempotent — duplicate adds are ignored. +func (s *Store) AddSCIMGroupMember(ctx context.Context, scimGroupID, userID, orgID uuid.UUID) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + return q.AddSCIMGroupMember(ctx, generated.AddSCIMGroupMemberParams{ + ScimGroupID: scimGroupID, + UserID: userID, + OrgID: orgID, + }) + }) +} + +// RemoveSCIMGroupMember removes a user from a SCIM group. +func (s *Store) RemoveSCIMGroupMember(ctx context.Context, orgID uuid.UUID, scimGroupID, userID uuid.UUID) error { + return s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + return q.RemoveSCIMGroupMember(ctx, generated.RemoveSCIMGroupMemberParams{ + ScimGroupID: scimGroupID, + UserID: userID, + OrgID: orgID, + }) + }) +} + +// ListSCIMGroupMembers returns all user IDs in the given SCIM group. +func (s *Store) ListSCIMGroupMembers(ctx context.Context, orgID uuid.UUID, scimGroupID uuid.UUID) ([]uuid.UUID, error) { + var result []uuid.UUID + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + var err error + result, err = q.ListSCIMGroupMembers(ctx, generated.ListSCIMGroupMembersParams{ + ScimGroupID: scimGroupID, + OrgID: orgID, + }) + return err + }) + if err != nil { + return nil, fmt.Errorf("list scim group members: %w", err) + } + return result, nil +} + +// ListUserSCIMGroups returns all SCIM groups that the given user belongs to within the org. +func (s *Store) ListUserSCIMGroups(ctx context.Context, userID, orgID uuid.UUID) ([]generated.ScimGroup, error) { + var rows []generated.ScimGroup + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + var err error + rows, err = q.ListUserSCIMGroups(ctx, generated.ListUserSCIMGroupsParams{ + UserID: userID, + OrgID: orgID, + }) + return err + }) + if err != nil { + return nil, fmt.Errorf("list user scim groups: %w", err) + } + return rows, err +} + +// CountOtherSCIMGroupsWithSameMapping counts how many other SCIM groups (excluding +// excludeGroupID) map to the same notification group and contain the given user. +func (s *Store) CountOtherSCIMGroupsWithSameMapping(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, mappedGroupID uuid.UUID, excludeGroupID uuid.UUID) (int, error) { + var count int32 + err := s.withOrgTx(ctx, orgID, func(q *generated.Queries) error { + var err error + count, err = q.CountOtherSCIMGroupsWithSameMapping(ctx, generated.CountOtherSCIMGroupsWithSameMappingParams{ + UserID: userID, + MappedGroupID: uuid.NullUUID{UUID: mappedGroupID, Valid: true}, + ID: excludeGroupID, + OrgID: orgID, + }) + return err + }) + if err != nil { + return 0, fmt.Errorf("count other scim groups with same mapping: %w", err) + } + return int(count), nil +} diff --git a/internal/store/scim_groups_test.go b/internal/store/scim_groups_test.go new file mode 100644 index 00000000..973dfd60 --- /dev/null +++ b/internal/store/scim_groups_test.go @@ -0,0 +1,302 @@ +// ABOUTME: Integration tests for SCIM group and membership store methods. +// ABOUTME: Uses testutil.NewTestDB; each test runs in its own container (t.Parallel). +package store_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/scarson/cvert-ops/internal/testutil" +) + +func TestCreateSCIMGroup(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMGroupOrg") + extID := "ext-group-1" + + group, err := db.CreateSCIMGroup(ctx, org.ID, &extID, "Engineering") + require.NoError(t, err) + require.NotNil(t, group) + require.Equal(t, org.ID, group.OrgID) + require.Equal(t, "Engineering", group.DisplayName) + require.True(t, group.ExternalID.Valid) + require.Equal(t, "ext-group-1", group.ExternalID.String) + require.False(t, group.MappedRole.Valid, "mapped_role should be null") + require.False(t, group.MappedGroupID.Valid, "mapped_group_id should be null") +} + +func TestCreateSCIMGroup_DuplicateName(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMDupNameOrg") + + _, err := db.CreateSCIMGroup(ctx, org.ID, nil, "SameName") + require.NoError(t, err) + + _, err = db.CreateSCIMGroup(ctx, org.ID, nil, "SameName") + require.Error(t, err, "expected unique constraint error for duplicate (org_id, display_name)") +} + +func TestGetSCIMGroupByExternalID(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMExtIDOrg") + extID := "ext-lookup" + + created, err := db.CreateSCIMGroup(ctx, org.ID, &extID, "LookupGroup") + require.NoError(t, err) + + // Lookup by external_id. + got, err := db.GetSCIMGroupByExternalID(ctx, org.ID, "ext-lookup") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, created.ID, got.ID) + require.Equal(t, "LookupGroup", got.DisplayName) + + // Non-existent external_id → nil, nil. + got, err = db.GetSCIMGroupByExternalID(ctx, org.ID, "no-such-id") + require.NoError(t, err) + require.Nil(t, got) +} + +func TestListSCIMGroups_WithMemberCounts(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMListOrg") + + groupA, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Alpha") + require.NoError(t, err) + groupB, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Beta") + require.NoError(t, err) + + // Create 3 users and add them to group A. + for i := 0; i < 3; i++ { + u := db.MustCreateUser(t, ctx, uuid.New().String()+"@example.com", "User", "hash", 1) + require.NoError(t, db.CreateOrgMember(ctx, org.ID, u.ID, "member")) + require.NoError(t, db.AddSCIMGroupMember(ctx, groupA.ID, u.ID, org.ID)) + } + + rows, err := db.ListSCIMGroups(ctx, org.ID) + require.NoError(t, err) + require.Len(t, rows, 2) + + // Sorted by display_name → Alpha first, Beta second. + require.Equal(t, "Alpha", rows[0].DisplayName) + require.Equal(t, int32(3), rows[0].MemberCount) + require.Equal(t, "Beta", rows[1].DisplayName) + require.Equal(t, groupB.ID, rows[1].ID) // use groupB to avoid unused variable + require.Equal(t, int32(0), rows[1].MemberCount) +} + +func TestUpdateSCIMGroupMapping(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMMappingOrg") + + group, err := db.CreateSCIMGroup(ctx, org.ID, nil, "MapGroup") + require.NoError(t, err) + + // Create a notification group to map to. + notifGroup := db.MustCreateGroup(t, ctx, org.ID, "NotifGroup", "for mapping test") + + mappedRole := "admin" + err = db.UpdateSCIMGroupMapping(ctx, org.ID, group.ID, &mappedRole, ¬ifGroup.ID) + require.NoError(t, err) + + // Re-read and verify. + got, err := db.GetSCIMGroup(ctx, org.ID, group.ID) + require.NoError(t, err) + require.NotNil(t, got) + require.True(t, got.MappedRole.Valid) + require.Equal(t, "admin", got.MappedRole.String) + require.True(t, got.MappedGroupID.Valid) + require.Equal(t, notifGroup.ID, got.MappedGroupID.UUID) +} + +func TestDeleteSCIMGroup_CascadesMembers(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMDelCascadeOrg") + + group, err := db.CreateSCIMGroup(ctx, org.ID, nil, "CascadeGroup") + require.NoError(t, err) + + user := db.MustCreateUser(t, ctx, "cascade@example.com", "Cascade", "hash", 1) + require.NoError(t, db.CreateOrgMember(ctx, org.ID, user.ID, "member")) + require.NoError(t, db.AddSCIMGroupMember(ctx, group.ID, user.ID, org.ID)) + + // Verify member exists. + members, err := db.ListSCIMGroupMembers(ctx, org.ID, group.ID) + require.NoError(t, err) + require.Len(t, members, 1) + + // Delete group. + require.NoError(t, db.DeleteSCIMGroup(ctx, org.ID, group.ID)) + + // Verify group gone. + got, err := db.GetSCIMGroup(ctx, org.ID, group.ID) + require.NoError(t, err) + require.Nil(t, got) + + // Verify members cascade-deleted (query the underlying DB directly). + var count int + err = db.DB().QueryRowContext(ctx, "SELECT COUNT(*) FROM scim_group_members WHERE scim_group_id = $1", group.ID).Scan(&count) + require.NoError(t, err) + require.Equal(t, 0, count, "scim_group_members should be cascade-deleted") +} + +func TestAddSCIMGroupMember_Idempotent(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMIdempotentOrg") + + group, err := db.CreateSCIMGroup(ctx, org.ID, nil, "IdempotentGroup") + require.NoError(t, err) + + user := db.MustCreateUser(t, ctx, "idempotent@example.com", "Idempotent", "hash", 1) + require.NoError(t, db.CreateOrgMember(ctx, org.ID, user.ID, "member")) + + // Add twice — no error. + require.NoError(t, db.AddSCIMGroupMember(ctx, group.ID, user.ID, org.ID)) + require.NoError(t, db.AddSCIMGroupMember(ctx, group.ID, user.ID, org.ID)) + + members, err := db.ListSCIMGroupMembers(ctx, org.ID, group.ID) + require.NoError(t, err) + require.Len(t, members, 1, "member count should be 1 after duplicate add") +} + +func TestRemoveSCIMGroupMember(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMRemoveOrg") + + group, err := db.CreateSCIMGroup(ctx, org.ID, nil, "RemoveGroup") + require.NoError(t, err) + + user := db.MustCreateUser(t, ctx, "remove@example.com", "Remove", "hash", 1) + require.NoError(t, db.CreateOrgMember(ctx, org.ID, user.ID, "member")) + require.NoError(t, db.AddSCIMGroupMember(ctx, group.ID, user.ID, org.ID)) + + // Remove. + require.NoError(t, db.RemoveSCIMGroupMember(ctx, org.ID, group.ID, user.ID)) + + members, err := db.ListSCIMGroupMembers(ctx, org.ID, group.ID) + require.NoError(t, err) + require.Empty(t, members) +} + +func TestListUserSCIMGroups(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMListUserOrg") + + group1, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Group1") + require.NoError(t, err) + group2, err := db.CreateSCIMGroup(ctx, org.ID, nil, "Group2") + require.NoError(t, err) + + user := db.MustCreateUser(t, ctx, "multigroup@example.com", "MultiGroup", "hash", 1) + require.NoError(t, db.CreateOrgMember(ctx, org.ID, user.ID, "member")) + + // Add user to both groups. + require.NoError(t, db.AddSCIMGroupMember(ctx, group1.ID, user.ID, org.ID)) + require.NoError(t, db.AddSCIMGroupMember(ctx, group2.ID, user.ID, org.ID)) + + groups, err := db.ListUserSCIMGroups(ctx, user.ID, org.ID) + require.NoError(t, err) + require.Len(t, groups, 2) + + // User not in any groups. + other := db.MustCreateUser(t, ctx, "nogroup@example.com", "NoGroup", "hash", 1) + require.NoError(t, db.CreateOrgMember(ctx, org.ID, other.ID, "member")) + groups, err = db.ListUserSCIMGroups(ctx, other.ID, org.ID) + require.NoError(t, err) + require.Empty(t, groups) +} + +func TestCountOtherSCIMGroupsWithSameMapping(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + org := db.MustCreateOrg(t, ctx, "SCIMCountOrg") + + // Create a notification group. + notifGroup := db.MustCreateGroup(t, ctx, org.ID, "SharedNotifGroup", "shared mapping target") + + groupA, err := db.CreateSCIMGroup(ctx, org.ID, nil, "CountGroupA") + require.NoError(t, err) + groupB, err := db.CreateSCIMGroup(ctx, org.ID, nil, "CountGroupB") + require.NoError(t, err) + + // Map both SCIM groups to the same notification group. + role := "member" + require.NoError(t, db.UpdateSCIMGroupMapping(ctx, org.ID, groupA.ID, &role, ¬ifGroup.ID)) + require.NoError(t, db.UpdateSCIMGroupMapping(ctx, org.ID, groupB.ID, &role, ¬ifGroup.ID)) + + user := db.MustCreateUser(t, ctx, "countuser@example.com", "CountUser", "hash", 1) + require.NoError(t, db.CreateOrgMember(ctx, org.ID, user.ID, "member")) + + // User in both groups. + require.NoError(t, db.AddSCIMGroupMember(ctx, groupA.ID, user.ID, org.ID)) + require.NoError(t, db.AddSCIMGroupMember(ctx, groupB.ID, user.ID, org.ID)) + + // Excluding group A → count should be 1 (group B). + count, err := db.CountOtherSCIMGroupsWithSameMapping(ctx, org.ID, user.ID, notifGroup.ID, groupA.ID) + require.NoError(t, err) + require.Equal(t, 1, count) + + // Now remove user from group B. + require.NoError(t, db.RemoveSCIMGroupMember(ctx, org.ID, groupB.ID, user.ID)) + + // Excluding group A → count should be 0. + count, err = db.CountOtherSCIMGroupsWithSameMapping(ctx, org.ID, user.ID, notifGroup.ID, groupA.ID) + require.NoError(t, err) + require.Equal(t, 0, count) +} + +func TestSCIMGroups_RLSIsolation(t *testing.T) { + t.Parallel() + db := testutil.NewTestDB(t) + ctx := context.Background() + + orgA := db.MustCreateOrg(t, ctx, "SCIMRlsOrgA") + orgB := db.MustCreateOrg(t, ctx, "SCIMRlsOrgB") + + // Create group in org A (via superuser). + _, err := db.CreateSCIMGroup(ctx, orgA.ID, nil, "OrgAGroup") + require.NoError(t, err) + + // Query via AppStore scoped to org B → should return zero results. + groups, err := db.AppStore.ListSCIMGroups(ctx, orgB.ID) + require.NoError(t, err) + require.Empty(t, groups, "RLS should isolate org A groups from org B queries") + + // Also verify org A can see its own group via AppStore. + groups, err = db.AppStore.ListSCIMGroups(ctx, orgA.ID) + require.NoError(t, err) + require.Len(t, groups, 1) + require.Equal(t, "OrgAGroup", groups[0].DisplayName) +} diff --git a/migrations/000042_org_members_deactivation.down.sql b/migrations/000042_org_members_deactivation.down.sql new file mode 100644 index 00000000..4013b227 --- /dev/null +++ b/migrations/000042_org_members_deactivation.down.sql @@ -0,0 +1,5 @@ +-- migrate:no-transaction + +DROP INDEX CONCURRENTLY IF EXISTS org_members_active_idx; +ALTER TABLE org_members DROP COLUMN IF EXISTS scim_exempt; +ALTER TABLE org_members DROP COLUMN IF EXISTS deactivated_at; diff --git a/migrations/000042_org_members_deactivation.up.sql b/migrations/000042_org_members_deactivation.up.sql new file mode 100644 index 00000000..b71af3a2 --- /dev/null +++ b/migrations/000042_org_members_deactivation.up.sql @@ -0,0 +1,9 @@ +-- migrate:no-transaction +-- ABOUTME: Adds deactivation and SCIM exemption to org_members. +-- ABOUTME: deactivated_at is a general feature (admin-settable), scim_exempt prevents SCIM from modifying state. + +ALTER TABLE org_members ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMPTZ; +ALTER TABLE org_members ADD COLUMN IF NOT EXISTS scim_exempt BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX CONCURRENTLY IF NOT EXISTS org_members_active_idx + ON org_members (org_id) WHERE deactivated_at IS NULL; diff --git a/migrations/000043_group_members_scim_managed.down.sql b/migrations/000043_group_members_scim_managed.down.sql new file mode 100644 index 00000000..28d4d1cb --- /dev/null +++ b/migrations/000043_group_members_scim_managed.down.sql @@ -0,0 +1 @@ +ALTER TABLE group_members DROP COLUMN IF EXISTS scim_managed; diff --git a/migrations/000043_group_members_scim_managed.up.sql b/migrations/000043_group_members_scim_managed.up.sql new file mode 100644 index 00000000..88c991c5 --- /dev/null +++ b/migrations/000043_group_members_scim_managed.up.sql @@ -0,0 +1,4 @@ +-- ABOUTME: Adds scim_managed flag to group_members for SCIM notification sync tracking. +-- ABOUTME: SCIM removal only deletes scim_managed=true rows. Manual memberships preserved. + +ALTER TABLE group_members ADD COLUMN IF NOT EXISTS scim_managed BOOLEAN NOT NULL DEFAULT false; diff --git a/migrations/000044_create_scim_configs.down.sql b/migrations/000044_create_scim_configs.down.sql new file mode 100644 index 00000000..4892ed95 --- /dev/null +++ b/migrations/000044_create_scim_configs.down.sql @@ -0,0 +1,6 @@ +-- migrate:no-transaction + +DROP POLICY IF EXISTS org_isolation ON scim_configs; +ALTER TABLE scim_configs DISABLE ROW LEVEL SECURITY; +DROP INDEX CONCURRENTLY IF EXISTS scim_configs_token_hash_idx; +DROP TABLE IF EXISTS scim_configs; diff --git a/migrations/000044_create_scim_configs.up.sql b/migrations/000044_create_scim_configs.up.sql new file mode 100644 index 00000000..5e58ffe7 --- /dev/null +++ b/migrations/000044_create_scim_configs.up.sql @@ -0,0 +1,30 @@ +-- migrate:no-transaction +-- ABOUTME: SCIM provisioning config table (1:1 with orgs via sso_connections). +-- ABOUTME: Bearer token stored as sha256 hash. ON DELETE RESTRICT prevents silent SSO cascade. + +CREATE TABLE IF NOT EXISTS scim_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE, + sso_connection_id UUID NOT NULL UNIQUE REFERENCES sso_connections(id) ON DELETE RESTRICT, + enabled BOOLEAN NOT NULL DEFAULT false, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + default_role TEXT NOT NULL DEFAULT 'viewer' + CHECK (default_role IN ('viewer', 'member')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS scim_configs_token_hash_idx + ON scim_configs (token_hash); + +ALTER TABLE scim_configs ENABLE ROW LEVEL SECURITY; +ALTER TABLE scim_configs FORCE ROW LEVEL SECURITY; + +CREATE POLICY org_isolation ON scim_configs + USING (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid) + WITH CHECK (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid); + +GRANT SELECT, INSERT, UPDATE, DELETE ON scim_configs TO cvert_ops_app; diff --git a/migrations/000045_create_scim_groups.down.sql b/migrations/000045_create_scim_groups.down.sql new file mode 100644 index 00000000..ace510e0 --- /dev/null +++ b/migrations/000045_create_scim_groups.down.sql @@ -0,0 +1,14 @@ +-- migrate:no-transaction + +DROP POLICY IF EXISTS org_isolation ON scim_group_members; +ALTER TABLE scim_group_members DISABLE ROW LEVEL SECURITY; +DROP INDEX CONCURRENTLY IF EXISTS scim_group_members_user_id_idx; +DROP INDEX CONCURRENTLY IF EXISTS scim_group_members_org_id_idx; +DROP TABLE IF EXISTS scim_group_members; + +DROP POLICY IF EXISTS org_isolation ON scim_groups; +ALTER TABLE scim_groups DISABLE ROW LEVEL SECURITY; +DROP INDEX CONCURRENTLY IF EXISTS scim_groups_mapped_group_id_idx; +DROP INDEX CONCURRENTLY IF EXISTS scim_groups_org_external_id_idx; +DROP INDEX CONCURRENTLY IF EXISTS scim_groups_org_id_idx; +DROP TABLE IF EXISTS scim_groups; diff --git a/migrations/000045_create_scim_groups.up.sql b/migrations/000045_create_scim_groups.up.sql new file mode 100644 index 00000000..c4c56cab --- /dev/null +++ b/migrations/000045_create_scim_groups.up.sql @@ -0,0 +1,57 @@ +-- migrate:no-transaction +-- ABOUTME: SCIM group and membership tables for IdP group sync. +-- ABOUTME: scim_groups references organizations directly (survives scim_config deletion). + +CREATE TABLE IF NOT EXISTS scim_groups ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + external_id TEXT, + display_name TEXT NOT NULL, + mapped_role TEXT CHECK (mapped_role IN ('viewer', 'member', 'admin')), + mapped_group_id UUID REFERENCES groups(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (org_id, display_name) +); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS scim_groups_org_id_idx + ON scim_groups (org_id); +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS scim_groups_org_external_id_idx + ON scim_groups (org_id, external_id) WHERE external_id IS NOT NULL; +CREATE INDEX CONCURRENTLY IF NOT EXISTS scim_groups_mapped_group_id_idx + ON scim_groups (mapped_group_id); + +ALTER TABLE scim_groups ENABLE ROW LEVEL SECURITY; +ALTER TABLE scim_groups FORCE ROW LEVEL SECURITY; + +CREATE POLICY org_isolation ON scim_groups + USING (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid) + WITH CHECK (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid); + +GRANT SELECT, INSERT, UPDATE, DELETE ON scim_groups TO cvert_ops_app; + +CREATE TABLE IF NOT EXISTS scim_group_members ( + scim_group_id UUID NOT NULL REFERENCES scim_groups(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (scim_group_id, user_id) +); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS scim_group_members_org_id_idx + ON scim_group_members (org_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS scim_group_members_user_id_idx + ON scim_group_members (user_id); + +ALTER TABLE scim_group_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE scim_group_members FORCE ROW LEVEL SECURITY; + +CREATE POLICY org_isolation ON scim_group_members + USING (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid) + WITH CHECK (current_setting('app.bypass_rls', TRUE) = 'on' + OR org_id = current_setting('app.org_id', TRUE)::uuid); + +GRANT SELECT, INSERT, DELETE ON scim_group_members TO cvert_ops_app;