Skip to content

[CXH-2281] fix: SCIM account-lifecycle defects and test-server fidelity - #59

Open
manuel-ts-14 wants to merge 2 commits into
mainfrom
manueltraversarosasia/cxh-2281-baton-lucidchart-four-scim-account-lifecycle-defects
Open

[CXH-2281] fix: SCIM account-lifecycle defects and test-server fidelity#59
manuel-ts-14 wants to merge 2 commits into
mainfrom
manueltraversarosasia/cxh-2281-baton-lucidchart-four-scim-account-lifecycle-defects

Conversation

@manuel-ts-14

Copy link
Copy Markdown

Fixes four defects on the SCIM account-lifecycle paths added by CXH-1488, and rewrites the bundled test-server to replicate Lucid's published contract.

Linear: CXH-2281

Why the test-server changed first

The previous mock was written alongside the connector and encoded the connector's own assumptions — it returned 404 where Lucid documents 403, and answered SCIM PATCH with a hardcoded active: true without persisting anything. Two of the four defects below are invisible against that mock and against the unit tests, both of which hand-wrote the wrong status code. Rebuilding the mock from Lucid's published OpenAPI is what surfaced them.

Defects fixed

Delete aborted on Lucid's documented 403. 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. Delete now disambiguates via SCIM, which 404s specifically for absence, and refuses only when the user still exists and their content could not be transferred — deleting in that case would destroy documents the operator asked to retain.

SCIM delete 409 surfaced as AlreadyExists. Lucid returns 409 for a user that can never be deleted, such as an account owner (docs). uhttp maps that to codes.AlreadyExists — the code the SDK uses for idempotent no-ops — so a refused delete read as success. Now FailedPrecondition, carrying Lucid's reason.

User status was pinned to STATUS_ENABLED. A SCIM deactivation never appeared in a sync. client.User now decodes Lucid's enabled field and the status derives from it; roles are emitted in the profile so update_user is observable too.

client.User decoded usernames. Lucid emits username (singular), so the field was empty on every user. Corrected.

Verification

A/B against the same mock, connector built from origin/main vs this branch:

Check before after
Status of a disabled user RESOURCE_STATUS_ENABLED RESOURCE_STATUS_DISABLED
username in profile absent populated
Delete retry after 403 exit 1, PermissionDenied exit 0
Delete a protected user (409) AlreadyExists FailedPrecondition

Mock-side trace for the retry case, before and after:

before:  GET /v1/users/105 -> 403          (nothing follows; delete abandoned)
after:   GET /v1/users/105 -> 403
         GET /scim/v2/Users/lucid-105 -> 404   (probe disambiguates)
         DELETE /scim/v2/Users/lucid-105 -> 404 (proceeds, treated as success)

Seven regression tests added in pkg/connector/users_test.go covering both 403 branches, the 409 path, the happy path, and the status/username mapping. go build, go vet, golangci-lint run and go test ./pkg/... are clean.

Test-server changes

Rebuilt from Lucid's published OpenAPI, with the doc URL on every handler. Models the documented 403; persists SCIM PATCH state and echoes it back; returns 404/409 on delete; emits Lucid's {code, message, requestId} error envelope; paginates via the Link header at Lucid's 200-record page size; requires distinct REST and SCIM bearers; and hardens the OAuth token endpoint against bad grant_type and missing credentials. Seed data now includes a disabled user, a user with no roles, and a unicode display name.

Scenario flags reproduce each case: -legacy-user-404, -protected-users, -users N, -transfer-rate-limit, -strict-scim-doc.

-strict-scim-doc enforces Lucid's documented Content-Type and schemas URN, which the connector does not currently send. That divergence is deliberately not changed here — it needs one call against an Enterprise tenant to settle, and is tracked in CXH-2282.

Not in scope

CXH-2282 SCIM wire contract · CXH-2283 ungated capabilities · CXH-2284 remaining SCIM surface gaps · CXH-2285 folder/document grant idempotency

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Aug 18, 2026

Copy link
Copy Markdown

CXH-2281

Comment thread pkg/connector/users.go Outdated
Comment on lines +211 to +214
status := v2.UserTrait_Status_STATUS_DISABLED
if user.Enabled {
status = v2.UserTrait_Status_STATUS_ENABLED
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Bug: client.User.Enabled is a non-pointer bool, so "field absent from the payload" and "user is disabled" are indistinguishable — and the default chosen here is the destructive one. The enabled/username evidence in the PR body cites reference/getuser (GET /v1/users/{id}), but every user in a sync comes from ListUserGET /users, a different endpoint whose response shape is only verified against the mock this PR also wrote. If that list payload omits enabled, every user syncs as RESOURCE_STATUS_DISABLED, which reads downstream as a mass deactivation.

Suggest failing safe: make it Enabled *bool in models.go and treat nil as ENABLED (only an explicit false disables), plus a test asserting a payload with no enabled key maps to ENABLED. (Confidence: medium — the code-level ambiguity is certain; whether GET /users returns enabled is unverified.)

Comment on lines +29 to +34
// IsConflictError reports whether err represents an upstream 409. SCIM delete
// uses it for a user that can never be deleted (account owner, default document
// owner) — terminal, not an idempotent "already done".
func IsConflictError(err error) bool {
return status.Code(err) == codes.AlreadyExists
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: IsConflictError is byte-identical to IsAlreadyExistsError above (both status.Code(err) == codes.AlreadyExists), which is the correct mapping for a 409 — but two exported predicates with the same body and different doc comments will drift the moment someone edits one. IsAlreadyExistsError currently has no callers, so consider deleting it (or renaming it to IsConflictError) rather than keeping both.

Comment thread cmd/test-server/main.go
Comment on lines +494 to +510
size := cfg.pageSize
if requested := r.URL.Query().Get("pageSize"); requested != "" {
if n, err := strconv.Atoi(requested); err == nil && n > 0 && n < size {
size = n
}
}

end := offset + size
if end > len(all) {
end = len(all)
}
page := all[offset:end]

if end < len(all) {
next := fmt.Sprintf("%s://%s/users?pageSize=%d&pageToken=%d", schemeOf(r), r.Host, size, end)
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"next\"", next))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: -page-size 0 (or a negative value) is accepted by the flag and never clamped, so size <= 0 gives end == offset — an empty page that still emits Link: <...pageToken=offset>; rel="next" with the same offset. A connector under test then paginates forever on identical tokens. The query-param path already guards with n > 0; add the same clamp to cfg.pageSize (e.g. if size <= 0 { size = lucidPageSize }) so the mock can't manufacture an infinite pagination loop.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: [CXH-2281] fix: SCIM account-lifecycle defects and test-server fidelity

Blocking Issues: 0 | Suggestions: 6 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 90161ec042d9.
Review mode: full
View review run

Review Summary

Scanned the full PR diff (6 files, +1038/-137) for security and correctness: the Delete / transferContentBeforeDelete rewrite, the new ScimUserExists probe, the client.User field changes, and the rebuilt test-server. The prior finding about absent enabled defaulting to DISABLED is addressed — Enabled is now *bool and an absent field maps to RESOURCE_STATUS_UNSPECIFIED rather than to a wrong verdict — and the OAuth token endpoint now bounds its form body via maxOAuthFormBytes. Two earlier findings are still open in the head tree: IsConflictError remains a verbatim duplicate of IsAlreadyExistsError (helpers.go:29-33), and the -page-size flag is still used unclamped (main.go:485-508). No new blocking issues; the six suggestions below are unverified live-API assumptions and documentation drift rather than defects in the logic as written.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/client/scim.go:117-122ScimUserExists treats a SCIM 404 as definitively "gone", but this PR's own mock quotes Lucid documenting that deactivated users are excluded from the SCIM /Users payload. If the single-resource GET filters the same way, a deactivated user whose REST record 403s is hard-deleted with no content transfer. Confidence: medium.
  • pkg/connector/users.go:193-200enabled is cited from the single-user endpoint, but the status of every synced resource comes from GET /users. If the list payload omits the field, all users sync UNSPECIFIED instead of the previous ENABLED; the A/B in the PR body was run against this repo's own mock, which was written to emit it. Confidence: medium.
  • pkg/connector/users.go:303-307 — the refusal message tells operators to check for account.user:readonly, a scope name that appears nowhere else in the repo; docs/connector.mdx:92 documents the provisioning scope as account.user.
  • docs/connector.mdx:98 — still documents the behaviour this PR replaces: "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". Delete now probes SCIM on a 403 and refuses when the user still exists, and a 409 now surfaces as FailedPrecondition; neither is documented (connector mixin D3/D4).
  • cmd/test-server/main.go:408-415POST /_test/users?count=N validates only n < 0, so a large count OOMs the mock; same class as the maxOAuthFormBytes bound just added.
  • pkg/connector/client/models.go:13-15 — if Lucid emits username (singular), Usernames is now permanently the zero value, yet users.go:213 still writes an empty usernames entry into every user profile.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/client/scim.go`:
- Around lines 104-125: ScimUserExists maps a SCIM 404 to "user is gone", and
  transferContentBeforeDelete relies on that to skip the content transfer and
  proceed with a hard delete. cmd/test-server/main.go:719-723 quotes Lucid
  documenting that deactivated users are excluded from the SCIM /Users
  collection. Confirm against a live Enterprise tenant whether
  GET /scim/v2/Users/<id> also 404s for a user who exists but is deactivated.
  If it does, this path silently destroys content for the common offboard order
  (deactivate, then delete) whenever the REST GET 403s on a scope problem, and
  ScimUserExists should not be the sole absence oracle. Either way, record the
  verified answer in the doc comment.

In `pkg/connector/users.go`:
- Around lines 192-200: the enabled field is documented for GET /v1/users/<id>,
  but userResource is fed primarily by ListUser (GET /users) and by the
  POST /users create response. Confirm both payloads carry enabled. If the list
  response omits it, every user syncs RESOURCE_STATUS_UNSPECIFIED, which is a
  whole-account regression from the previous unconditional STATUS_ENABLED; in
  that case fall back to ENABLED when the field is absent, or take the status
  from a source that has it.
- Around lines 303-307: the FailedPrecondition message instructs the operator to
  check that the OAuth token carries account.user:readonly. That scope name
  appears nowhere else in the repo; docs/connector.mdx:92 documents the
  provisioning scope as account.user and line 96 as
  account.user.transfercontent. Determine which name Lucid actually publishes
  and make this message and the docs agree.

In `docs/connector.mdx`:
- Around line 98: this note describes the pre-PR behaviour ("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"). Rewrite it for the new
  flow: GET /v1/users/<id> answers 403 rather than 404 for an absent user, the
  connector disambiguates with a SCIM GET, it refuses to delete (returning
  FailedPrecondition) when the user still exists but their email cannot be read,
  and a SCIM 409 for a protected user (account owner or default document owner)
  now surfaces as FailedPrecondition instead of reading as an idempotent
  success. Use the same scope name as the error message in users.go:306.

In `cmd/test-server/main.go`:
- Around lines 408-415: the count query parameter is validated only for n < 0,
  so POST /_test/users?count=100000000 makes seedUsers allocate 100M user
  structs and OOM the process. The endpoint is unauthenticated and the server
  listens on :8080 across all interfaces by default. Add an upper bound (for
  example reject n greater than 100000) alongside the existing non-negative
  check, matching the maxOAuthFormBytes bound added for the OAuth handler.

In `pkg/connector/client/models.go`:
- Around lines 13-15: the Usernames field, tagged with the plural usernames JSON
  key, is retained next to the newly added Username. If Lucid only ever emits
  the singular form, this field is permanently the zero value, and
  pkg/connector/users.go:213 still writes it into the resource profile as an
  empty usernames entry. Remove the field and the profile entry, or keep it only
  with a comment naming the API version or tenant shape that still sends the
  plural form.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see review comments.

Comment thread cmd/test-server/main.go
manuel-ts-14 and others added 2 commits August 26, 2026 11:56
Four defects surfaced during CXH-1488 validation, each reproducible against a
test-server rebuilt from Lucid's published OpenAPI.

Delete aborted on Lucid's documented 403. GET /v1/users/{id} answers 403 --
never 404 -- for a user that does not exist, so the not-found guard failed and
the SCIM delete was never attempted, leaving platform retries unable to
converge. Delete now disambiguates via SCIM, which 404s specifically for
absence, and refuses only when the user still exists and their content could
not be transferred.

SCIM delete 409 surfaced as AlreadyExists, the code the SDK uses for idempotent
no-ops, so a refused delete of an account owner read as success. It is now
FailedPrecondition carrying Lucid's reason.

User status was pinned to STATUS_ENABLED, so a SCIM deactivation never appeared
in a sync. client.User now decodes Lucid's `enabled` field and the status
derives from it; roles are emitted in the profile so update_user is observable.

client.User decoded `usernames`, a key Lucid does not emit, so the field was
empty on every user. Corrected to `username`.

The test-server is rewritten from Lucid's published contract rather than the
connector's assumptions -- the previous version encoded the connector's own
behaviour and could not have caught the first two defects. It models the
documented 403, persists SCIM PATCH state, returns 404/409 on delete, emits
Lucid's error envelope, paginates via the Link header, and requires distinct
REST and SCIM bearers. Scenario flags reproduce each case; -strict-scim-doc
enforces Lucid's documented Content-Type and schemas URN, which the connector
does not currently send (tracked in CXH-2282).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
golangci-lint (gosec G120) flagged the test-server's unbounded
r.ParseForm() on /oauth2/token; wrap the body with http.MaxBytesReader.

client.User.Enabled was a non-pointer bool, so a GET /users payload
that omits `enabled` was indistinguishable from an explicit `false`
and got reported as disabled. Enabled is now *bool: nil maps to
RESOURCE_STATUS_UNSPECIFIED instead of assuming a status, and the
legacy `usernames` field is kept alongside `username` for existing
consumers of the profile map.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@luisina-santos
luisina-santos force-pushed the manueltraversarosasia/cxh-2281-baton-lucidchart-four-scim-account-lifecycle-defects branch from 5266f08 to edc65bb Compare August 26, 2026 14:57
Comment thread pkg/connector/users.go
Comment on lines +303 to +307
return status.Errorf(codes.FailedPrecondition,
"baton-lucidchart: user %s exists but their email could not be read for content transfer (%v); "+
"refusing to delete and lose their documents — check that the OAuth token carries "+
"account.user:readonly and that the user is on this account",
userID, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this tells the operator to check for account.user:readonly, a scope name that appears nowhere else in the repo — docs/connector.mdx:92 documents the provisioning scope as account.user and line 96 as account.user.transfercontent. An operator following this message will look for a scope that may not exist. Confidence: high (the string mismatch is verifiable in-repo); pick whichever name Lucid actually publishes and make the docs and this message agree.

Comment on lines +117 to +122
if _, err := c.doRequest(ctx, req, nil); err != nil {
if IsNotFoundError(err) {
return false, nil
}
return false, err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: treating SCIM 404 as "definitively gone" is load-bearing for the delete path, but cmd/test-server/main.go:719-723 quotes Lucid as documenting that "Deactivated users will not be included in the totalResults or the JSON payload of users returned" for the SCIM /Users collection. If the single-resource GET /Users/{id} filters the same way, then for a user who was deactivated first (the normal offboard order) and whose REST record 403s on a scope problem, transferContentBeforeDelete returns nil → ScimDeleteUser hard-deletes without transferring content, which is the exact data loss the 403 guard exists to prevent. Confidence: medium — the collection-level exclusion is documented, the single-GET behaviour is not. Worth confirming against a live Enterprise tenant (alongside CXH-2282) and noting the answer here.

Comment thread pkg/connector/users.go
Comment on lines +193 to 200
status := v2.Status_RESOURCE_STATUS_UNSPECIFIED
if user.Enabled != nil {
if *user.Enabled {
status = v2.Status_RESOURCE_STATUS_ENABLED
} else {
status = v2.Status_RESOURCE_STATUS_DISABLED
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: enabled is cited from reference/getuser (the single-user endpoint), but the field that drives every synced resource comes from ListUserGET /users, and the A/B in the PR body was run against this repo's own mock, which was written to emit it. If Lucid's list response omits enabled, every user syncs RESOURCE_STATUS_UNSPECIFIED — a regression from the previous unconditional ENABLED, across the whole account rather than the one-user case. Same applies to the POST /users create response feeding CreateAccount. Confidence: medium. Worth confirming the list payload carries enabled before this ships.

Comment thread cmd/test-server/main.go
Comment on lines +408 to +415
mux.HandleFunc("POST /_test/users", func(w http.ResponseWriter, r *http.Request) {
n, err := strconv.Atoi(r.URL.Query().Get("count"))
if err != nil || n < 0 {
http.Error(w, "count must be a non-negative integer", http.StatusBadRequest)
return
}
s.seedUsers(n)
log.Printf("[_test] reseeded with %d users", n)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: count is only checked for n < 0, so POST /_test/users?count=100000000 drives seedUsers into allocating 100M structs and OOMs the process. The endpoint is unauthenticated and the server defaults to :8080 on all interfaces. Same class as the maxOAuthFormBytes bound added in the last commit — add an upper bound (e.g. reject n > 100_000) so a typo in a test script can't kill the mock mid-run.

Comment on lines 13 to 15
Email string `json:"email"`
Name string `json:"name"`
UserId int `json:"userId"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: if Lucid emits username (singular), then Usernames is now permanently empty — and users.go:213 still writes it into the profile, so every synced user carries a usernames: "" key alongside the real username. Dropping the field and the profile entry avoids shipping a value that is guaranteed to be wrong; keep it only if some tenant/API version is known to send the plural form.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is a potential breaking change

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants