Skip to content

[CXH-2208] fix: derive CLM hrefs for Grant/Revoke instead of requiring a profile field - #63

Open
FeliLucero1 wants to merge 35 commits into
mainfrom
clm-folder-provisioning-external-id
Open

[CXH-2208] fix: derive CLM hrefs for Grant/Revoke instead of requiring a profile field#63
FeliLucero1 wants to merge 35 commits into
mainfrom
clm-folder-provisioning-external-id

Conversation

@FeliLucero1

@FeliLucero1 FeliLucero1 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Description

  • Bug fix
  • New feature

Grant/Revoke for CLM folders/groups/members previously required a href
profile field to be present on the principal/entitlement resource — which
pebble's identity-only V3EntitlementToV2 hydration doesn't carry, so
provisioning silently failed for any resource synced that way.

This PR drops that requirement. The href to write is now resolved via
clmPreferredHref, in priority order:

  1. A real, server-issued Href already on the resource's own profile, if present
  2. A sample Href from a sibling entry already on hand (e.g. another of the
    member's current groups, another folder-security entry)
  3. client.GroupHref/MemberHref, derived from the discovered CLM base URL

Revoke compares by bare ID instead, so it no longer needs a href at all.

Also includes several rounds of hardening from automated review: empty-ID
guards on every Grant/Revoke path (principal, folder, member/group), rejecting
malformed sample hrefs (trailing-slash/collection-root, bare scheme+host, no
path segment), and consistent codes.InvalidArgument classification on all
the new validation errors.

Useful links:

@FeliLucero1 FeliLucero1 changed the title fix: carry CLM href via ExternalId instead of profile for Grant/Revoke [CXH-2208] fix: carry CLM href via ExternalId instead of profile for Grant/Revoke Aug 7, 2026
@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

CXH-2208

Comment thread pkg/connector/clm_groups.go Outdated
Comment thread pkg/connector/clm_folders.go Outdated
Comment thread pkg/connector/clm_members.go Outdated
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: [CXH-2208] fix: derive CLM hrefs for Grant/Revoke instead of requiring a profile field

Blocking Issues: 0 | Suggestions: 4 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base f7597e86ad55.
Review mode: incremental since a090a700
View review run

Review Summary

The new commit reworks SearchFolders onto CLM's async FolderSearchTasks Task API (create → poll → paginate the task's own Result href, with a new ResultHref field on the page token), corrects ClmFolderSecurity from paginated wrappers to flat arrays, and moves group/user security entries to the confirmed nested {Item:{…},AccessType} wire shape via custom marshalers. The full PR diff was scanned for security and correctness, including pkg/client/oauth.go's scope narrowing and the .github/workflows/ci.yaml concurrency block; no go.mod/go.sum changes are present. Prior bot feedback on this PR is addressed — the empty-ID guards, codes.InvalidArgument classification, clmHrefWithID's trailing-slash/bare-host/query-string handling, and the getAnnos propagation in the default: branches are all in the head tree — and no blocking issues were found in the new commit; the four suggestions below are all on code introduced by it.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/client/clm_client.go:376-377 — an empty task.Result.Href mints a token that re-POSTs a new search task each call and recomputes the identical token, looping forever with maxClmListPages unable to fire; same lines trust Result.Limit as the requested page size.
  • pkg/connector/clm_folders.go:141 — a flat-shaped security entry unmarshals without error into Href: "", so Grants emits grants with an empty principal resource ID instead of failing or skipping.
  • pkg/client/clm_models.go:171-177ClmRoleSecurityEntry keeps a "Confirmed shape" claim that the sibling structs' own new docs contradict; if Roles is nested like Groups/Users, Item string fails to unmarshal and breaks the whole expand=Security read.
  • pkg/client/clm_client.go:425-432 (and :388) — authenticated requests are issued to URLs read from a response body and from the pagination token without validating scheme/host against the discovered CLM base URL.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/client/clm_client.go`:
- Around line 376-377: SearchFolders mints its next-page token with `ResultHref:
  task.Result.Href`. If that Href is empty, the next call's `decoded.ResultHref != ""`
  check at line 352 fails, so it POSTs a brand-new folder search task, gets page 1 back,
  and rebuilds `requestedPage` as `{Offset: 0, Requests: 0}` — producing the identical
  token again, forever. The `maxClmListPages` cap cannot catch this because both Offset
  and Requests reset on every create. Guard it: when `task.Result.Href == ""`, either
  return the first page with an empty next token or return an explicit error, rather
  than emitting a token that will re-create the search. Also on the same two lines,
  `PageSize: task.Result.Limit` trusts a response field for the short-page stop while
  the create POST never sends `pageSortParams.limit`, so the caller's `options.PageSize`
  is ignored on page 1; if `Limit` is 0 the short-page stop can never fire and each
  folder sync issues one extra out-of-range request. Consider sending the requested
  limit on the create call and/or falling back to `options.PageSize` when `Limit <= 0`.
- Around line 425-432 (and line 388 in `getClmFolderSearchResultPage`): both issue a
  bearer-token-authenticated request to a URL taken verbatim from an API response body
  (`task.Href`) or from the pagination token (`resultHref`), with no validation. Because
  `resultHref` is persisted in the page token and replayed on resume, a tampered token
  could send the DocuSign access token to an arbitrary host. Before calling
  `doClmRequest`, check the parsed URL's Scheme and Host against the resolved CLM base
  URL and reject a mismatch with a `codes.InvalidArgument` error.

In `pkg/connector/clm_folders.go`:
- Around line 141-178: `entry.Href` for both the Groups and Users loops now comes from
  the nested `Item.Href` populated by the new `UnmarshalJSON`. If CLM returns the flat
  shape, `json.Unmarshal` silently ignores the unknown top-level keys and leaves `Href`
  empty, so `clmIDFromHref("")` produces an empty principal resource ID that is still
  emitted as a grant. Skip entries whose `Href` is empty (with a `ctxzap` Warn naming the
  folder), in both the Groups loop at line 141 and the Users loop at line 171, so a
  wire-shape mismatch surfaces instead of writing degenerate grants.

In `pkg/client/clm_models.go`:
- Around line 171-177: `ClmRoleSecurityEntry`'s doc asserts "Confirmed shape: flat
  {AccessType, Item}", but `ClmUserSecurityEntry`'s new doc states that every populated
  folder-security entry on the tested tenant was a Group, so Roles was not confirmed
  live either — and both neighbouring structs were just corrected away from identically
  worded claims. If Roles is nested like Groups/Users, unmarshalling an object into
  `Item string` errors and fails the entire `GetFolder?expand=Security` request, which is
  the same sync-breaking failure this PR fixes for Groups. Either add a tolerant
  `UnmarshalJSON` that accepts a bare string or an `{"Item":{"Name":...}}` object (with a
  table-driven test covering both), or reword the doc so it no longer claims confirmation
  the evidence does not support.

@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.

Comment thread pkg/connector/helper.go Outdated

@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.

Comment thread pkg/client/clm_client.go Outdated
Comment thread pkg/connector/clm_folders_test.go Outdated

@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.

Comment thread pkg/connector/clm_folders_test.go
Comment thread pkg/connector/clm_folders.go Outdated
Comment thread pkg/client/clm_client.go Outdated
Comment thread pkg/connector/clm_groups_test.go Outdated

@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.

Comment thread pkg/connector/helper.go
Comment thread pkg/connector/clm_folders_test.go Outdated
Comment thread pkg/connector/clm_folders.go Outdated
Comment thread pkg/connector/clm_folders_test.go Outdated

@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.

FeliLucero1 added a commit that referenced this pull request Aug 10, 2026
- Remove clmPreferredHref's redundant empty-string guard — clmHrefWithID
  already rejects an empty sample on its own.
- Rephrase clmPreferredHref's doc away from "no live tenant confirmed this"
  hedging into a factual statement of the write-vs-read Href asymmetry.
- Update two clm_folders_test.go comments that still named the removed
  clmGroupHrefFromResource helper as current write-side behavior.
Comment thread pkg/connector/clm_groups_test.go Outdated

@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.

FeliLucero1 added a commit that referenced this pull request Aug 10, 2026
- Remove the redundant client.GroupHref/client.MemberHref calls from
  clm_folder's Revoke — clmFindGroupSecurityIndex/clmFindUserSecurityIndex
  only ever compare by clmIDFromHref, so principal.Id.Resource (already a
  bare ID) works directly without the extra ensureClmReady round trip and
  discarded error path.
- Update stale comments (clm_folders_test.go, clm_groups.go) that still
  described Grant as deriving the Href straight from the ID, or named the
  deleted clmGroupHrefFromResource/clmMemberHrefFromResource helpers.
- Split TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource
  into two subtests so both of clmPreferredHref's branches (fallback via
  client.GroupHref, and the real-sample branch) get exercised.
- Assert the exact Href a Grant call writes in
  TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal, not
  just the resulting AccessType.
Comment thread pkg/connector/clm_folders_test.go
Comment thread pkg/connector/clm_groups_test.go
FeliLucero1 and others added 3 commits August 19, 2026 16:42
clmHrefWithID located the ID segment to replace via strings.LastIndex
on the raw sampleHref string, even though url.Parse was already called
to validate it. A "/" inside a query string or fragment (e.g.
".../group-old?filter=a/b") would win over the real path separator,
corrupting the query instead of replacing the ID
(".../group-old?filter=a/" + newID). Now splits on the parsed URL's
Path and reassembles via url.URL, so query/fragment content can't be
mistaken for the path separator. Added a regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test-groups/test-signing-groups/test-permission-profiles are
needs-chained within a single workflow run but share no concurrency
group at all, so two different branches' runs against this repo's one
live demo account can still execute concurrently -- one run's mid-cycle
Grant/Revoke corrupting another's "should be zero grants after Revoke"
assertion. Confirmed directly: this same failure just hit test-groups
here while PR #64 (stacked on this branch) ran its own, already-fixed
version of this same job at the same time. Adds a workflow-level
concurrency group so the whole three-job run queues/cancels as one
unit against the shared account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The all-samples-failed-to-parse branch already logged before falling
back to a derived href; the more common no-samples-at-all branch (a
group/member with no other memberships yet) fell back completely
silently. Since deriveFallback's shape is unverified against a live
tenant, a silently-wrong derived href written into a PatchFolderSecurity
body would leave a grant with no observable trace of why it didn't
take effect. Caught by the automated PR reviewer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@FeliLucero1
FeliLucero1 force-pushed the clm-folder-provisioning-external-id branch from 14806b3 to 1a72aec Compare August 19, 2026 19:42
FeliLucero1 added a commit that referenced this pull request Aug 19, 2026
PR #63 and this branch independently fixed the same tautological-test
finding on TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch
(a byte-identical sample-derived vs. fallback-derived Href) via two
different mechanisms — Server.SetFolderGroupSecurityHref/
SetFolderUserSecurityHref (surgical Href override) vs. a full
PatchFolderSecurity re-seed — which the rebase concatenated into one
function body (duplicate sampleHost/wantHref declarations, and the
re-seed silently overriding the override). Keep the surgical version:
it preserves folder-contracts' original seed shape (both group
entries) instead of replacing it.
FeliLucero1 added a commit that referenced this pull request Aug 19, 2026
Rebasing this branch onto PR #63's latest tip silently reapplied an
older PR #64 commit's import-block patch over helper.go's newly-added
codes/status imports (no conflict was flagged since the patch context
didn't include them), breaking the build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread .github/workflows/ci.yaml
Comment on lines +15 to +17
concurrency:
group: docusign-demo-account
cancel-in-progress: false

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: The group key is a constant, so every PR run and every push-to-main run shares one queue. With cancel-in-progress: false GitHub still keeps only one pending run per group — "any previously pending job or workflow in the concurrency group will be canceled" — so with 3+ runs in flight the oldest queued run is cancelled rather than eventually run, and its checks land as cancelled needing a manual re-run. That's an acceptable trade for a shared demo account, but worth noting in the comment (or scoping the group to just the sync-test steps) so the cancellations aren't read as flakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same accepted trade-off already discussed on this and PR #64's ci.yaml — one shared demo account, workflow-level concurrency keeps the whole 3-job run serialized as one unit at the cost of an occasional cancelled-and-rerun on a busy day. Leaving as-is.

Comment thread pkg/connector/helper.go
// what the raw-string check used to.
return "", status.Errorf(codes.InvalidArgument, "baton-docusign: cannot derive a sibling href from %q — no path separator found", sampleHref)
}
u.Path = u.Path[:idx+1] + newID

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: only u.Path is rewritten, so the sample's RawQuery and Fragment ride along into the derived href — TestClmHrefWithID pins this (.../group-old?filter=a/b.../group-new?filter=a/b). The result is a write href identifying a different object, so carrying another entry's query/fragment is never wanted; clearing them (u.RawQuery = "", u.Fragment = "") makes the derivation shape-only. Low likelihood in practice since CLM Hrefs don't appear to carry query strings, but it costs two lines to rule out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in 71407ba: clmHrefWithID now clears RawQuery and Fragment before returning, since the derived href identifies a different object than the sample's. Updated TestClmHrefWithID's regression case to match (the derived href no longer carries the sample's ?filter=a/b).

FeliLucero1 and others added 2 commits August 19, 2026 16:49
staticcheck doesn't recognize t.Fatalf as terminating the goroutine, so
it flags the found.AccessType dereference just after a "found == nil"
guard as a possible nil pointer dereference. Adding an explicit return
after each Fatalf breaks the control-flow path staticcheck otherwise
sees, the standard fix for this well-known false-positive pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Only Path was rewritten, so a sample carrying a query string or
fragment leaked it into the derived href for a different object.
Since the derived href identifies a distinct target, the sample's
query/fragment is never meaningful there -- clear both so the
derivation is shape-only. Caught by the automated PR reviewer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread pkg/connector/helper.go Outdated
Comment on lines +87 to +90
// clmIDFromHref extracts the trailing path segment from a CLM object's Href — see
// client.IDFromHref's doc. pkg/client/clmtest reimplements the same logic locally to
// avoid depending on pkg/connector, so both packages call the one shared definition in
// pkg/client instead of maintaining two copies.

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 doc contradicts itself — "pkg/client/clmtest reimplements the same logic locally" is exactly what this PR removed (clmtest's idFromHref now just calls client.IDFromHref), yet the next clause correctly says both packages call the one shared definition. The reason clmtest can't import pkg/connector is still worth stating; the "reimplements locally" part is now stale. Something like: "pkg/client/clmtest can't import pkg/connector, so the single definition lives in pkg/client and both packages delegate to it."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e395125 — reworded to state the actual reason (clmtest can't import pkg/connector) instead of the stale 'reimplements locally' claim.

Comment thread pkg/connector/clm_folders.go Outdated
}
default:
return nil, nil, fmt.Errorf("baton-docusign: invalid principal type for CLM folder security: %s", principal.Id.ResourceType)
return nil, nil, status.Errorf(codes.InvalidArgument, "baton-docusign: invalid principal type for CLM folder security: %s", principal.Id.ResourceType)

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 PR changed the group/member href-resolution error paths (lines 234, 261) to propagate getAnnos instead of nil, but the default: branch here — and the matching one in Revoke (line 386) — still returns nil, discarding the rate-limit annotations GetFolderFresh already produced. Returning getAnnos in both default branches would make the whole method consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e395125 — both default branches (Grant and Revoke) now propagate getAnnos, consistent with the group/member branches.

@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.

Grant/Revoke's invalid-principal-type default branches still returned
nil annotations, discarding the rate-limit annotations GetFolderFresh
already produced -- inconsistent with the group/member branches in
this same file, which already propagate them. Also fixed
clmIDFromHref's doc comment, which claimed clmtest "reimplements the
same logic locally" when this PR made it delegate to the same shared
definition instead. Caught by the automated PR reviewer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread pkg/connector/helper.go
if u.Path == "/" || strings.HasSuffix(u.Path, "/") {
return "", status.Errorf(codes.InvalidArgument, "baton-docusign: cannot derive a sibling href from %q — sample has no ID segment (trailing slash)", sampleHref)
}
idx := strings.LastIndex(u.Path, "/")

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 (low confidence): the split uses u.Path, which is the decoded path, so a sample href whose ID segment contains an encoded slash (e.g. .../groups/a%2Fb) decodes to /v2/acct/groups/a/b and LastIndex would then replace only b, yielding .../groups/a/<newID> instead of .../groups/<newID>. u.EscapedPath() (with the result assigned back via u.RawPath/url.PathUnescape) would be exact. Practically unreachable if CLM IDs are GUID-like, so this is robustness only, not a live bug.

@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.

…lope

Live-tested against a real CLM tenant: with only spring_read/spring_write
granted, account discovery succeeded but every CLM data call (ListGroups,
ListPermissionSets) failed with 401 "Access Denied" (ErrorCode 103) — CLM
requires the impersonation scope for data-plane access, confirmed against
DocuSign's own CLM Authentication Overview example scope string.

Also fixes ClmErrorResponse: the real error body nests fields under "Error"
(UserMessage/DeveloperMessage/ErrorCode), not a top-level "Message" — every
real CLM error was surfacing as "unknown CLM API error" instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread pkg/client/oauth.go Outdated
// account-discovery scope as "springcm_read" instead); confirmed harmless to
// request even when unrecognized — DocuSign's OAuth consent silently drops them
// rather than rejecting the whole grant.
clmScopes = []string{"impersonation", "spring_read", "spring_write", "springcm_read", "springcm_write"}

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: adding impersonation makes the documented CLM scope list stale. docs/doc-info.md:173 still tells operators to grant only spring_read/spring_write, and the in-code docs at pkg/client/clm_client.go:4 and pkg/connector/helper.go:47 say the same. Worth also noting that x/oauth2's refresh-token grant doesn't resend Scopes, so an already-connected CLM install keeps its old consent and must be re-authorized (--configure) before this fix takes effect.

Comment thread pkg/client/clm_models.go Outdated
Comment on lines +37 to +40
if e.Error.DeveloperMessage != "" && e.Error.DeveloperMessage != e.Error.UserMessage {
return fmt.Sprintf("CLM API error %d: %s (%s)", e.Error.ErrorCode, e.Error.UserMessage, e.Error.DeveloperMessage)
}
return fmt.Sprintf("CLM API error %d: %s", e.Error.ErrorCode, e.Error.UserMessage)

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: when CLM returns a DeveloperMessage but no UserMessage, the first branch is taken and renders an empty slot — CLM API error 103: (token missing impersonation scope) (double space, no primary text). Falling back to DeveloperMessage as the primary when UserMessage is empty would keep the message well-formed. Low confidence that CLM ever emits that combination, since the live sample populates both.

@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.

Message() rendered a malformed "CLM API error 103:  (dev msg)" (empty
primary slot, double space) whenever CLM returned a DeveloperMessage
with no UserMessage. Now falls back to DeveloperMessage as the primary
text in that case; added a regression test.

clm_client.go's package doc still listed only spring_read/spring_write
as CLM's OAuth scopes, left stale when impersonation was added to
clmScopes. Both were flagged by the CI reviewer on the very first pass
over this PR and never fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@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.

…eal cause

DocuSign's CLM Authentication Overview docs (pasted live) confirm the
Authorization Code Grant scope example is exactly signature+spring_read+
spring_write, with impersonation explicitly noted as JWT-Grant-only. The
live tenant that returned 401 "Access Denied" (ErrorCode 103) already had
this exact scope set granted, so the 401 was never a scope problem —
current working theory is a demo/UAT environment limitation, per the same
docs requiring a production account for CLM API access (see clm_client.go).

Also drops springcm_read/springcm_write: DocuSign's official scopes
reference lists no such scopes at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@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.

…wire shape

SearchFolders previously called a synchronous POST /folders/search endpoint
that doesn't exist for folders (confirmed live: 405 Method Not Allowed).
Folder search is CLM's async Task API instead: POST /foldersearchtasks with
{"Title": ""} (the only accepted "match everything" parameter — other names
including the "Name" field ClmFolder itself uses are rejected) creates a
search task that resolves inline or must be polled via its own Href until
Status leaves "Processing"; continuation pages GET the task's own Result
href directly rather than re-POSTing a new search. Verified live against a
100-folder real CLM tenant, list and grants both sync cleanly end-to-end.

Also fixes ClmFolderSecurity: the real wire shape is three flat arrays
(Groups/Roles/Users), each entry nesting the referenced object under an
"Item" key sibling to AccessType — not the paginated Page-wrapper types an
earlier version assumed, which broke JSON unmarshaling outright on any
folder with populated security. Confirmed via GetFolder?expand=Security
against real data.

Grant/Revoke on clm_folder (PatchFolderSecurity) remains unconfirmed to
work against a real tenant — tested live against a disposable test folder
with no effect despite matching the documented shape; see clm_client.go's
package doc for the evidence and the likely real mechanism (a dedicated
Task API endpoint, not this generic object Patch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread pkg/client/clm_client.go
Comment on lines +376 to +377
requestedPage := clmRequestedPage{Offset: 0, PageSize: task.Result.Limit}
nextToken, err := getClmNextToken(requestedPage, len(task.Result.Items), task.Result.Next != "", task.Result.Total, task.Result.Href)

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: if CLM ever returns a Result with an empty Href, this mints a token with ResultHref: "", so the next SearchFolders call falls through the decoded.ResultHref != "" check at line 352 and POSTs a brand new search task — which returns page 1 again and, because requestedPage is rebuilt here as {Offset: 0, Requests: 0}, computes the byte-identical token every time. That's an unbounded loop re-emitting the same folders forever: maxClmListPages can't fire because both Offset and Requests reset on each create. Every other response field here (Next/Total/Offset) is explicitly distrusted per getClmNextToken's doc, so Result.Href deserves the same guard — e.g. return the first page with an empty next token (or a hard error) when task.Result.Href == "".

Secondary, same two lines: PageSize: task.Result.Limit trusts a response field for the short-page stop, and the create POST never sends pageSortParams.limit, so the caller's options.PageSize is ignored on page 1. If Limit comes back 0, itemCount < requested.PageSize is never true and every folder sync issues one extra out-of-range request past its last page.

Comment thread pkg/client/clm_models.go
Comment on lines 171 to 177
@@ -103,31 +176,68 @@ type ClmRoleSecurityEntry struct {
Item string `json:"Item"`
}

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 doc still claims a "Confirmed shape" while the two structs on either side of it were just corrected away from identically-worded "confirmed" claims — and ClmUserSecurityEntry's new doc concedes that every populated folder-security entry on the tested tenant was a Group, so Roles cannot have been confirmed live either. If Roles is nested like Groups/Users ({"Item":{...},"AccessType":...}), unmarshalling an object into Item string returns an error, which fails the whole GetFolder?expand=Security call — the same sync-breaking failure this PR is fixing for Groups. Consider a tolerant unmarshaler (bare string, else Item.Name), or at minimum reword "Confirmed" to match the actual evidence.

var grants []*v2.Grant

for _, entry := range folder.Security.Groups.Items {
for _, entry := range folder.Security.Groups {

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: entry.Href (line 146, and line 176 for Users) now comes from the nested Item.Href via the new UnmarshalJSON. If CLM ever returns the flat shape instead, json.Unmarshal does not error — unknown top-level keys are ignored — so Href silently decodes as "", and clmIDFromHref("") yields an empty principal resource ID that still gets emitted as a grant. That's the inverse of the wire-shape bug this PR fixes, and it fails silently rather than loudly. Worth skipping (with a Warn) entries whose Href is empty in both loops, matching the defensiveness of the new empty-ID guards in Grant/Revoke.

Comment thread pkg/client/clm_client.go
Comment on lines +425 to +432
pollURL, err := url.Parse(task.Href)
if err != nil {
return task, fmt.Errorf("baton-docusign: invalid CLM folder search task href %q: %w", task.Href, err)
}
// WithNoCache: repeated GETs to the same task URL must observe its latest
// Status, not a memoized first response — see GetFolder's noCache param for the
// same uhttp GET-cache staleness concern.
if _, err := c.doClmRequest(ctx, http.MethodGet, pollURL, nil, &task, uhttp.WithNoCache()); err != nil {

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 (security, low likelihood): this issues a bearer-token-authenticated GET to a URL taken verbatim from a response body, with no check that its scheme/host matches the discovered CLM base URL. The same applies to resultHref in getClmFolderSearchResultPage (line 388), which is worse in one respect: that URL is persisted into the pagination token and handed back to the connector on resume, so a tampered token would redirect an authenticated request — and the DocuSign access token with it — to an arbitrary host. Validating u.Scheme/u.Host against the resolved CLM base URL before calling doClmRequest closes both.

@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.

7 participants