[CXH-2208] fix: derive CLM hrefs for Grant/Revoke instead of requiring a profile field - #63
[CXH-2208] fix: derive CLM hrefs for Grant/Revoke instead of requiring a profile field#63FeliLucero1 wants to merge 31 commits into
Conversation
Connector PR Review: [CXH-2208] fix: derive CLM hrefs for Grant/Revoke instead of requiring a profile fieldBlocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0 Review SummaryThe new commit is a two-line fix plus a doc correction, and it addresses both prior findings: Security IssuesNone found. Correctness IssuesNone found. Suggestions
Prompt for AI agents |
422de89 to
94c2054
Compare
- 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.
- 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.
…efWithID clmHrefWithID had no check that newID was non-empty — an empty ID silently produced a trailing-slash href with no ID segment at all, which would go out in a real PatchFolderSecurity/PatchMemberGroups request body with no link back to "the ID was empty." Confirmed reachable in practice: List() never validates that CLM's Href field is non-empty before deriving a resource ID from it. clmPreferredHref also discarded every per-sample parse error before falling back, so an unexpected Href shape (a real bug or an unconfirmed-API-shape surprise) looked identical to the routine "no sample available yet" case. Now logged before falling back. Also: dedupe clmIDFromHref (pkg/connector) and idFromHref (clmtest) — both reimplemented the identical trailing-path-segment logic — into one shared client.IDFromHref.
…error prefixes - Collapse GroupHref/MemberHref (identical bodies except the endpoint constant) into a shared hrefFor, and give MemberHref its own clmGetMember const instead of reusing clmPatchPutMember. - Extract clmSampleHrefsFrom, used identically in 3 places (clm_folders.go x2, clm_groups.go) to build clmPreferredHref's sample list from a principal's profile href plus existing entries. - Add the missing "baton-docusign: " prefix to every error wrap in clm_folders.go/clm_groups.go that was missing it, per this repo's error-handling convention (every wrapped error gets the prefix). - Unify clmFindGroupSecurityIndex/clmFindUserSecurityIndex (identical loops differing only by entry type) behind one generic helper.
…preference TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch asserted against Hrefs that were byte-identical to what clmPreferredHref's fallback derivation would also produce, so it passed whether or not the sample branch actually ran — the same gap already fixed in clm_groups_test.go. Added Server.SetFolderGroupSecurityHref/SetFolderUserSecurityHref (folder security entries carry no separate ID field to key an override off of) to move the existing samples onto a different host first, and build the expected Href literally instead of via clmHrefWithID. Also trims doc comments that narrated this PR's fix history instead of describing current behavior.
- parseIntoClmGroupResource/parseIntoClmMemberResource's doc was stale after the clmSampleHrefsFrom refactor: Grant does read the profile href off the resource now, as the preferred sample — it just isn't required anymore. Reworded both. - The folder-security SampleBranch test only moved one of folder-contracts' two seeded group entries onto the alt host, so the assertion silently depended on seed order (group-legal being first). Moved group-finance too so it's order-independent, matching what the test's own comment already claimed. - SetFolderGroupSecurityHref/SetFolderUserSecurityHref silently no-op on a typo'd folderID/groupID instead of failing the test, matching every other fixture-misuse case in this package.
Consistency follow-up: SetFolderGroupSecurityHref/SetFolderUserSecurityHref were just changed to Fatalf on a miss, but the original SetGroupHref still silently no-op'd.
- Rename clmGetGroup/clmGetMember to clmGroupPath/clmMemberPath — hrefFor never issues a request with them, it only builds a Href string locally, so the clmGet* naming misleadingly implied a new GET endpoint (and neither appeared in the package doc's endpoint list). - clmSampleHrefsFrom appended a present-but-empty profile "href" as a sample (GetProfileStringValue returns ok=true for ""), which always fails clmHrefWithID and made the routine no-sample case look identical to a genuinely unexpected Href shape. Added a regression test, verified via mutation testing. - Reworded clmPreferredHref's doc: it claimed the Debug log "avoids masking" the wrong-host risk, which isn't true if Debug is below the default log level in production. State what it actually achieves instead (distinguishable at raised verbosity, not silently discarded outright) rather than overclaiming production visibility.
Same reasoning as the profile-href guard: an empty entry Href fails clmHrefWithID the same way and re-trips clmPreferredHref's unexpected-shape log on degenerate-but-routine data, not just the profile side. Added a regression test, verified via mutation testing.
clmHrefWithID's empty-newID guard only protects the sample path. When id is empty and no sample href is available (or all fail to parse), clmPreferredHref fell straight through to deriveFallback (typically client.GroupHref/MemberHref built from that same empty id), producing the same malformed trailing-slash href the sample-path guard exists to prevent. Moved the check into clmPreferredHref itself, ahead of both paths, plus a regression test. Also fixed clm_groups.go's Grant comment: it said the href source order was "currentGroups, else client.GroupHref," omitting that ent.Resource's own profile href (via clmSampleHrefsFrom) is actually tried first when present — contradicting the very next comment block a few lines down. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reword hrefFor's doc to admit the one-time CLM base-URL discovery it can trigger via ensureClmReady, and add an explicit empty-ID guard to Revoke covering all three principal branches — Grant already rejected an empty ID via clmPreferredHref/clmHrefWithID, but Revoke's shortcut of comparing principal.Id.Resource directly skipped that check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Grant's role branch used principal.Id.Resource directly as roleName with no emptiness check, unlike the group/member branches (guarded incidentally by clmPreferredHref) — an empty ID could overwrite an existing empty-Item role entry or append a new one, then PATCH it to the real CLM API uncaught. Hoists the same guard Revoke already has to the top of Grant, and adds a regression test covering both across all three principal kinds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same class of bug as clm_folders.go's Grant/Revoke (already guarded): clmIDFromHref reduces an empty Href to "", so an empty groupID could match a currentGroups entry with an empty Href — Grant would falsely return GrantAlreadyExists before clmPreferredHref's own empty-id check ever ran, and Revoke would silently drop that unrelated membership via PutMemberGroups' full-replace semantics. Adds the same guard to both, plus a regression test covering empty member ID, empty group ID, and both. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same class of bug as the principal-ID guard already added: folderID (ent.Resource.Id.Resource in Grant, grantObj.Entitlement.Resource.Id.Resource in Revoke) had no emptiness check. parseIntoClmFolderResource derives a clm_folder's ID via clmIDFromHref(folder.Href) with no non-empty validation, so an empty Href would produce a folder resource with an empty ID — which GetFolderFresh/PatchFolderSecurity would then build into a URL hitting the folders collection root instead of failing clearly. Adds the same guard shape to both, plus a regression test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ject trailing-slash sample hrefs
- All the pre-flight validation errors added this PR (clm_folders.go and
clm_groups.go's Grant/Revoke guards, helper.go's clmHrefWithID/
clmPreferredHref) were plain fmt.Errorf, which status.Code() reads as
codes.Unknown. Switched to status.Errorf(codes.InvalidArgument, ...)
so these surface as permanent bad-input failures, consistent with the
rest of the file's error-classification style.
- clmHrefWithID silently trimmed a trailing slash off sampleHref before
deriving, which made a collection-root-shaped sample (".../groups/")
indistinguishable from a valid item href ending in "groups" — the
ID-replacement logic then dropped the real collection segment,
producing ".../<newID>" instead of ".../groups/<newID>". Now rejects
any sample already ending in "/" outright, with a regression test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…errors Converts the last 4 validation errors this PR's diff touches (unknown CLM folder entitlement slug, invalid principal type in clm_folders.go Grant/Revoke, invalid principal type in clm_groups.go Grant) from plain fmt.Errorf to status.Errorf(codes.InvalidArgument, ...), matching the empty-ID guards already converted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
14806b3 to
1a72aec
Compare
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.
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>
| concurrency: | ||
| group: docusign-demo-account | ||
| cancel-in-progress: false |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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.
| // 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 |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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).
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>
| // 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. |
There was a problem hiding this comment.
🟡 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."
There was a problem hiding this comment.
Fixed in e395125 — reworded to state the actual reason (clmtest can't import pkg/connector) instead of the stale 'reimplements locally' claim.
| } | ||
| 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) |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
Fixed in e395125 — both default branches (Grant and Revoke) now propagate getAnnos, consistent with the group/member branches.
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>
| 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, "/") |
There was a problem hiding this comment.
🟡 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.
Description
Grant/Revoke for CLM folders/groups/members previously required a
hrefprofile field to be present on the principal/entitlement resource — which
pebble's identity-only
V3EntitlementToV2hydration doesn't carry, soprovisioning 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:member's current groups, another folder-security entry)
client.GroupHref/MemberHref, derived from the discovered CLM base URLRevoke 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.InvalidArgumentclassification on allthe new validation errors.
Useful links: