[CXH-2281] - Fix SCIM account-lifecycle defects and rebuild test-server for doc fidelity - #60
[CXH-2281] - Fix SCIM account-lifecycle defects and rebuild test-server for doc fidelity#60sergiocorral-conductorone wants to merge 12 commits into
Conversation
… for doc fidelity
Fixes four defects on the SCIM account-lifecycle paths added by CXH-1488, and
rebuilds the bundled test-server from Lucid's published OpenAPI so the mock
reproduces the real contract instead of the connector's assumptions.
Findings fixed:
1. Delete aborted on Lucid's documented 403. GET /v1/users/{id} answers 403 (never
404) for an absent user, so the not-found guard failed and the SCIM delete was
never attempted; platform retries could not converge. Delete now disambiguates
via a SCIM GET (which 404s specifically for absence) and refuses only when the
user still exists and their content could not be transferred.
2. SCIM delete 409 surfaced as AlreadyExists (the idempotent-success code). Now
mapped to FailedPrecondition, carrying Lucid's reason.
3. User status was pinned to STATUS_ENABLED. client.User now decodes `enabled`
and the status derives from it; roles are emitted in the profile so an
update_user role change is observable.
4. client.User read `usernames` (plural); Lucid emits `username` (singular), so
the field was empty on every user. Corrected.
Deltas vs reference PR #59 (its CI review left 1 blocking issue + 3 suggestions):
- BLOCKING: Enabled is a *bool (not bool) and userResource defaults to ENABLED,
flipping to DISABLED only on an explicit enabled=false. A non-pointer bool could
not tell an absent field from false, and PR #59 defaulted to DISABLED — a
GET /users payload missing `enabled` would have synced every user as deactivated.
- Collapsed the duplicate IsConflictError/IsAlreadyExistsError into one predicate.
- Clamped test-server -page-size <= 0 to Lucid's 200 (unclamped it paginated forever).
- Fixed the stale ci.yaml bearer comment and wired -scim-token through the
start-test-server action so REST/SCIM tokens cannot silently diverge.
Validated against the rebuilt mock: full sync exits 0, disabled user 105 reads
back RESOURCE_STATUS_DISABLED, mock returns documented 403/404/409 + dual-token
auth, page-size clamp holds. go build, go vet, gofmt and go test ./... are clean.
Fixes CXH-2281
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connector PR Review: [CXH-2281] - Fix SCIM account-lifecycle defects and rebuild test-server for doc fidelityBlocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0 Review SummaryThe full PR diff was re-scanned for security and correctness (connector, client, test-server, CI workflow, docs; no Security IssuesNone found. Correctness IssuesNone found. SuggestionsNone. |
…rd ParseForm - users.go: revert rebase regression from deprecated WithUserProfile/WithStatus (UserTraitOption) back to WithResourceProfile/WithResourceStatus (ResourceOption), clearing staticcheck SA1019. Restores the userTraitStatusToResourceStatus helper main uses to map the trait status enum to the resource status enum. - test-server: wrap the OAuth token handler body with http.MaxBytesReader (1 MiB) before ParseForm to satisfy gosec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ID collision, trait status, docs - users.go: 409 delete branch now appends Lucid's actual err via (%v) so FailedPrecondition carries the real reason, matching the 403 sibling. - users.go: split transferContentBeforeDelete's REST-404 from REST-403. A definite 404 falls through to the SCIM delete without probing SCIM, so a probe outage (403/405/501/5xx) can no longer abort a valid delete; the SCIM probe stays strict only for the ambiguous 403 case. - users.go: sync the (deprecated) user trait status via WithStatus so a disabled user no longer reports Resource.Status=DISABLED alongside UserTrait.Status=ENABLED; users_test asserts the trait status too. - test-server: seedUsers advances nextID past the seeded range so POST /users cannot mint a colliding userId after -users N. - test-server: reword the startup banner and three doc comments to describe the mock's behaviour without narrating the now-fixed connector defect. - Remove ticket-brief.md (internal process artifact, no repo convention). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…403 delete path - test-server: cap POST /_test/users?count= at maxSeedUsers (100k) so a mistyped count returns 400 instead of OOMing the mock in makeslice. - docs/connector.mdx: document the 403 → SCIM-probe behaviour, the FailedPrecondition refusal when a user exists but their email is unreadable, and the 409 (account/document owner) refusal — the note previously covered only the REST-404 skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The double-failure branch in transferContentBeforeDelete (REST 403 then a failing SCIM existence probe) was the only place wrapping two errors with %w in one fmt.Errorf, so its gRPC status code depended on errors.As DFS order rather than a deliberate choice. Return codes.Unknown explicitly to signal the genuinely-indeterminate state, keeping both underlying error details in the message text via %v. Add TestDelete_RestForbiddenAndScimProbeFails_AbortsWithoutDeleting covering the previously-untested path: delete aborts with codes.Unknown and SCIM DELETE is never reached. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lucid's GET /v1/users/{id} documents only 200 and 403 (403 is its
"does not exist" response); 404 is undocumented and of unknown meaning.
The prior code short-circuited a 404 straight to the hard SCIM delete,
so if a 404 ever occurred for a user who still exists, offboarding would
destroy content the operator asked to retain.
Now the 404 path probes SCIM (like the 403 path) and refuses only when
SCIM affirmatively reports the user still present. Unlike the 403 path,
a SCIM probe error is treated as "proceed" so a probe outage still cannot
block an otherwise-valid delete, preserving the original motive.
Add tests for 404+gone (proceed), 404+exists (refuse), and
404+probe-error (proceed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Unknown On the ambiguous REST 403 path, transferContentBeforeDelete previously collapsed every SCIM existence-probe failure into codes.Unknown. That hid two distinguishable cases from the platform: - A probe cancelled/timed-out mid-flight lost its context error, so errors.Is(err, context.Canceled/DeadlineExceeded) returned false downstream. - A transient probe failure (429/5xx, which the SDK maps to Unavailable/ResourceExhausted) looked non-retryable, discouraging the platform from re-attempting a deprovision that would likely succeed. Classify in priority order — cancellation (preserved via %w), then retryable code (preserved), then a deliberate codes.Unknown fallback for genuinely indeterminate failures. Update the 500-probe test (500 now maps to the retryable Unavailable) and add coverage for the retryable, indeterminate, and cancellation branches. Also refresh docs/connector.mdx to describe one disambiguation rule covering both the 403 and undocumented-404 ambiguous responses: probe SCIM, and only a failed probe falls through to the delete. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t error errorlint (verify / lint) flagged the cancellation branch's fmt.Errorf for formatting the REST error with %v alongside the %w context error. Pass err.Error() as a string so only the context error is wrapped — this keeps errors.Is(context.Canceled/DeadlineExceeded) matching without pulling the REST PermissionDenied status into the error chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ourceExhausted The 403 SCIM-probe error switch checked codes.ResourceExhausted, which GrpcCodeFromHTTPStatus never produces for any HTTP status, and omitted codes.DeadlineExceeded, the code a real 408 probe timeout produces. A 408 therefore fell through to the non-retryable codes.Unknown branch — backwards from intent. - Add codes.DeadlineExceeded to the retryable check; remove the unreachable codes.ResourceExhausted (the only producer in the vendored SDK is the gRPC ratelimit interceptor, not the uhttp client path ScimUserExists uses — no speculative defensive branch). - Correct the function comment and docs/connector.mdx: 429/5xx surface as Unavailable, 408 as DeadlineExceeded (not ResourceExhausted). - Extend the transient-probe test with a 408 -> DeadlineExceeded case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…able Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
501 maps to codes.Unimplemented (not Unavailable), so the probe correctly does not retry it. Reword the comment so it no longer overclaims that all 5xx map to Unavailable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| userTraitOptions := []rs.UserTraitOption{ | ||
| rs.WithEmail(user.Email, true), | ||
| rs.WithUserLogin(user.Email), | ||
| // Keep the (deprecated) trait status in sync with the resource status. |
There was a problem hiding this comment.
Is it worth creating a ticket about this? @sergiocorral-conductorone @luisina-santos
There was a problem hiding this comment.
for the engine team I mean
| // ScimUserExists reports whether the user still exists, via SCIM GET /Users/{id}. | ||
| // SCIM 404s specifically for absence, which disambiguates REST's overloaded 403. | ||
| // A non-nil error means "unknown" — never treat it as "gone". | ||
| func (c *LucidchartClient) ScimUserExists(ctx context.Context, userID string) (bool, error) { |
There was a problem hiding this comment.
FYI: worst-case Delete path is now 3 calls instead of 1-2
GetUser -> ScimUserExists -> ScimDeleteUser on the ambiguous 403/404 paths. Not a real scale concern since Delete runs per-account not in a sync loop, just flagging for awareness.
There was a problem hiding this comment.
Thanks — agreed, no change needed. The worst-case 3-call Delete path is fine since Delete runs on-demand for deprovisioning and is not in a sync loop, so the extra probe calls do not compound.
| If you also want C1 to transfer a deleted user's documents to another user before removing the account, add the `account.user.transfercontent` scope. Without it, Delete calls to `POST /v1/transferUserContent` will return 403. | ||
|
|
||
| Note: if the user's REST record is already gone when deletion runs (GetUser returns 404), the content transfer is skipped and the SCIM delete proceeds — this is intentional idempotent retry behavior. | ||
| Note: when the user's REST record can't be read at deletion time, C1 applies one disambiguation rule for both ambiguous responses. Lucid's `GET /v1/users/{id}` returns 403 for both "not permitted" and "does not exist", and can also return an undocumented 404; neither on its own proves the user is gone. So on either a 403 or a 404, C1 probes SCIM (which returns 404 specifically for absence) before deleting: if SCIM affirmatively reports the user is still present, C1 refuses the delete with a `FailedPrecondition` error rather than hard-deleting content it could not transfer — grant the `account.user:readonly` scope so the email can be read. Only a *failed* probe falls through to the delete, and even then the two paths differ: on a 404 a probe outage proceeds to the SCIM delete (idempotent retry, so an outage can't block an otherwise-valid delete), whereas on a 403 an unresolved probe blocks the delete and surfaces its cause — a cancelled sync keeps its context error, a transient probe failure returns a retryable code so the platform retries (429/5xx surface as `Unavailable`, a 408 timeout as `DeadlineExceeded`), and a genuinely indeterminate failure returns `Unknown`. A raw SCIM 409 (the user is an account owner or default document owner and cannot be deleted) also surfaces as `FailedPrecondition` carrying Lucid's reason. |
There was a problem hiding this comment.
FYI: README doesn't mention any of this
This paragraph (and the SCIM token / content-transfer flag in general) has no equivalent in README.md. Looks like it predates this PR though, not something to fix here — just a heads up for whenever someone does a README pass.
There was a problem hiding this comment.
Thanks — acknowledged, leaving out of scope here. The README/docs gap predates this PR and is not something this change introduced.
There was a problem hiding this comment.
LGTM. The four SCIM lifecycle fixes look right — the *bool enabled fail-safe, the 403/404 SCIM-probe disambiguation, and the 409 → FailedPrecondition mapping. Confirmed the 409-predicate collapse is safe and the SCIM id prefix is applied on both delete and existence checks. Feli's comments are non-blocking nits.
Address PR #60 review nits: - Add IsRetryableError(err) to client/helpers.go (Unavailable + DeadlineExceeded, matching the SDK retry gate) and use it instead of inlining the status.Code() checks in the 403 probe path. - Pull the deeply-nested probe-failure classifier switch out of transferContentBeforeDelete into a small classifyProbeFailure helper. - Collapse the identical err==nil / IsNotFoundError cases in Delete into a single multi-value case. Behavior-preserving; go build/vet/test/gofmt all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes CXH-2281.
Linear: CXH-2281
Fixes four defects on the SCIM account-lifecycle paths added by CXH-1488, and rebuilds the bundled test-server from Lucid's published OpenAPI so the mock reproduces the real contract instead of the connector's own assumptions. The test-server is the repro harness that the sibling tickets CXH-2282–2285 depend on, so getting it right is the point of this ticket.
Prior art
This builds directly on QA's reference PR #59 (
[CXH-2281] fix: SCIM account-lifecycle defects and test-server fidelity), which diagnosed all four defects and wrote the first test-server rewrite. I treated #59 as a verified starting hypothesis rather than porting it as-is: its CI review left 1 blocking issue and 3 suggestions open, and it carried unrelated dependency/vendor churn from an older base. This branch keeps #59's sound approach and closes the review.Defects fixed
GET /v1/users/{id}answers 403 — never 404 — for a user that does not exist (docs), so the not-found guard failed and the SCIM delete was never attempted; platform retries of an already-processed deprovision could not converge.Deletenow disambiguates via a SCIMGET /Users/{id}(which 404s specifically for absence) and refuses only when the user still exists and their content could not be transferred — deleting then would destroy documents the operator asked to retain.AlreadyExists. Lucid returns 409 for a user that can never be deleted, e.g. an account owner (docs).uhttpmaps that tocodes.AlreadyExists— the code the SDK uses for idempotent no-ops — so a refused delete read as success. NowFailedPrecondition, carrying Lucid's reason.STATUS_ENABLED.client.Usernow decodes Lucid'senabledfield and the status derives from it; roles are emitted in the profile so anupdate_userrole change is observable too.client.Userreadusernames(plural). Lucid emitsusername(singular), so the field was empty on every user. Corrected.What changed vs PR #59 (and why)
Enabledis*bool, anduserResourcefails safe toENABLED. [CXH-2281] fix: SCIM account-lifecycle defects and test-server fidelity #59 used a non-pointerbooland defaulted the status toDISABLED, so anyGET /userspayload missingenabledwould have synced every user as deactivated — a mass-deactivation footgun the CI reviewer flagged. A pointer distinguishes "absent" from "explicit false"; status flips toDISABLEDonly onenabled == false. Confirmed against Lucid's docs thatGET /users(listusers) returns the same User schema as getuser —enabled+usernameare both present on the sync path — so the pointer is defence-in-depth at no cost.IsConflictError/IsAlreadyExistsErrorinto a single 409 predicate so they cannot drift.-page-size <= 0to Lucid's 200. Unclamped it returned an empty page while still emitting aLinknext-token at the same offset — infinite pagination for the connector under test.ci.yaml"any non-empty bearer" comment and wired-scim-token(fromBATON_LUCID_SCIM_TOKEN) through thestart-test-servercomposite action, so the mock's REST/SCIM tokens cannot silently diverge.go.mod/go.sum/vendor/**/.versions.yamlbumps (it was cut from79714b3; this branch is based on the newermain3420066, which already carries those) and its deletion oftickets/CXH-1488/*.md. Also rebased [CXH-2281] fix: SCIM account-lifecycle defects and test-server fidelity #59'suserResourceedits ontomain's newerWithUserProfile/WithStatusform instead of reintroducing the removeduserTraitStatusToResourceStatushelper.Verification
Local, against the rebuilt mock:
main)RESOURCE_STATUS_ENABLEDRESOURCE_STATUS_DISABLEDusernamein profilePermissionDeniedAlreadyExistsFailedPreconditionLink-header pagination); reading the synced c1z back shows user 105RESOURCE_STATUS_DISABLED, all othersENABLED.curlagainst the mock confirms the documented contract: RESTGET /v1/users/999→ 403, SCIMGET /Users/lucid-999→ 404, SCIMDELETE lucid-101(protected) → 409,DELETE lucid-105→ 204, SCIM token rejected on REST routes → 401,-page-size 0clamped to a single 200-record page.pkg/connector/users_test.go: the seven delete/status/username regression tests (both 403 branches, the 409 path, happy path, no-transfer-email) plus a table-drivenenabledmapping test asserting absent →ENABLED, false →DISABLED, true →ENABLED.go build ./...,go vet ./...,gofmt -l, andgo test ./...are all clean.Not in scope (tracked separately, per #59)
CXH-2282 SCIM wire contract (Content-Type / PatchOp schemas URN — needs a live Enterprise tenant; modelled in the mock behind
-strict-scim-doc, off by default) · CXH-2283 ungated capabilities · CXH-2284 remaining SCIM surface gaps · CXH-2285 folder/document grant idempotency.🤖 Generated with Claude Code