From c5273e651854681ba88c1b914d8d0865f6644dde Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 7 Aug 2026 13:46:49 -0300 Subject: [PATCH 01/41] fix: carry CLM href via ExternalId instead of profile for Grant/Revoke baton-sdk's local provisioner (pkg/provisioner/provisioner.go) rebuilds the principal resource it hands to Grant/Revoke from only Id/DisplayName/ Annotations/Description/ExternalId/ParentResourceId, dropping the top-level Resource.profile. Since clm_member and clm_group stashed their CLM href only via WithResourceProfile, that data was lost on every offline/local provisioning call, making clm_folder Grant/Revoke fail 100% of the time for both principal types. ExternalId survives that reconstruction, so the href now travels there instead. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_folders.go | 12 +++++++----- pkg/connector/clm_groups.go | 17 ++++++++++------- pkg/connector/clm_members.go | 8 +++++++- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 016c9791..c0aca4c6 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -403,12 +403,14 @@ func clmIsKnownRole(name string) bool { } // clmMemberHrefFromResource reads back the Href stashed in a CLM member resource's -// profile (see parseIntoClmMemberResource) — needed to reference the member in a -// folder-security grant body. +// ExternalId (see parseIntoClmMemberResource) — needed to reference the member in a +// folder-security grant body. Reads ExternalId, not the profile: the SDK's local +// provisioner rebuilds the principal passed to Grant/Revoke without the top-level +// profile, but preserves ExternalId (see parseIntoClmMemberResource's comment). func clmMemberHrefFromResource(principal *v2.Resource) (string, error) { - href, ok := rs.GetProfileStringValue(rs.GetProfile(principal), "href") - if !ok || href == "" { - return "", fmt.Errorf("baton-docusign: CLM member resource %s is missing its href profile field", principal.Id.Resource) + href := principal.GetExternalId().GetId() + if href == "" { + return "", fmt.Errorf("baton-docusign: CLM member resource %s is missing its href external ID", principal.Id.Resource) } return href, nil } diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 271f0dee..a54234f6 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -221,9 +221,11 @@ func newClmGroupBuilder(c *client.Client) *clmGroupBuilder { } // parseIntoClmGroupResource maps a client.ClmGroup to a Baton v2.Resource. The Href is -// carried in the profile (not just used to derive the ResourceId) so Grant() can -// reconstruct a reference to this group without needing to guess a URL — see -// clmGroupHrefFromResource. +// carried both in the profile (for display) and via WithExternalID — ExternalId is +// what clmGroupHrefFromResource actually reads, because the SDK's local provisioner +// (pkg/provisioner/provisioner.go) rebuilds the principal it hands to Grant/Revoke from +// only Id/DisplayName/Annotations/Description/ExternalId/ParentResourceId, dropping the +// top-level profile. func parseIntoClmGroupResource(group *client.ClmGroup) (*v2.Resource, error) { profile := map[string]any{ "name": group.Name, @@ -237,16 +239,17 @@ func parseIntoClmGroupResource(group *client.ClmGroup) (*v2.Resource, error) { clmIDFromHref(group.Href), nil, rs.WithResourceProfile(profile), + rs.WithExternalID(&v2.ExternalId{Id: group.Href}), ) } // clmGroupHrefFromResource reads back the Href stashed in a CLM group resource's -// profile (see parseIntoClmGroupResource) — needed to reference the group in a +// ExternalId (see parseIntoClmGroupResource) — needed to reference the group in a // Members.Patch grant body. func clmGroupHrefFromResource(groupResource *v2.Resource) (string, error) { - href, ok := rs.GetProfileStringValue(rs.GetProfile(groupResource), "href") - if !ok || href == "" { - return "", fmt.Errorf("baton-docusign: CLM group resource %s is missing its href profile field", groupResource.Id.Resource) + href := groupResource.GetExternalId().GetId() + if href == "" { + return "", fmt.Errorf("baton-docusign: CLM group resource %s is missing its href external ID", groupResource.Id.Resource) } return href, nil } diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 17618a38..247cf7ce 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -83,7 +83,12 @@ func newClmMemberBuilder(c *client.Client) *clmMemberBuilder { } } -// parseIntoClmMemberResource maps a client.ClmMember to a Baton v2.Resource. +// parseIntoClmMemberResource maps a client.ClmMember to a Baton v2.Resource. The Href +// is also stashed via WithExternalID, not just the profile — the SDK's local +// provisioner (pkg/provisioner/provisioner.go) rebuilds the principal it hands to +// Grant/Revoke from only Id/DisplayName/Annotations/Description/ExternalId/ +// ParentResourceId, dropping the top-level profile. ExternalId is the field that +// actually survives to clmMemberHrefFromResource. func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) { profile := map[string]any{ profileFieldEmail: member.Email, @@ -109,5 +114,6 @@ func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) clmIDFromHref(member.Href), userTraits, rs.WithResourceProfile(profile), + rs.WithExternalID(&v2.ExternalId{Id: member.Href}), ) } From 9c672a4401b64cdcd7c0e22e181503eecc745e18 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 7 Aug 2026 14:16:27 -0300 Subject: [PATCH 02/41] fix: carry CLM href via a raw annotation instead of deprecated ExternalId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review feedback on the previous commit: rs.WithExternalID sets Resource.ExternalId, which the proto marks [deprecated = true] and staticcheck (SA1019) correctly flags — this repo just went through the same cleanup for trait-level profile helpers (c5fdde7). ExternalId still survives the SDK's local provisioner reconstruction, but reusing its message shape as a plain rs.WithAnnotation (not through the deprecated field) gets the same durability without the lint failure or the deprecated symbol. Also, per review: clmHrefFromResource now falls back to the profile's href field when the annotation isn't present, for resources that never get rebuilt by the provisioner (an entitlement's own Resource) or that were synced before this annotation existed. And added a regression test that builds the principal the way the provisioner actually does (Id/DisplayName/Annotations/Description/ParentResourceId only, no profile) instead of the fully-populated resource every existing test used — the previous tests would have passed either way. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_folders.go | 14 +++--- pkg/connector/clm_folders_test.go | 83 +++++++++++++++++++++++++++++++ pkg/connector/clm_groups.go | 27 ++++++---- pkg/connector/clm_members.go | 16 +++--- pkg/connector/helper.go | 18 +++++++ 5 files changed, 133 insertions(+), 25 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index c0aca4c6..f3f080f7 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -402,15 +402,13 @@ func clmIsKnownRole(name string) bool { return false } -// clmMemberHrefFromResource reads back the Href stashed in a CLM member resource's -// ExternalId (see parseIntoClmMemberResource) — needed to reference the member in a -// folder-security grant body. Reads ExternalId, not the profile: the SDK's local -// provisioner rebuilds the principal passed to Grant/Revoke without the top-level -// profile, but preserves ExternalId (see parseIntoClmMemberResource's comment). +// clmMemberHrefFromResource reads back the Href stashed in a CLM member resource (see +// parseIntoClmMemberResource and clmHrefFromResource) — needed to reference the member +// in a folder-security grant body. func clmMemberHrefFromResource(principal *v2.Resource) (string, error) { - href := principal.GetExternalId().GetId() - if href == "" { - return "", fmt.Errorf("baton-docusign: CLM member resource %s is missing its href external ID", principal.Id.Resource) + href, ok := clmHrefFromResource(principal) + if !ok || href == "" { + return "", fmt.Errorf("baton-docusign: CLM member resource %s is missing its href", principal.Id.Resource) } return href, nil } diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 600b410b..79e2f554 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -422,6 +422,89 @@ func TestClmFolderBuilder_GrantAndRevoke_ToleratesBareIDOnRead(t *testing.T) { } } +// clmSimulateProvisionerPrincipal rebuilds r the way the SDK's local file-mode +// provisioner does before handing a principal to Grant/Revoke (see +// vendor/github.com/conductorone/baton-sdk/pkg/provisioner/provisioner.go's grant/revoke +// functions): only Id, DisplayName, Annotations, Description, and ParentResourceId +// survive — notably NOT the top-level Profile, which is where this connector used to +// stash the CLM href before the fix TestClmFolderBuilder_GrantAndRevoke_ +// SurvivesProvisionerPrincipalReconstruction regression-guards. Deliberately excludes +// ExternalId even though the real provisioner does still copy it: the SDK marks that +// field `[deprecated = true]` and this connector no longer relies on it (see +// parseIntoClmMemberResource/parseIntoClmGroupResource). +func clmSimulateProvisionerPrincipal(r *v2.Resource) *v2.Resource { + return &v2.Resource{ + Id: r.Id, + DisplayName: r.DisplayName, + Annotations: r.Annotations, + Description: r.Description, + ParentResourceId: r.ParentResourceId, + } +} + +// TestClmFolderBuilder_GrantAndRevoke_SurvivesProvisionerPrincipalReconstruction is a +// regression test for the bug fixed by carrying the CLM href as a v2.ExternalId +// annotation instead of only the top-level profile. Every other Grant/Revoke test in +// this file passes the fully-populated resource parseIntoClm*Resource returns, which +// still has both profile and the annotation — so it would pass whether or not the fix +// actually works. This test instead passes a principal reconstructed the way the real +// offline/local provisioning path (baton-sdk's Provisioner) actually delivers it — see +// clmSimulateProvisionerPrincipal — to confirm the Href is still recoverable. +func TestClmFolderBuilder_GrantAndRevoke_SurvivesProvisionerPrincipalReconstruction(t *testing.T) { + srv, c := clmtest.NewServer(t) + b := newClmFolderBuilder(c) + ctx := context.Background() + + folderResource, err := rs.NewResource("Templates", clmFolderResourceType, "folder-templates") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + + t.Run("clm_group principal", func(t *testing.T) { + full, err := parseIntoClmGroupResource(&client.ClmGroup{Name: "Operations", Href: srv.GroupHref("group-ops")}) + if err != nil { + t.Fatalf("parseIntoClmGroupResource: %v", err) + } + principal := clmSimulateProvisionerPrincipal(full) + ent := &v2.Entitlement{Slug: "view", Resource: folderResource} + + if _, _, err := b.Grant(ctx, principal, ent); err != nil { + t.Fatalf("Grant with a provisioner-reconstructed principal: %v", err) + } + groups := srv.FolderSecurity("folder-templates").Groups.Items + if len(groups) != 1 || groups[0].AccessType != client.ClmAccessTypeView { + t.Fatalf("expected one View group entry after Grant, got %+v", groups) + } + + grantObj := &v2.Grant{Principal: principal, Entitlement: ent} + if _, err := b.Revoke(ctx, grantObj); err != nil { + t.Fatalf("Revoke with a provisioner-reconstructed principal: %v", err) + } + }) + + t.Run("clm_member principal", func(t *testing.T) { + full, err := parseIntoClmMemberResource(&client.ClmMember{Email: "dave@example.com", UserName: "dave", Href: srv.MemberHref("member-dave")}) + if err != nil { + t.Fatalf("parseIntoClmMemberResource: %v", err) + } + principal := clmSimulateProvisionerPrincipal(full) + ent := &v2.Entitlement{Slug: "view", Resource: folderResource} + + if _, _, err := b.Grant(ctx, principal, ent); err != nil { + t.Fatalf("Grant with a provisioner-reconstructed principal: %v", err) + } + users := srv.FolderSecurity("folder-templates").Users.Items + if len(users) != 1 || users[0].AccessType != client.ClmAccessTypeView { + t.Fatalf("expected one View user entry after Grant, got %+v", users) + } + + grantObj := &v2.Grant{Principal: principal, Entitlement: ent} + if _, err := b.Revoke(ctx, grantObj); err != nil { + t.Fatalf("Revoke with a provisioner-reconstructed principal: %v", err) + } + }) +} + func hasAlreadyExists(annos annotations.Annotations) bool { return annos.Contains(&v2.GrantAlreadyExists{}) } diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index a54234f6..9366426a 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -221,11 +221,16 @@ func newClmGroupBuilder(c *client.Client) *clmGroupBuilder { } // parseIntoClmGroupResource maps a client.ClmGroup to a Baton v2.Resource. The Href is -// carried both in the profile (for display) and via WithExternalID — ExternalId is -// what clmGroupHrefFromResource actually reads, because the SDK's local provisioner +// carried both in the profile (for display, and as a fallback — see +// clmHrefFromResource) and as a raw v2.ExternalId annotation, which is what +// clmGroupHrefFromResource actually prefers: the SDK's local provisioner // (pkg/provisioner/provisioner.go) rebuilds the principal it hands to Grant/Revoke from -// only Id/DisplayName/Annotations/Description/ExternalId/ParentResourceId, dropping the -// top-level profile. +// only Id/DisplayName/Annotations/Description/(deprecated)ExternalId/ParentResourceId, +// dropping the top-level profile — Annotations is what survives. Deliberately +// rs.WithAnnotation, not rs.WithExternalID: Resource.ExternalId itself is +// `[deprecated = true]` in the proto (SA1019) and no longer read by anything: reusing +// the ExternalId message shape as a plain annotation sidesteps that deprecated field +// while still surviving the same reconstruction. func parseIntoClmGroupResource(group *client.ClmGroup) (*v2.Resource, error) { profile := map[string]any{ "name": group.Name, @@ -239,17 +244,17 @@ func parseIntoClmGroupResource(group *client.ClmGroup) (*v2.Resource, error) { clmIDFromHref(group.Href), nil, rs.WithResourceProfile(profile), - rs.WithExternalID(&v2.ExternalId{Id: group.Href}), + rs.WithAnnotation(&v2.ExternalId{Id: group.Href}), ) } -// clmGroupHrefFromResource reads back the Href stashed in a CLM group resource's -// ExternalId (see parseIntoClmGroupResource) — needed to reference the group in a -// Members.Patch grant body. +// clmGroupHrefFromResource reads back the Href stashed in a CLM group resource (see +// parseIntoClmGroupResource and clmHrefFromResource) — needed to reference the group in +// a Members.Patch grant body. func clmGroupHrefFromResource(groupResource *v2.Resource) (string, error) { - href := groupResource.GetExternalId().GetId() - if href == "" { - return "", fmt.Errorf("baton-docusign: CLM group resource %s is missing its href external ID", groupResource.Id.Resource) + href, ok := clmHrefFromResource(groupResource) + if !ok || href == "" { + return "", fmt.Errorf("baton-docusign: CLM group resource %s is missing its href", groupResource.Id.Resource) } return href, nil } diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 247cf7ce..ea1b2c2c 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -84,11 +84,15 @@ func newClmMemberBuilder(c *client.Client) *clmMemberBuilder { } // parseIntoClmMemberResource maps a client.ClmMember to a Baton v2.Resource. The Href -// is also stashed via WithExternalID, not just the profile — the SDK's local -// provisioner (pkg/provisioner/provisioner.go) rebuilds the principal it hands to -// Grant/Revoke from only Id/DisplayName/Annotations/Description/ExternalId/ -// ParentResourceId, dropping the top-level profile. ExternalId is the field that -// actually survives to clmMemberHrefFromResource. +// is also stashed as a raw v2.ExternalId annotation, not just the profile — the SDK's +// local provisioner (pkg/provisioner/provisioner.go) rebuilds the principal it hands to +// Grant/Revoke from only Id/DisplayName/Annotations/Description/(deprecated)ExternalId/ +// ParentResourceId, dropping the top-level profile. Annotations is what survives to +// clmMemberHrefFromResource — deliberately not Resource.ExternalId itself, which is +// `[deprecated = true]` in the proto (SA1019) and no longer read by anything. Reusing +// the ExternalId message shape as a plain annotation (rs.WithAnnotation, not +// rs.WithExternalID) sidesteps that deprecated field entirely while still surviving the +// same reconstruction, since Annotations there is copied verbatim. func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) { profile := map[string]any{ profileFieldEmail: member.Email, @@ -114,6 +118,6 @@ func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) clmIDFromHref(member.Href), userTraits, rs.WithResourceProfile(profile), - rs.WithExternalID(&v2.ExternalId{Id: member.Href}), + rs.WithAnnotation(&v2.ExternalId{Id: member.Href}), ) } diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index e4532098..7229a09c 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -4,7 +4,9 @@ import ( "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -75,6 +77,22 @@ func isOptInFeatureUnavailableError(err error) bool { } } +// clmHrefFromResource reads back a CLM object's Href, preferring the raw v2.ExternalId +// annotation set at sync time (see parseIntoClmMemberResource/parseIntoClmGroupResource) +// — the one thing that survives the SDK's local provisioner rebuilding a principal for +// Grant/Revoke, since Annotations is copied verbatim there while the top-level profile +// is dropped — and falling back to the profile's "href" field for a resource the +// provisioner never rebuilds in the first place (an entitlement's own Resource, read +// directly from the store) or one synced before this annotation existed. +func clmHrefFromResource(r *v2.Resource) (string, bool) { + var ext v2.ExternalId + annos := annotations.Annotations(r.GetAnnotations()) + if ok, err := annos.Pick(&ext); err == nil && ok && ext.GetId() != "" { + return ext.GetId(), true + } + return rs.GetProfileStringValue(rs.GetProfile(r), "href") +} + // clmIDFromHref extracts the trailing path segment from a CLM object's Href — CLM's // Object API schemas expose a Href field ("Uri where the object can be retrieved") but // no separate opaque Id field, so this is the closest thing to a native ID CLM exposes. From dcef0bbb287328a9f8d4e67926088bc3d7cb7a9a Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 7 Aug 2026 14:51:52 -0300 Subject: [PATCH 03/41] fix: derive CLM group/member hrefs from ID instead of resource metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The annotation-based fix in the previous commit still had a gap the bot review caught: the pebble storage engine's V3EntitlementToV2 hydrates an Entitlement's own Resource as an identity-only stub on every read — no profile, no annotations, nothing but Id. clmGroupBuilder.Grant reads the group's href off ent.Resource, so on pebble it would always fail regardless of which resource-level mechanism carries the href, since neither survives that hydration. Fixes it at the root instead of patching around it: client.GroupHref and client.MemberHref derive the href directly from the resource's native ID plus the CLM base URL the client already resolves via discovery — the same "/v2/{account}/groups|members/{id}" shape every other CLM request already uses. This needs nothing beyond Id, which is never stripped by any reconstruction or hydration path in any storage engine, so the annotation stashed at sync time (and the profile fallback) are no longer needed at all — removed along with clmHrefFromResource/ clmMemberHrefFromResource/clmGroupHrefFromResource. Added TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource per the bot's specific suggestion, and strengthened the existing principal-reconstruction regression test into TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal, which now passes a bare identity-only resource instead of one carrying extra fields that happened to survive one specific reconstruction path. Co-Authored-By: Claude Sonnet 5 --- pkg/client/clm_client.go | 34 ++++++++++++++++ pkg/connector/clm_folders.go | 20 +++------- pkg/connector/clm_folders_test.go | 64 +++++++++++++------------------ pkg/connector/clm_groups.go | 31 ++++----------- pkg/connector/clm_groups_test.go | 42 ++++++++++++++++++++ pkg/connector/clm_members.go | 14 ++----- pkg/connector/helper.go | 18 --------- 7 files changed, 119 insertions(+), 104 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index b2bc7da7..187827df 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -106,6 +106,7 @@ const ( clmSearchFolders = "/v2/%s/folders/search" clmGetFolder = "/v2/%s/folders/%s" clmPatchFolder = "/v2/%s/folders/%s" + clmGetGroup = "/v2/%s/groups/%s" clmGetGroups = "/v2/%s/groups" clmGetGroupMembers = "/v2/%s/groups/%s/groupmembers" clmGetMembers = "/v2/%s/members" @@ -420,6 +421,39 @@ func (c *Client) ListGroups(ctx context.Context, options PageOptions) ([]ClmGrou return page.Items, nextToken, anno, nil } +// GroupHref builds a CLM group's Href from its native ID and the already-resolved CLM +// base URL — for callers that only have a group's ResourceId, not a fully-hydrated +// Resource to read a stashed Href from. Needed because a Resource carrying only +// identity (no profile, no annotations) is not an edge case here: the pebble storage +// engine's V3EntitlementToV2 (vendor/.../dotc1z/engine/pebble/translate_v2.go) +// deliberately hydrates an Entitlement's Resource as an identity-only stub, by design, +// on every read — so an entitlement-side lookup can never rely on anything beyond the +// ID surviving. Matches the same "/v2/{account}/groups/{id}" shape every other CLM +// group request already uses (clmGetGroup) and clmtest's own Server.GroupHref. +func (c *Client) GroupHref(ctx context.Context, groupID string) (string, error) { + if err := c.ensureClmReady(ctx); err != nil { + return "", err + } + groupURL, err := c.buildClmClientURL(clmGetGroup, groupID) + if err != nil { + return "", err + } + return groupURL.String(), nil +} + +// MemberHref is GroupHref's counterpart for CLM members — see its doc for why this +// derives the Href from the ID rather than reading it off a Resource. +func (c *Client) MemberHref(ctx context.Context, memberID string) (string, error) { + if err := c.ensureClmReady(ctx); err != nil { + return "", err + } + memberURL, err := c.buildClmClientURL(clmPatchPutMember, memberID) + if err != nil { + return "", err + } + return memberURL.String(), nil +} + // GetGroupMembers lists the members of a CLM group. // // Pagination: offset/limit, see package doc. diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index f3f080f7..6cd25ef6 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -200,7 +200,7 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en switch principal.Id.ResourceType { case clmGroupResourceType.Id: - groupHref, err := clmGroupHrefFromResource(principal) + groupHref, err := f.client.GroupHref(ctx, principal.Id.Resource) if err != nil { return nil, nil, err } @@ -223,7 +223,7 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en write.Roles = append(write.Roles, client.ClmRoleSecurityEntry{AccessType: accessType, Item: roleName}) } case clmMemberResourceType.Id: - memberHref, err := clmMemberHrefFromResource(principal) + memberHref, err := f.client.MemberHref(ctx, principal.Id.Resource) if err != nil { return nil, nil, err } @@ -263,7 +263,7 @@ func clmFolderSecurityToWrite(sec client.ClmFolderSecurity) client.ClmFolderSecu // clmFindGroupSecurityIndex returns the index of entries whose Href identifies // groupHref (compared via clmIDFromHref, since the read-side Href shape isn't -// guaranteed to match exactly — see clmGroupHrefFromResource), or -1 if not found. +// guaranteed to match exactly — see client.GroupHref), or -1 if not found. func clmFindGroupSecurityIndex(entries []client.ClmGroupSecurityEntry, groupHref string) int { for i, e := range entries { if clmIDFromHref(e.Href) == clmIDFromHref(groupHref) { @@ -311,7 +311,7 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno switch principal.Id.ResourceType { case clmGroupResourceType.Id: - groupHref, err := clmGroupHrefFromResource(principal) + groupHref, err := f.client.GroupHref(ctx, principal.Id.Resource) if err != nil { return nil, err } @@ -328,7 +328,7 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno } write.Roles[i].AccessType = client.ClmAccessTypeNoAccess case clmMemberResourceType.Id: - memberHref, err := clmMemberHrefFromResource(principal) + memberHref, err := f.client.MemberHref(ctx, principal.Id.Resource) if err != nil { return nil, err } @@ -402,13 +402,3 @@ func clmIsKnownRole(name string) bool { return false } -// clmMemberHrefFromResource reads back the Href stashed in a CLM member resource (see -// parseIntoClmMemberResource and clmHrefFromResource) — needed to reference the member -// in a folder-security grant body. -func clmMemberHrefFromResource(principal *v2.Resource) (string, error) { - href, ok := clmHrefFromResource(principal) - if !ok || href == "" { - return "", fmt.Errorf("baton-docusign: CLM member resource %s is missing its href", principal.Id.Resource) - } - return href, nil -} diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 79e2f554..896c31b1 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -422,35 +422,31 @@ func TestClmFolderBuilder_GrantAndRevoke_ToleratesBareIDOnRead(t *testing.T) { } } -// clmSimulateProvisionerPrincipal rebuilds r the way the SDK's local file-mode -// provisioner does before handing a principal to Grant/Revoke (see -// vendor/github.com/conductorone/baton-sdk/pkg/provisioner/provisioner.go's grant/revoke -// functions): only Id, DisplayName, Annotations, Description, and ParentResourceId -// survive — notably NOT the top-level Profile, which is where this connector used to -// stash the CLM href before the fix TestClmFolderBuilder_GrantAndRevoke_ -// SurvivesProvisionerPrincipalReconstruction regression-guards. Deliberately excludes -// ExternalId even though the real provisioner does still copy it: the SDK marks that -// field `[deprecated = true]` and this connector no longer relies on it (see -// parseIntoClmMemberResource/parseIntoClmGroupResource). -func clmSimulateProvisionerPrincipal(r *v2.Resource) *v2.Resource { +// clmIdentityOnlyResource builds a Resource carrying nothing but its Id — the worst +// case any code path in this connector can hand Grant/Revoke a principal in: it's what +// the SDK's local file-mode provisioner effectively reduces a principal to once you +// strip the fields no longer relied on (see the previous commit), and it's exactly what +// the pebble storage engine's V3EntitlementToV2 (vendor/.../dotc1z/engine/pebble/ +// translate_v2.go) hydrates an Entitlement's own Resource as on every read, by design, +// regardless of provisioning path. clmMemberHrefFromResource/clmGroupHrefFromResource +// no longer exist — Grant/Revoke derive the Href straight from resourceID via +// client.GroupHref/client.MemberHref, so nothing beyond Id is ever needed. +func clmIdentityOnlyResource(resourceType *v2.ResourceType, resourceID string) *v2.Resource { return &v2.Resource{ - Id: r.Id, - DisplayName: r.DisplayName, - Annotations: r.Annotations, - Description: r.Description, - ParentResourceId: r.ParentResourceId, + Id: &v2.ResourceId{ResourceType: resourceType.Id, Resource: resourceID}, } } -// TestClmFolderBuilder_GrantAndRevoke_SurvivesProvisionerPrincipalReconstruction is a -// regression test for the bug fixed by carrying the CLM href as a v2.ExternalId -// annotation instead of only the top-level profile. Every other Grant/Revoke test in -// this file passes the fully-populated resource parseIntoClm*Resource returns, which -// still has both profile and the annotation — so it would pass whether or not the fix -// actually works. This test instead passes a principal reconstructed the way the real -// offline/local provisioning path (baton-sdk's Provisioner) actually delivers it — see -// clmSimulateProvisionerPrincipal — to confirm the Href is still recoverable. -func TestClmFolderBuilder_GrantAndRevoke_SurvivesProvisionerPrincipalReconstruction(t *testing.T) { +// TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal is a regression test +// for the bug originally fixed by carrying the CLM href on the resource (first via +// ExternalId, then an annotation) and, after the pebble-engine gap the annotation +// approach missed, fixed properly by deriving the Href from the ID instead. Every other +// Grant/Revoke test in this file passes the fully-populated resource +// parseIntoClm*Resource returns — so it would pass whether or not either fix actually +// worked. This test instead passes an identity-only principal — see +// clmIdentityOnlyResource — to confirm the Href is still derivable with nothing else on +// the resource to fall back to. +func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testing.T) { srv, c := clmtest.NewServer(t) b := newClmFolderBuilder(c) ctx := context.Background() @@ -461,15 +457,11 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesProvisionerPrincipalReconstruct } t.Run("clm_group principal", func(t *testing.T) { - full, err := parseIntoClmGroupResource(&client.ClmGroup{Name: "Operations", Href: srv.GroupHref("group-ops")}) - if err != nil { - t.Fatalf("parseIntoClmGroupResource: %v", err) - } - principal := clmSimulateProvisionerPrincipal(full) + principal := clmIdentityOnlyResource(clmGroupResourceType, "group-ops") ent := &v2.Entitlement{Slug: "view", Resource: folderResource} if _, _, err := b.Grant(ctx, principal, ent); err != nil { - t.Fatalf("Grant with a provisioner-reconstructed principal: %v", err) + t.Fatalf("Grant with an identity-only principal: %v", err) } groups := srv.FolderSecurity("folder-templates").Groups.Items if len(groups) != 1 || groups[0].AccessType != client.ClmAccessTypeView { @@ -478,20 +470,16 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesProvisionerPrincipalReconstruct grantObj := &v2.Grant{Principal: principal, Entitlement: ent} if _, err := b.Revoke(ctx, grantObj); err != nil { - t.Fatalf("Revoke with a provisioner-reconstructed principal: %v", err) + t.Fatalf("Revoke with an identity-only principal: %v", err) } }) t.Run("clm_member principal", func(t *testing.T) { - full, err := parseIntoClmMemberResource(&client.ClmMember{Email: "dave@example.com", UserName: "dave", Href: srv.MemberHref("member-dave")}) - if err != nil { - t.Fatalf("parseIntoClmMemberResource: %v", err) - } - principal := clmSimulateProvisionerPrincipal(full) + principal := clmIdentityOnlyResource(clmMemberResourceType, "member-dave") ent := &v2.Entitlement{Slug: "view", Resource: folderResource} if _, _, err := b.Grant(ctx, principal, ent); err != nil { - t.Fatalf("Grant with a provisioner-reconstructed principal: %v", err) + t.Fatalf("Grant with an identity-only principal: %v", err) } users := srv.FolderSecurity("folder-templates").Users.Items if len(users) != 1 || users[0].AccessType != client.ClmAccessTypeView { diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 9366426a..3c356b9a 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -150,7 +150,10 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent memberID := principal.Id.Resource groupID := ent.Resource.Id.Resource - groupHref, err := clmGroupHrefFromResource(ent.Resource) + // Derive the group's Href from its ID rather than reading it off ent.Resource: the + // pebble storage engine hydrates an entitlement's Resource as an identity-only stub + // (no profile, no annotations) — see client.GroupHref's doc. + groupHref, err := g.client.GroupHref(ctx, groupID) if err != nil { return nil, nil, err } @@ -221,16 +224,10 @@ func newClmGroupBuilder(c *client.Client) *clmGroupBuilder { } // parseIntoClmGroupResource maps a client.ClmGroup to a Baton v2.Resource. The Href is -// carried both in the profile (for display, and as a fallback — see -// clmHrefFromResource) and as a raw v2.ExternalId annotation, which is what -// clmGroupHrefFromResource actually prefers: the SDK's local provisioner -// (pkg/provisioner/provisioner.go) rebuilds the principal it hands to Grant/Revoke from -// only Id/DisplayName/Annotations/Description/(deprecated)ExternalId/ParentResourceId, -// dropping the top-level profile — Annotations is what survives. Deliberately -// rs.WithAnnotation, not rs.WithExternalID: Resource.ExternalId itself is -// `[deprecated = true]` in the proto (SA1019) and no longer read by anything: reusing -// the ExternalId message shape as a plain annotation sidesteps that deprecated field -// while still surviving the same reconstruction. +// kept in the profile for display only — Grant/Revoke derive it directly from the +// group's ID via client.GroupHref instead of reading it off the resource, since neither +// a top-level profile nor an annotation is guaranteed to survive to where it's needed +// (see client.GroupHref's doc for why). func parseIntoClmGroupResource(group *client.ClmGroup) (*v2.Resource, error) { profile := map[string]any{ "name": group.Name, @@ -244,17 +241,5 @@ func parseIntoClmGroupResource(group *client.ClmGroup) (*v2.Resource, error) { clmIDFromHref(group.Href), nil, rs.WithResourceProfile(profile), - rs.WithAnnotation(&v2.ExternalId{Id: group.Href}), ) } - -// clmGroupHrefFromResource reads back the Href stashed in a CLM group resource (see -// parseIntoClmGroupResource and clmHrefFromResource) — needed to reference the group in -// a Members.Patch grant body. -func clmGroupHrefFromResource(groupResource *v2.Resource) (string, error) { - href, ok := clmHrefFromResource(groupResource) - if !ok || href == "" { - return "", fmt.Errorf("baton-docusign: CLM group resource %s is missing its href", groupResource.Id.Resource) - } - return href, nil -} diff --git a/pkg/connector/clm_groups_test.go b/pkg/connector/clm_groups_test.go index 02e9dbd3..0d378430 100644 --- a/pkg/connector/clm_groups_test.go +++ b/pkg/connector/clm_groups_test.go @@ -176,6 +176,48 @@ func TestClmGroupBuilder_GrantAndRevoke_Idempotent(t *testing.T) { } } +// TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource is a regression +// test for a gap the annotation-based fix in the previous commit missed: the pebble +// storage engine's V3EntitlementToV2 (vendor/.../dotc1z/engine/pebble/translate_v2.go) +// deliberately hydrates an Entitlement's Resource as an identity-only stub on every +// read — no profile, no annotations, nothing but Id. clmGroupBuilder.Grant previously +// read the group's Href off ent.Resource; on pebble that would always fail, in every +// version of this connector including the one before this test existed. It now derives +// the Href straight from ent.Resource.Id.Resource via client.GroupHref instead, which +// needs nothing else to survive. +func TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource(t *testing.T) { + srv, c := clmtest.NewServer(t) + b := newClmGroupBuilder(c) + ctx := context.Background() + + memberResource, err := rs.NewResource("Carol", clmMemberResourceType, "member-carol") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + // An identity-only Resource — exactly what V3EntitlementToV2 hands back on pebble, + // carrying nothing but the group's ID. + identityOnlyGroupResource := &v2.Resource{ + Id: &v2.ResourceId{ResourceType: clmGroupResourceType.Id, Resource: "group-legal"}, + } + ent := &v2.Entitlement{Slug: entitlementClmGroupMember, Resource: identityOnlyGroupResource} + + if _, annos, err := b.Grant(ctx, memberResource, ent); err != nil { + t.Fatalf("Grant with an identity-only ent.Resource: %v", err) + } else if hasAlreadyExists(annos) { + t.Error("first Grant should not report GrantAlreadyExists") + } + groups := srv.MemberGroups("member-carol") + found := false + for _, g := range groups { + if g == "group-legal" { + found = true + } + } + if !found { + t.Fatalf("expected carol to be granted group-legal, got %v", groups) + } +} + // TestClmGroupMemberSlugRegressionPin guards against an accidental rename of // the "member" entitlement slug: since clm_group is a new, unreleased // resource type, add this before the first release locks the slug in. diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index ea1b2c2c..9af1b54a 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -84,15 +84,10 @@ func newClmMemberBuilder(c *client.Client) *clmMemberBuilder { } // parseIntoClmMemberResource maps a client.ClmMember to a Baton v2.Resource. The Href -// is also stashed as a raw v2.ExternalId annotation, not just the profile — the SDK's -// local provisioner (pkg/provisioner/provisioner.go) rebuilds the principal it hands to -// Grant/Revoke from only Id/DisplayName/Annotations/Description/(deprecated)ExternalId/ -// ParentResourceId, dropping the top-level profile. Annotations is what survives to -// clmMemberHrefFromResource — deliberately not Resource.ExternalId itself, which is -// `[deprecated = true]` in the proto (SA1019) and no longer read by anything. Reusing -// the ExternalId message shape as a plain annotation (rs.WithAnnotation, not -// rs.WithExternalID) sidesteps that deprecated field entirely while still surviving the -// same reconstruction, since Annotations there is copied verbatim. +// is kept in the profile for display only — Grant/Revoke derive it directly from the +// member's ID via client.MemberHref instead of reading it off the resource, since +// neither a top-level profile nor an annotation is guaranteed to survive to where it's +// needed (see client.GroupHref's doc, MemberHref's counterpart, for why). func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) { profile := map[string]any{ profileFieldEmail: member.Email, @@ -118,6 +113,5 @@ func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) clmIDFromHref(member.Href), userTraits, rs.WithResourceProfile(profile), - rs.WithAnnotation(&v2.ExternalId{Id: member.Href}), ) } diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 7229a09c..e4532098 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -4,9 +4,7 @@ import ( "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" - "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" - rs "github.com/conductorone/baton-sdk/pkg/types/resource" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -77,22 +75,6 @@ func isOptInFeatureUnavailableError(err error) bool { } } -// clmHrefFromResource reads back a CLM object's Href, preferring the raw v2.ExternalId -// annotation set at sync time (see parseIntoClmMemberResource/parseIntoClmGroupResource) -// — the one thing that survives the SDK's local provisioner rebuilding a principal for -// Grant/Revoke, since Annotations is copied verbatim there while the top-level profile -// is dropped — and falling back to the profile's "href" field for a resource the -// provisioner never rebuilds in the first place (an entitlement's own Resource, read -// directly from the store) or one synced before this annotation existed. -func clmHrefFromResource(r *v2.Resource) (string, bool) { - var ext v2.ExternalId - annos := annotations.Annotations(r.GetAnnotations()) - if ok, err := annos.Pick(&ext); err == nil && ok && ext.GetId() != "" { - return ext.GetId(), true - } - return rs.GetProfileStringValue(rs.GetProfile(r), "href") -} - // clmIDFromHref extracts the trailing path segment from a CLM object's Href — CLM's // Object API schemas expose a Href field ("Uri where the object can be retrieved") but // no separate opaque Id field, so this is the closest thing to a native ID CLM exposes. From 59dba8aa529ce155829fda8e5af2f59132edae9f Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 13:07:54 -0300 Subject: [PATCH 04/41] fix: prefer real server-issued hrefs over derived ones on writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses current bot review feedback on PR #63: - clmGroupBuilder.Grant and clmFolderBuilder.Grant now prefer a real, server-issued Href already on hand (another of the member's current groups; another security entry the same folder already carries) over one derived from the discovered CLM base URL, via new clmPreferredHref/ clmHrefWithID helpers. Read-side comparisons were always safe (they only ever look at the trailing ID via clmIDFromHref), but a WRITE carries the full href, and if the discovered base URL host ever differs from what CLM's own Href values actually use — unconfirmed, no live tenant available — that write could carry a href CLM rejects or stores inconsistently. Falls back to the derived form when no real sample is available (e.g. a member with no other groups yet). - Fixed a stale failure message in TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal left over from an earlier rename. Other bot comments on this PR (deprecated ExternalId, profile fallback, missing regression test, pebble-engine entitlement stub) are stale — they describe code from the PR's first two commits, before it was rewritten twice over the following review rounds to derive hrefs from ID instead of any resource-carried data at all. GitHub re-anchored their commit references after the stack rebase, but their content predates both rewrites. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_folders.go | 21 +++++++++-- pkg/connector/clm_folders_test.go | 2 +- pkg/connector/clm_groups.go | 24 ++++++++---- pkg/connector/helper.go | 35 ++++++++++++++++++ pkg/connector/helper_test.go | 61 +++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 11 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 6cd25ef6..016a0cf2 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -200,7 +200,16 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en switch principal.Id.ResourceType { case clmGroupResourceType.Id: - groupHref, err := f.client.GroupHref(ctx, principal.Id.Resource) + // Prefer a real, server-issued Href already on hand (any OTHER group-security + // entry this folder already carries) over one derived from the discovered CLM + // base URL — see clmPreferredHref's doc. + groupSampleHrefs := make([]string, len(write.Groups)) + for i, entry := range write.Groups { + groupSampleHrefs[i] = entry.Href + } + groupHref, err := clmPreferredHref(principal.Id.Resource, groupSampleHrefs, func() (string, error) { + return f.client.GroupHref(ctx, principal.Id.Resource) + }) if err != nil { return nil, nil, err } @@ -223,7 +232,14 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en write.Roles = append(write.Roles, client.ClmRoleSecurityEntry{AccessType: accessType, Item: roleName}) } case clmMemberResourceType.Id: - memberHref, err := f.client.MemberHref(ctx, principal.Id.Resource) + // Same rationale as the group case above. + userSampleHrefs := make([]string, len(write.Users)) + for i, entry := range write.Users { + userSampleHrefs[i] = entry.Href + } + memberHref, err := clmPreferredHref(principal.Id.Resource, userSampleHrefs, func() (string, error) { + return f.client.MemberHref(ctx, principal.Id.Resource) + }) if err != nil { return nil, nil, err } @@ -401,4 +417,3 @@ func clmIsKnownRole(name string) bool { } return false } - diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 896c31b1..d4401c94 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -488,7 +488,7 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin grantObj := &v2.Grant{Principal: principal, Entitlement: ent} if _, err := b.Revoke(ctx, grantObj); err != nil { - t.Fatalf("Revoke with a provisioner-reconstructed principal: %v", err) + t.Fatalf("Revoke with an identity-only principal: %v", err) } }) } diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 3c356b9a..0680648a 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -150,14 +150,10 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent memberID := principal.Id.Resource groupID := ent.Resource.Id.Resource - // Derive the group's Href from its ID rather than reading it off ent.Resource: the - // pebble storage engine hydrates an entitlement's Resource as an identity-only stub - // (no profile, no annotations) — see client.GroupHref's doc. - groupHref, err := g.client.GroupHref(ctx, groupID) - if err != nil { - return nil, nil, err - } + // Don't read the group's Href off ent.Resource: the pebble storage engine hydrates + // an entitlement's Resource as an identity-only stub (no profile, no annotations) — + // see client.GroupHref's doc. currentGroups, annos, err := g.client.GetMemberGroups(ctx, memberID) if err != nil { return nil, annos, fmt.Errorf("getting current groups for CLM member %s: %w", memberID, err) @@ -170,6 +166,20 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent } } + // Prefer a real, server-issued Href already on hand (any of this member's OTHER + // current groups) over one derived from the discovered CLM base URL — see + // clmPreferredHref's doc. + sampleHrefs := make([]string, len(currentGroups)) + for i, current := range currentGroups { + sampleHrefs[i] = current.Href + } + groupHref, err := clmPreferredHref(groupID, sampleHrefs, func() (string, error) { + return g.client.GroupHref(ctx, groupID) + }) + if err != nil { + return nil, nil, err + } + newGroups := make([]client.ClmGroup, 0, len(currentGroups)+1) newGroups = append(newGroups, currentGroups...) newGroups = append(newGroups, client.ClmGroup{Href: groupHref}) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index e4532098..9415e9a5 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -1,6 +1,7 @@ package connector import ( + "fmt" "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" @@ -85,3 +86,37 @@ func clmIDFromHref(href string) string { } return href } + +// clmHrefWithID rebuilds sampleHref with its trailing ID segment replaced by newID — +// used to derive a sibling object's Href from a known-real one, instead of guessing at +// the host from the discovered CLM base URL, whenever a real sample happens to be +// available. See clmPreferredHref for why this matters specifically for writes. +func clmHrefWithID(sampleHref, newID string) (string, error) { + trimmed := strings.TrimSuffix(sampleHref, "/") + idx := strings.LastIndex(trimmed, "/") + if idx == -1 { + return "", fmt.Errorf("baton-docusign: cannot derive a sibling href from %q — no path separator found", sampleHref) + } + return trimmed[:idx+1] + newID, nil +} + +// clmPreferredHref resolves the href to send in a WRITE targeting id: it prefers +// deriving the href from a real, server-issued sample href (the first non-empty entry +// in sampleHrefs) over guessing at the host from the discovered CLM base URL. A write +// that carries a subtly-wrong host — if the discovered base URL ever differs from what +// CLM's own Href values actually use, since no live tenant confirmed this — risks CLM +// rejecting it or storing it inconsistently; a read-side comparison doesn't have this +// risk, since clmIDFromHref only ever looks at the trailing ID. Falls back to +// deriveFallback when no real sample href is available (e.g. a member with no other +// group memberships yet, or a folder with no other security entries yet). +func clmPreferredHref(id string, sampleHrefs []string, deriveFallback func() (string, error)) (string, error) { + for _, sample := range sampleHrefs { + if sample == "" { + continue + } + if derived, err := clmHrefWithID(sample, id); err == nil { + return derived, nil + } + } + return deriveFallback() +} diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index 034ed990..51c318b1 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -32,3 +32,64 @@ func TestIsOptInFeatureUnavailableError(t *testing.T) { }) } } + +func TestClmHrefWithID(t *testing.T) { + got, err := clmHrefWithID("https://clm.example.com/v2/acct-1/groups/group-old", "group-new") + if err != nil { + t.Fatalf("clmHrefWithID: %v", err) + } + if want := "https://clm.example.com/v2/acct-1/groups/group-new"; got != want { + t.Errorf("clmHrefWithID = %q, want %q", got, want) + } + + if _, err := clmHrefWithID("no-path-separator", "x"); err == nil { + t.Error("expected an error for a sample href with no path separator") + } +} + +func TestClmPreferredHref(t *testing.T) { + fallbackCalled := false + fallback := func() (string, error) { + fallbackCalled = true + return "https://derived.example.com/v2/acct-1/groups/group-target", nil + } + + t.Run("prefers a real sample href over the fallback", func(t *testing.T) { + fallbackCalled = false + got, err := clmPreferredHref("group-target", []string{"https://real.example.com/v2/acct-1/groups/group-other"}, fallback) + if err != nil { + t.Fatalf("clmPreferredHref: %v", err) + } + if want := "https://real.example.com/v2/acct-1/groups/group-target"; got != want { + t.Errorf("clmPreferredHref = %q, want %q", got, want) + } + if fallbackCalled { + t.Error("expected the fallback NOT to be called when a real sample href is available") + } + }) + + t.Run("skips empty sample hrefs", func(t *testing.T) { + fallbackCalled = false + got, err := clmPreferredHref("group-target", []string{"", "https://real.example.com/v2/acct-1/groups/group-other"}, fallback) + if err != nil { + t.Fatalf("clmPreferredHref: %v", err) + } + if want := "https://real.example.com/v2/acct-1/groups/group-target"; got != want { + t.Errorf("clmPreferredHref = %q, want %q", got, want) + } + }) + + t.Run("falls back when no sample href is available", func(t *testing.T) { + fallbackCalled = false + got, err := clmPreferredHref("group-target", nil, fallback) + if err != nil { + t.Fatalf("clmPreferredHref: %v", err) + } + if want := "https://derived.example.com/v2/acct-1/groups/group-target"; got != want { + t.Errorf("clmPreferredHref = %q, want %q", got, want) + } + if !fallbackCalled { + t.Error("expected the fallback to be called when no sample hrefs are available") + } + }) +} From 43d07cf194e0a4d90e3440a5b9bc2f60ad4bc078 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 14:01:41 -0300 Subject: [PATCH 05/41] fix: address deep-code-review findings on PR #63 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- pkg/connector/clm_folders_test.go | 4 ++-- pkg/connector/helper.go | 19 ++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index d4401c94..6b6ae6b2 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -368,7 +368,7 @@ func assertUsersPreserved(t *testing.T, before, after []client.ClmUserSecurityEn // TestClmFolderBuilder_GrantAndRevoke_ToleratesBareIDOnRead is a regression test: the // CLM API's read-side Href representation isn't confirmed to always match the exact -// Href shape clmGroupHrefFromResource constructs on the write side. Grant/Revoke's +// Href shape client.GroupHref constructs on the write side. Grant/Revoke's // existence checks compare via clmIDFromHref (not raw equality) specifically so a bare // ID and a full Href ending in that ID are still treated as the same principal — if // the API ever returns Href as a bare ID, a raw-equality comparison would never match, @@ -382,7 +382,7 @@ func TestClmFolderBuilder_GrantAndRevoke_ToleratesBareIDOnRead(t *testing.T) { ctx := context.Background() // Seed folder-templates' group Security entry with a bare group ID, not the full - // Href clmGroupHrefFromResource would construct. + // Href client.GroupHref would construct. if _, err := c.PatchFolderSecurity(ctx, "folder-templates", client.ClmFolderSecurityWrite{ Groups: []client.ClmGroupSecurityEntry{{AccessType: client.ClmAccessTypeViewEdit, Href: "group-ops"}}, }); err != nil { diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 9415e9a5..7c120a31 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -101,19 +101,16 @@ func clmHrefWithID(sampleHref, newID string) (string, error) { } // clmPreferredHref resolves the href to send in a WRITE targeting id: it prefers -// deriving the href from a real, server-issued sample href (the first non-empty entry -// in sampleHrefs) over guessing at the host from the discovered CLM base URL. A write -// that carries a subtly-wrong host — if the discovered base URL ever differs from what -// CLM's own Href values actually use, since no live tenant confirmed this — risks CLM -// rejecting it or storing it inconsistently; a read-side comparison doesn't have this -// risk, since clmIDFromHref only ever looks at the trailing ID. Falls back to -// deriveFallback when no real sample href is available (e.g. a member with no other -// group memberships yet, or a folder with no other security entries yet). +// deriving the href from a real, server-issued sample href (the first entry in +// sampleHrefs that clmHrefWithID can use) over guessing at the host from the discovered +// CLM base URL. CLM's Href host is not guaranteed to match the discovered base URL — a +// write that carries a subtly-wrong host risks CLM rejecting it or storing it +// inconsistently; a read-side comparison doesn't have this risk, since clmIDFromHref +// only ever looks at the trailing ID. Falls back to deriveFallback when no real sample +// href is available (e.g. a member with no other group memberships yet, or a folder +// with no other security entries yet). func clmPreferredHref(id string, sampleHrefs []string, deriveFallback func() (string, error)) (string, error) { for _, sample := range sampleHrefs { - if sample == "" { - continue - } if derived, err := clmHrefWithID(sample, id); err == nil { return derived, nil } From 6e7b5b5a4e3415bcbf0040c83bd5958297ac8cc8 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 15:47:50 -0300 Subject: [PATCH 06/41] fix: address latest bot review findings on PR #63 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- pkg/connector/clm_folders.go | 17 +++--- pkg/connector/clm_folders_test.go | 18 +++++-- pkg/connector/clm_groups.go | 5 +- pkg/connector/clm_groups_test.go | 89 ++++++++++++++++++++++--------- 4 files changed, 88 insertions(+), 41 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 016a0cf2..b7d2dc0e 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -327,11 +327,10 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno switch principal.Id.ResourceType { case clmGroupResourceType.Id: - groupHref, err := f.client.GroupHref(ctx, principal.Id.Resource) - if err != nil { - return nil, err - } - i := clmFindGroupSecurityIndex(write.Groups, groupHref) + // No need to build a real Href via client.GroupHref here: clmFindGroupSecurityIndex + // only ever compares by clmIDFromHref, so principal.Id.Resource (already a bare ID) + // works directly and this skips a needless ensureClmReady round trip. + i := clmFindGroupSecurityIndex(write.Groups, principal.Id.Resource) if i < 0 || write.Groups[i].AccessType == client.ClmAccessTypeNoAccess { return annotations.New(&v2.GrantAlreadyRevoked{}), nil } @@ -344,11 +343,9 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno } write.Roles[i].AccessType = client.ClmAccessTypeNoAccess case clmMemberResourceType.Id: - memberHref, err := f.client.MemberHref(ctx, principal.Id.Resource) - if err != nil { - return nil, err - } - i := clmFindUserSecurityIndex(write.Users, memberHref) + // Same reasoning as the group case above: clmFindUserSecurityIndex only compares + // by clmIDFromHref, so no need to build a real Href via client.MemberHref. + i := clmFindUserSecurityIndex(write.Users, principal.Id.Resource) if i < 0 || write.Users[i].AccessType == client.ClmAccessTypeNoAccess { return annotations.New(&v2.GrantAlreadyRevoked{}), nil } diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 6b6ae6b2..a476b0a4 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -428,9 +428,11 @@ func TestClmFolderBuilder_GrantAndRevoke_ToleratesBareIDOnRead(t *testing.T) { // strip the fields no longer relied on (see the previous commit), and it's exactly what // the pebble storage engine's V3EntitlementToV2 (vendor/.../dotc1z/engine/pebble/ // translate_v2.go) hydrates an Entitlement's own Resource as on every read, by design, -// regardless of provisioning path. clmMemberHrefFromResource/clmGroupHrefFromResource -// no longer exist — Grant/Revoke derive the Href straight from resourceID via -// client.GroupHref/client.MemberHref, so nothing beyond Id is ever needed. +// regardless of provisioning path. clmMemberHrefFromResource/clmGroupHrefFromResource no +// longer exist — Grant prefers a real, server-issued sample Href already on hand via +// clmPreferredHref, falling back to client.GroupHref/client.MemberHref only when none is +// available; Revoke needs nothing beyond Id, since clmFindGroupSecurityIndex/ +// clmFindUserSecurityIndex compare by clmIDFromHref. func clmIdentityOnlyResource(resourceType *v2.ResourceType, resourceID string) *v2.Resource { return &v2.Resource{ Id: &v2.ResourceId{ResourceType: resourceType.Id, Resource: resourceID}, @@ -467,6 +469,11 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin if len(groups) != 1 || groups[0].AccessType != client.ClmAccessTypeView { t.Fatalf("expected one View group entry after Grant, got %+v", groups) } + // folder-templates starts with no group entries (clmPreferredHref's fallback + // branch), so this is the specific Href client.GroupHref must have derived. + if want := srv.GroupHref("group-ops"); groups[0].Href != want { + t.Errorf("expected Grant to write Href %q, got %q", want, groups[0].Href) + } grantObj := &v2.Grant{Principal: principal, Entitlement: ent} if _, err := b.Revoke(ctx, grantObj); err != nil { @@ -485,6 +492,11 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin if len(users) != 1 || users[0].AccessType != client.ClmAccessTypeView { t.Fatalf("expected one View user entry after Grant, got %+v", users) } + // folder-templates starts with no user entries (clmPreferredHref's fallback + // branch), so this is the specific Href client.MemberHref must have derived. + if want := srv.MemberHref("member-dave"); users[0].Href != want { + t.Errorf("expected Grant to write Href %q, got %q", want, users[0].Href) + } grantObj := &v2.Grant{Principal: principal, Entitlement: ent} if _, err := b.Revoke(ctx, grantObj); err != nil { diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 0680648a..b33ff955 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -152,8 +152,9 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent groupID := ent.Resource.Id.Resource // Don't read the group's Href off ent.Resource: the pebble storage engine hydrates - // an entitlement's Resource as an identity-only stub (no profile, no annotations) — - // see client.GroupHref's doc. + // an entitlement's Resource as an identity-only stub (no profile, no annotations). + // The groupHref this Grant actually writes below is resolved via clmPreferredHref — + // a real sample Href from currentGroups if one exists, else client.GroupHref. currentGroups, annos, err := g.client.GetMemberGroups(ctx, memberID) if err != nil { return nil, annos, fmt.Errorf("getting current groups for CLM member %s: %w", memberID, err) diff --git a/pkg/connector/clm_groups_test.go b/pkg/connector/clm_groups_test.go index 0d378430..44b32227 100644 --- a/pkg/connector/clm_groups_test.go +++ b/pkg/connector/clm_groups_test.go @@ -177,23 +177,17 @@ func TestClmGroupBuilder_GrantAndRevoke_Idempotent(t *testing.T) { } // TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource is a regression -// test for a gap the annotation-based fix in the previous commit missed: the pebble +// test for a gap the annotation-based fix in an earlier commit missed: the pebble // storage engine's V3EntitlementToV2 (vendor/.../dotc1z/engine/pebble/translate_v2.go) // deliberately hydrates an Entitlement's Resource as an identity-only stub on every // read — no profile, no annotations, nothing but Id. clmGroupBuilder.Grant previously // read the group's Href off ent.Resource; on pebble that would always fail, in every -// version of this connector including the one before this test existed. It now derives -// the Href straight from ent.Resource.Id.Resource via client.GroupHref instead, which -// needs nothing else to survive. +// version of this connector including the one before this test existed. It now +// resolves the groupHref to write via clmPreferredHref — nothing on ent.Resource itself +// is ever needed, whether that resolves from a real sample Href already on hand (the +// member's other current groups) or, absent one, client.GroupHref's ID-only fallback. +// The two subtests below exercise both of clmPreferredHref's branches. func TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource(t *testing.T) { - srv, c := clmtest.NewServer(t) - b := newClmGroupBuilder(c) - ctx := context.Background() - - memberResource, err := rs.NewResource("Carol", clmMemberResourceType, "member-carol") - if err != nil { - t.Fatalf("NewResource: %v", err) - } // An identity-only Resource — exactly what V3EntitlementToV2 hands back on pebble, // carrying nothing but the group's ID. identityOnlyGroupResource := &v2.Resource{ @@ -201,21 +195,64 @@ func TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource(t *testin } ent := &v2.Entitlement{Slug: entitlementClmGroupMember, Resource: identityOnlyGroupResource} - if _, annos, err := b.Grant(ctx, memberResource, ent); err != nil { - t.Fatalf("Grant with an identity-only ent.Resource: %v", err) - } else if hasAlreadyExists(annos) { - t.Error("first Grant should not report GrantAlreadyExists") - } - groups := srv.MemberGroups("member-carol") - found := false - for _, g := range groups { - if g == "group-legal" { - found = true + t.Run("fallback branch: member has no other groups, derives via client.GroupHref", func(t *testing.T) { + srv, c := clmtest.NewServer(t) + b := newClmGroupBuilder(c) + ctx := context.Background() + + // member-dave is seeded with zero groups (clmtest/seed.go), so currentGroups is + // empty and clmPreferredHref has no sample to derive from. + memberResource, err := rs.NewResource("Dave", clmMemberResourceType, "member-dave") + if err != nil { + t.Fatalf("NewResource: %v", err) } - } - if !found { - t.Fatalf("expected carol to be granted group-legal, got %v", groups) - } + + if _, annos, err := b.Grant(ctx, memberResource, ent); err != nil { + t.Fatalf("Grant with an identity-only ent.Resource: %v", err) + } else if hasAlreadyExists(annos) { + t.Error("first Grant should not report GrantAlreadyExists") + } + groups := srv.MemberGroups("member-dave") + found := false + for _, g := range groups { + if g == "group-legal" { + found = true + } + } + if !found { + t.Fatalf("expected dave to be granted group-legal, got %v", groups) + } + }) + + t.Run("sample branch: member already has another group, derives via clmPreferredHref's sample", func(t *testing.T) { + srv, c := clmtest.NewServer(t) + b := newClmGroupBuilder(c) + ctx := context.Background() + + // member-carol is seeded into group-finance (clmtest/seed.go), giving + // clmPreferredHref a real sample Href to derive group-legal's Href from instead + // of falling back to client.GroupHref. + memberResource, err := rs.NewResource("Carol", clmMemberResourceType, "member-carol") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + + if _, annos, err := b.Grant(ctx, memberResource, ent); err != nil { + t.Fatalf("Grant with an identity-only ent.Resource: %v", err) + } else if hasAlreadyExists(annos) { + t.Error("first Grant should not report GrantAlreadyExists") + } + groups := srv.MemberGroups("member-carol") + found := false + for _, g := range groups { + if g == "group-legal" { + found = true + } + } + if !found { + t.Fatalf("expected carol to be granted group-legal, got %v", groups) + } + }) } // TestClmGroupMemberSlugRegressionPin guards against an accidental rename of From d544828dd1802d9b59d9b626296f4a9257154ea9 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 16:22:37 -0300 Subject: [PATCH 07/41] doc: confirm clmHrefWithID's path-prefix reuse against clm_models.go Flagged twice across review rounds as an unconfirmed assumption. It's actually already confirmed by this codebase's own model docs: every sample source clmPreferredHref is called with (folder-security Groups/Users entries, a member's current-groups list) is documented in clm_models.go as sharing its respective object's own native Href shape, and no call site ever mixes samples across collection types. Cite that instead of leaving this looking like an open risk. --- pkg/connector/helper.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 7c120a31..66799b19 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -91,6 +91,18 @@ func clmIDFromHref(href string) string { // used to derive a sibling object's Href from a known-real one, instead of guessing at // the host from the discovered CLM base URL, whenever a real sample happens to be // available. See clmPreferredHref for why this matters specifically for writes. +// +// Reuses sampleHref's entire path, not just scheme+host, on the assumption that +// same-collection Hrefs share the same path shape and only the trailing ID segment +// differs. This is confirmed for every sample source this helper is actually called +// with: ClmGroupSecurityEntry.Href and ClmUserSecurityEntry.Href are each documented in +// clm_models.go as carrying the full Group/Member object's own native Href (not some +// folder-scoped nested path), and ClmGroupPage's doc confirms a member's-current-groups +// response (GetMemberGroups) shares the identical ClmGroup/Href shape as the top-level +// groups-list endpoint. Every clmPreferredHref call site in this codebase also only +// ever draws samples from one single collection at a time (e.g. groupSampleHrefs from +// write.Groups alone), so a same-type sample is never a hidden assumption to re-verify +// here — it's structurally guaranteed by the callers. func clmHrefWithID(sampleHref, newID string) (string, error) { trimmed := strings.TrimSuffix(sampleHref, "/") idx := strings.LastIndex(trimmed, "/") From 08f151d543044d52a7c7a7b93ff5a2c950014ca7 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 21:08:27 -0300 Subject: [PATCH 08/41] doc: confirm isOptInFeatureUnavailableError's NotFound rationale against official docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocuSign's CLM API docs (Response and Error Codes) confirm 404 is the same response CLM returns for "no access rights" as for "object doesn't exist" — a 403 never leaks whether the object exists. Cite it: NotFound here isn't just "account never provisioned in SpringCM", it's just as plausible a "no access" signal as PermissionDenied/Unauthenticated. No behavior change. --- pkg/connector/helper.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 66799b19..ed04867a 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -54,7 +54,14 @@ func parsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, strin // - PermissionDenied/Unauthenticated: the account/token lacks the CLM subscription // or OAuth scope — the expected case for most eSignature-only accounts. // - NotFound: the discovery endpoint 404s for an account that was never provisioned -// in the legacy SpringCM system CLM discovery still runs through. +// in the legacy SpringCM system CLM discovery still runs through. Confirmed via +// DocuSign's own CLM API docs (Response and Error Codes) that 404 isn't unique to +// "this object doesn't exist" across the whole CLM Object API — it's the same +// response CLM returns for "this exists but you don't have access rights", +// specifically so a 403 never leaks whether the object exists ("If the user does +// not have permissions to see the object or the object does not exist, a 404 +// response code is returned"). So a 404 is just as plausible a "no access" signal +// as PermissionDenied/Unauthenticated are, not only a genuinely-missing account. // - FailedPrecondition: ensureClmInitialized wraps its "response didn't contain a // recognized base-URL field" error with this code specifically — a non-CLM // account's discovery response plausibly has a different shape entirely (no CLM From a56ca85ae5abd51c5f589cccab68348764305e67 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 22:15:32 -0300 Subject: [PATCH 09/41] fix: reject bare scheme+host samples in clmHrefWithID; preserve annos on href-resolution error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clmHrefWithID's "no path separator" guard only checked for the ABSENCE of a "/", which a bare scheme+host like "https://clm.example.com" still has (the one separating scheme from host) — so it was accepted as a valid sample, producing a garbage "https://" href with no real path, which clmPreferredHref would then prefer over the correct client.GroupHref/MemberHref fallback. Now also requires a real path segment via url.Parse. Also fixes clm_groups.go's Grant: a clmPreferredHref failure returned nil annotations, discarding the rate-limit descriptions GetMemberGroups' real round trip above already returned, and lacked the baton-docusign: prefix. --- pkg/connector/clm_groups.go | 2 +- pkg/connector/helper.go | 8 ++++++++ pkg/connector/helper_test.go | 7 +++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index b33ff955..4f641636 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -178,7 +178,7 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent return g.client.GroupHref(ctx, groupID) }) if err != nil { - return nil, nil, err + return nil, annos, fmt.Errorf("baton-docusign: resolving href for CLM group %s: %w", groupID, err) } newGroups := make([]client.ClmGroup, 0, len(currentGroups)+1) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index ed04867a..d3903088 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -2,6 +2,7 @@ package connector import ( "fmt" + "net/url" "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" @@ -116,6 +117,13 @@ func clmHrefWithID(sampleHref, newID string) (string, error) { if idx == -1 { return "", fmt.Errorf("baton-docusign: cannot derive a sibling href from %q — no path separator found", sampleHref) } + // A bare scheme+host like "https://clm.example.com" also contains a "/" (the one + // separating scheme from host), so the LastIndex check above alone accepts it — + // producing a garbage "https://" href with no real path. Require an actual + // path segment before the trailing one being replaced. + if u, err := url.Parse(trimmed); err != nil || u.Path == "" || u.Path == "/" { + return "", fmt.Errorf("baton-docusign: cannot derive a sibling href from %q — no path segment found", sampleHref) + } return trimmed[:idx+1] + newID, nil } diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index 51c318b1..ecf5b510 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -45,6 +45,13 @@ func TestClmHrefWithID(t *testing.T) { if _, err := clmHrefWithID("no-path-separator", "x"); err == nil { t.Error("expected an error for a sample href with no path separator") } + + // A bare scheme+host contains a "/" too (separating scheme from host), so this must + // be rejected on its own — not accepted as a valid sample producing a garbage + // "https://x"-shaped href with no real path. + if _, err := clmHrefWithID("https://clm.example.com", "x"); err == nil { + t.Error("expected an error for a sample href with no path segment (bare scheme+host)") + } } func TestClmPreferredHref(t *testing.T) { From 949672eb6c6e5469eafd8423c1d366df98d28593 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 13:38:58 -0300 Subject: [PATCH 10/41] fix: address remaining PR #63 review threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strengthen both Revoke subtests in TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal: asserting only err == nil couldn't fail if the derived Href stopped matching the entry Grant wrote (Revoke would silently report GrantAlreadyRevoked instead of actually revoking). - Wrap clmPreferredHref's error in clm_folders.go's Grant with the baton-docusign: prefix and preserve getAnnos, matching every other error path in this file. - Fix a self-referential doc citation on client.GroupHref (clmGetGroup exists only to build this Href, so it wasn't independent corroboration). - Reuse the existing clmIdentityOnlyResource helper in clm_groups_test.go instead of duplicating its construction. - Add TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch: the existing identity-only-principal test only ever hits clmPreferredHref's fallback branch (folder-templates starts empty); this grants a different group/member on folder-contracts (already seeded with real security entries) to exercise and assert the sample-derived Href end to end. - Add Server.LastPatchedMemberGroupHrefs to clmtest and use it to assert the exact Href clm_groups_test.go's sample-branch subtest sent, instead of only membership (which strips Href entirely, so it couldn't detect clmPreferredHref ignoring its sample). - Soften helper.go's clmHrefWithID doc: "confirmed" meant confirmed against clm_models.go's doc comments, not an observed API response, and "structurally guaranteed by the callers" wasn't enforced by the code — it's the caller's contract. - Add the principal's/entitlement's own profile href (when present, e.g. not stripped by pebble) as clmPreferredHref's first-preference sample in clm_folders.go's Grant and clm_groups.go's Grant — this exact object's own recorded Href, not a sibling's to derive from. --- pkg/client/clm_client.go | 6 +- pkg/client/clmtest/handlers.go | 3 + pkg/client/clmtest/server.go | 29 ++++++++-- pkg/connector/clm_folders.go | 33 +++++++---- pkg/connector/clm_folders_test.go | 93 ++++++++++++++++++++++++++++++- pkg/connector/clm_groups.go | 19 +++++-- pkg/connector/clm_groups_test.go | 28 +++++++++- pkg/connector/helper.go | 19 ++++--- 8 files changed, 191 insertions(+), 39 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 187827df..fa561e97 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -428,8 +428,10 @@ func (c *Client) ListGroups(ctx context.Context, options PageOptions) ([]ClmGrou // engine's V3EntitlementToV2 (vendor/.../dotc1z/engine/pebble/translate_v2.go) // deliberately hydrates an Entitlement's Resource as an identity-only stub, by design, // on every read — so an entitlement-side lookup can never rely on anything beyond the -// ID surviving. Matches the same "/v2/{account}/groups/{id}" shape every other CLM -// group request already uses (clmGetGroup) and clmtest's own Server.GroupHref. +// ID surviving. Asserts CLM's Href shape is "/v2/{account}/groups/{id}" — no CLM request +// in this codebase independently confirms that shape yet (clmGetGroup exists only to +// build this one), so this is currently only verified against clmtest's own +// Server.GroupHref, not a live tenant. func (c *Client) GroupHref(ctx context.Context, groupID string) (string, error) { if err := c.ensureClmReady(ctx); err != nil { return "", err diff --git a/pkg/client/clmtest/handlers.go b/pkg/client/clmtest/handlers.go index 935579b4..4e1e978e 100644 --- a/pkg/client/clmtest/handlers.go +++ b/pkg/client/clmtest/handlers.go @@ -209,13 +209,16 @@ func (s *Server) handlePatchMember(w http.ResponseWriter, r *http.Request) { return } + hrefs := make([]string, 0, len(body.Groups.Items)) for _, g := range body.Groups.Items { + hrefs = append(hrefs, g.Href) gid := idFromHref(g.Href) if !containsString(s.memberGroups[id], gid) { s.memberGroups[id] = append(s.memberGroups[id], gid) s.groupMembers[gid] = append(s.groupMembers[gid], id) } } + s.lastPatchedMemberGroupHrefs[id] = hrefs writeJSON(w, *m) } diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index e90f940b..cddf16dc 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -113,6 +113,8 @@ type Server struct { permissionSetOrder []string memberGroupsRequests int // count of GET .../members/{id}/groups calls, for pagination assertions + + lastPatchedMemberGroupHrefs map[string][]string // memberID -> the raw Href strings the last PATCH request body carried, for tests } // MemberGroupsRequestCount returns how many times GET .../members/{id}/groups has been @@ -125,6 +127,20 @@ func (s *Server) MemberGroupsRequestCount() int { return s.memberGroupsRequests } +// LastPatchedMemberGroupHrefs returns the raw Href strings the most recent PATCH +// .../members/{id} request body carried for memberID — unlike MemberGroups (which +// reduces everything to the trailing ID via idFromHref, the same as the real API's own +// comparison semantics), this exposes the exact Href the connector sent, so a test can +// tell a sample-derived Href from a base-URL-derived one even when the two mock helpers +// that build them happen to produce byte-identical strings. +func (s *Server) LastPatchedMemberGroupHrefs(memberID string) []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.lastPatchedMemberGroupHrefs[memberID])) + copy(out, s.lastPatchedMemberGroupHrefs[memberID]) + return out +} + // URL returns the mock server's base URL — also what handleClmAccountDiscovery // returns as the CLM API base URL. func (s *Server) URL() string { return s.baseURL } @@ -180,12 +196,13 @@ func (s *Server) FolderSecurity(folderID string) client.ClmFolderSecurity { // RunStandalone so both construct exactly the same seeded state. func newState() *Server { return &Server{ - folders: make(map[string]*client.ClmFolder), - groups: make(map[string]*client.ClmGroup), - groupMembers: make(map[string][]string), - members: make(map[string]*client.ClmMember), - memberGroups: make(map[string][]string), - permissionSets: make(map[string]*client.ClmPermissionSet), + folders: make(map[string]*client.ClmFolder), + groups: make(map[string]*client.ClmGroup), + groupMembers: make(map[string][]string), + members: make(map[string]*client.ClmMember), + memberGroups: make(map[string][]string), + permissionSets: make(map[string]*client.ClmPermissionSet), + lastPatchedMemberGroupHrefs: make(map[string][]string), } } diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index b7d2dc0e..d881871a 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -200,18 +200,26 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en switch principal.Id.ResourceType { case clmGroupResourceType.Id: - // Prefer a real, server-issued Href already on hand (any OTHER group-security - // entry this folder already carries) over one derived from the discovered CLM - // base URL — see clmPreferredHref's doc. - groupSampleHrefs := make([]string, len(write.Groups)) - for i, entry := range write.Groups { - groupSampleHrefs[i] = entry.Href + // Prefer a real, server-issued Href already on hand over one derived from the + // discovered CLM base URL — see clmPreferredHref's doc. The principal's own + // profile href (parseIntoClmGroupResource still populates it for display) comes + // first: it's this exact group's own recorded Href, not a sibling's to derive + // from, so it's the most direct sample available when the resource happens to + // carry one — only ever a sample, never required, so an identity-only principal + // (no profile at all) still falls through to the other-entries/fallback path + // unchanged. + groupSampleHrefs := make([]string, 0, len(write.Groups)+1) + if href, ok := rs.GetProfileStringValue(rs.GetProfile(principal), "href"); ok { + groupSampleHrefs = append(groupSampleHrefs, href) + } + for _, entry := range write.Groups { + groupSampleHrefs = append(groupSampleHrefs, entry.Href) } groupHref, err := clmPreferredHref(principal.Id.Resource, groupSampleHrefs, func() (string, error) { return f.client.GroupHref(ctx, principal.Id.Resource) }) if err != nil { - return nil, nil, err + return nil, getAnnos, fmt.Errorf("baton-docusign: resolving href for CLM group %s: %w", principal.Id.Resource, err) } if i := clmFindGroupSecurityIndex(write.Groups, groupHref); i >= 0 { if slug, ok := clmSlugForAccessType(write.Groups[i].AccessType); ok && slug == ent.Slug { @@ -233,15 +241,18 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en } case clmMemberResourceType.Id: // Same rationale as the group case above. - userSampleHrefs := make([]string, len(write.Users)) - for i, entry := range write.Users { - userSampleHrefs[i] = entry.Href + userSampleHrefs := make([]string, 0, len(write.Users)+1) + if href, ok := rs.GetProfileStringValue(rs.GetProfile(principal), "href"); ok { + userSampleHrefs = append(userSampleHrefs, href) + } + for _, entry := range write.Users { + userSampleHrefs = append(userSampleHrefs, entry.Href) } memberHref, err := clmPreferredHref(principal.Id.Resource, userSampleHrefs, func() (string, error) { return f.client.MemberHref(ctx, principal.Id.Resource) }) if err != nil { - return nil, nil, err + return nil, getAnnos, fmt.Errorf("baton-docusign: resolving href for CLM member %s: %w", principal.Id.Resource, err) } if i := clmFindUserSecurityIndex(write.Users, memberHref); i >= 0 { if slug, ok := clmSlugForAccessType(write.Users[i].AccessType); ok && slug == ent.Slug { diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index a476b0a4..4b83a89e 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -476,9 +476,21 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin } grantObj := &v2.Grant{Principal: principal, Entitlement: ent} - if _, err := b.Revoke(ctx, grantObj); err != nil { + annos, err := b.Revoke(ctx, grantObj) + if err != nil { t.Fatalf("Revoke with an identity-only principal: %v", err) } + // If the derived Href ever stopped matching the entry Grant wrote, + // clmFindGroupSecurityIndex would return -1 and Revoke would report + // GrantAlreadyRevoked with a nil error instead of actually revoking — asserting + // only err == nil would still pass while access was silently left in place. + if hasAlreadyRevoked(annos) { + t.Fatal("Revoke incorrectly reported GrantAlreadyRevoked — the derived Href didn't match the entry Grant wrote") + } + groups = srv.FolderSecurity("folder-templates").Groups.Items + if len(groups) != 1 || groups[0].AccessType != client.ClmAccessTypeNoAccess { + t.Fatalf("expected the entry's AccessType to become NoAccess after Revoke, got %+v", groups) + } }) t.Run("clm_member principal", func(t *testing.T) { @@ -499,9 +511,86 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin } grantObj := &v2.Grant{Principal: principal, Entitlement: ent} - if _, err := b.Revoke(ctx, grantObj); err != nil { + annos, err := b.Revoke(ctx, grantObj) + if err != nil { t.Fatalf("Revoke with an identity-only principal: %v", err) } + // Same rationale as the clm_group subtest above. + if hasAlreadyRevoked(annos) { + t.Fatal("Revoke incorrectly reported GrantAlreadyRevoked — the derived Href didn't match the entry Grant wrote") + } + users = srv.FolderSecurity("folder-templates").Users.Items + if len(users) != 1 || users[0].AccessType != client.ClmAccessTypeNoAccess { + t.Fatalf("expected the entry's AccessType to become NoAccess after Revoke, got %+v", users) + } + }) +} + +// TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch covers the +// clmPreferredHref branch TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal +// doesn't: folder-templates always starts with no security entries, so every Grant +// there hits clmPreferredHref's fallback (client.GroupHref/MemberHref) — the newer, +// riskier sample branch (deriving a written Href from an existing folder-security +// entry) never runs. folder-contracts is already seeded with real group/user security +// entries (clmtest/seed.go), so granting a DIFFERENT group/member there exercises the +// sample branch and lets this assert the derived Href end to end. +func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *testing.T) { + srv, c := clmtest.NewServer(t) + b := newClmFolderBuilder(c) + ctx := context.Background() + + folderResource, err := rs.NewResource("Contracts", clmFolderResourceType, "folder-contracts") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + + t.Run("clm_group principal", func(t *testing.T) { + // group-ops is not among folder-contracts' existing entries (group-legal, + // group-finance), so clmPreferredHref must derive group-ops' Href from one of + // those samples via clmHrefWithID, not just echo a pre-existing entry. + principal := clmIdentityOnlyResource(clmGroupResourceType, "group-ops") + ent := &v2.Entitlement{Slug: "view", Resource: folderResource} + + if _, _, err := b.Grant(ctx, principal, ent); err != nil { + t.Fatalf("Grant with an identity-only principal: %v", err) + } + groups := srv.FolderSecurity("folder-contracts").Groups.Items + var found *client.ClmGroupSecurityEntry + for i := range groups { + if groups[i].Href == srv.GroupHref("group-ops") { + found = &groups[i] + } + } + if found == nil { + t.Fatalf("expected a group-ops entry with the sample-derived Href, got %+v", groups) + } + if found.AccessType != client.ClmAccessTypeView { + t.Errorf("expected View AccessType, got %q", found.AccessType) + } + }) + + t.Run("clm_member principal", func(t *testing.T) { + // member-dave is not folder-contracts' existing member entry (member-bob), so + // clmPreferredHref must derive member-dave's Href from that sample. + principal := clmIdentityOnlyResource(clmMemberResourceType, "member-dave") + ent := &v2.Entitlement{Slug: "view", Resource: folderResource} + + if _, _, err := b.Grant(ctx, principal, ent); err != nil { + t.Fatalf("Grant with an identity-only principal: %v", err) + } + users := srv.FolderSecurity("folder-contracts").Users.Items + var found *client.ClmUserSecurityEntry + for i := range users { + if users[i].Href == srv.MemberHref("member-dave") { + found = &users[i] + } + } + if found == nil { + t.Fatalf("expected a member-dave entry with the sample-derived Href, got %+v", users) + } + if found.AccessType != client.ClmAccessTypeView { + t.Errorf("expected View AccessType, got %q", found.AccessType) + } }) } diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 4f641636..f57e18ef 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -167,12 +167,19 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent } } - // Prefer a real, server-issued Href already on hand (any of this member's OTHER - // current groups) over one derived from the discovered CLM base URL — see - // clmPreferredHref's doc. - sampleHrefs := make([]string, len(currentGroups)) - for i, current := range currentGroups { - sampleHrefs[i] = current.Href + // Prefer a real, server-issued Href already on hand over one derived from the + // discovered CLM base URL — see clmPreferredHref's doc. The target group's own + // profile href (parseIntoClmGroupResource still populates it for display) comes + // first: it's this exact group's own recorded Href, not a sibling's (one of this + // member's OTHER current groups) to derive from — only ever a sample, never + // required, so an identity-only ent.Resource (the pebble-hydrated case this fix + // exists for) still falls through to the other-groups/fallback path unchanged. + sampleHrefs := make([]string, 0, len(currentGroups)+1) + if href, ok := rs.GetProfileStringValue(rs.GetProfile(ent.Resource), "href"); ok { + sampleHrefs = append(sampleHrefs, href) + } + for _, current := range currentGroups { + sampleHrefs = append(sampleHrefs, current.Href) } groupHref, err := clmPreferredHref(groupID, sampleHrefs, func() (string, error) { return g.client.GroupHref(ctx, groupID) diff --git a/pkg/connector/clm_groups_test.go b/pkg/connector/clm_groups_test.go index 44b32227..0d8d9e09 100644 --- a/pkg/connector/clm_groups_test.go +++ b/pkg/connector/clm_groups_test.go @@ -190,9 +190,7 @@ func TestClmGroupBuilder_GrantAndRevoke_Idempotent(t *testing.T) { func TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource(t *testing.T) { // An identity-only Resource — exactly what V3EntitlementToV2 hands back on pebble, // carrying nothing but the group's ID. - identityOnlyGroupResource := &v2.Resource{ - Id: &v2.ResourceId{ResourceType: clmGroupResourceType.Id, Resource: "group-legal"}, - } + identityOnlyGroupResource := clmIdentityOnlyResource(clmGroupResourceType, "group-legal") ent := &v2.Entitlement{Slug: entitlementClmGroupMember, Resource: identityOnlyGroupResource} t.Run("fallback branch: member has no other groups, derives via client.GroupHref", func(t *testing.T) { @@ -252,6 +250,30 @@ func TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource(t *testin if !found { t.Fatalf("expected carol to be granted group-legal, got %v", groups) } + + // MemberGroups reduces every Href to its trailing ID (matching the real API's + // own comparison semantics), which can't tell a sample-derived Href from a + // fallback-derived one — this mock's srv.GroupHref and client.GroupHref happen + // to build byte-identical strings either way. Asserting on the raw Href the + // server actually received at least pins the exact value clmPreferredHref + // computed, derived from carol's existing group-finance sample. + wantHref, err := clmHrefWithID(srv.GroupHref("group-finance"), "group-legal") + if err != nil { + t.Fatalf("clmHrefWithID (computing expected Href): %v", err) + } + // PatchMemberGroups sends the member's full current+new list (additive), so the + // PATCH body also carries carol's pre-existing group-finance entry alongside the + // new group-legal one. + hrefs := srv.LastPatchedMemberGroupHrefs("member-carol") + sawWantHref := false + for _, h := range hrefs { + if h == wantHref { + sawWantHref = true + } + } + if !sawWantHref { + t.Errorf("expected the PATCH request to carry Href %q, got %v", wantHref, hrefs) + } }) } diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index d3903088..fb4456a9 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -102,15 +102,16 @@ func clmIDFromHref(href string) string { // // Reuses sampleHref's entire path, not just scheme+host, on the assumption that // same-collection Hrefs share the same path shape and only the trailing ID segment -// differs. This is confirmed for every sample source this helper is actually called -// with: ClmGroupSecurityEntry.Href and ClmUserSecurityEntry.Href are each documented in -// clm_models.go as carrying the full Group/Member object's own native Href (not some -// folder-scoped nested path), and ClmGroupPage's doc confirms a member's-current-groups -// response (GetMemberGroups) shares the identical ClmGroup/Href shape as the top-level -// groups-list endpoint. Every clmPreferredHref call site in this codebase also only -// ever draws samples from one single collection at a time (e.g. groupSampleHrefs from -// write.Groups alone), so a same-type sample is never a hidden assumption to re-verify -// here — it's structurally guaranteed by the callers. +// differs. As documented in clm_models.go (not observed against a live tenant): +// ClmGroupSecurityEntry.Href and ClmUserSecurityEntry.Href each carry the full +// Group/Member object's own native Href (not some folder-scoped nested path), and +// ClmGroupPage's doc states a member's-current-groups response (GetMemberGroups) shares +// the identical ClmGroup/Href shape as the top-level groups-list endpoint. +// +// This function has no way to enforce it, but every clmPreferredHref call site in this +// codebase only ever draws samples from one single collection at a time (e.g. +// groupSampleHrefs from write.Groups alone) — mixing sample sources across collections +// is the caller's contract, not something clmHrefWithID/clmPreferredHref check. func clmHrefWithID(sampleHref, newID string) (string, error) { trimmed := strings.TrimSuffix(sampleHref, "/") idx := strings.LastIndex(trimmed, "/") From 42db4b82088e53ed5ed238ad32fff10bd8f980dc Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 14:56:37 -0300 Subject: [PATCH 11/41] test: make the sample-branch Grant test actually pin sample-preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wantHref reduced to srv.GroupHref("group-finance"), byte-identical to client.GroupHref's fallback derivation from the discovered base URL — the test passed even with clmPreferredHref's sample-preference loop deleted entirely (verified via mutation test). Adds SetGroupHref to override the seeded group-finance Href to a different host so the sample and fallback paths are actually distinguishable. --- pkg/client/clmtest/server.go | 13 +++++++++++++ pkg/connector/clm_groups_test.go | 21 +++++++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index cddf16dc..dfca91e4 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -155,6 +155,19 @@ func (s *Server) GroupHref(id string) string { return fmt.Sprintf("%s/v2/%s/groups/%s", s.baseURL, AccountID, id) } +// SetGroupHref overrides the Href a seeded group reports on GetMemberGroups, letting a +// test make it observably different from what GroupHref (and so client.GroupHref's +// fallback derivation, which builds the same shape from the discovered base URL) would +// produce — needed to distinguish a sample-derived Href from a fallback-derived one when +// both would otherwise be byte-identical. +func (s *Server) SetGroupHref(id, href string) { + s.mu.Lock() + defer s.mu.Unlock() + if g, ok := s.groups[id]; ok { + g.Href = href + } +} + func (s *Server) MemberHref(id string) string { return fmt.Sprintf("%s/v2/%s/members/%s", s.baseURL, AccountID, id) } diff --git a/pkg/connector/clm_groups_test.go b/pkg/connector/clm_groups_test.go index 0d8d9e09..67c6ddf1 100644 --- a/pkg/connector/clm_groups_test.go +++ b/pkg/connector/clm_groups_test.go @@ -2,6 +2,7 @@ package connector import ( "context" + "fmt" "testing" "github.com/conductorone/baton-docusign/pkg/client" @@ -229,7 +230,15 @@ func TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource(t *testin // member-carol is seeded into group-finance (clmtest/seed.go), giving // clmPreferredHref a real sample Href to derive group-legal's Href from instead - // of falling back to client.GroupHref. + // of falling back to client.GroupHref. srv.GroupHref and the fallback + // client.GroupHref build the same shape from the same discovered base URL, so + // they'd be byte-identical here — overriding group-finance's Href to a + // different host makes the sample branch's output actually distinguishable from + // what the fallback branch would have produced. + const sampleHost = "https://other.example.com" + altGroupFinanceHref := fmt.Sprintf("%s/v2/%s/groups/group-finance", sampleHost, clmtest.AccountID) + srv.SetGroupHref("group-finance", altGroupFinanceHref) + memberResource, err := rs.NewResource("Carol", clmMemberResourceType, "member-carol") if err != nil { t.Fatalf("NewResource: %v", err) @@ -253,11 +262,11 @@ func TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource(t *testin // MemberGroups reduces every Href to its trailing ID (matching the real API's // own comparison semantics), which can't tell a sample-derived Href from a - // fallback-derived one — this mock's srv.GroupHref and client.GroupHref happen - // to build byte-identical strings either way. Asserting on the raw Href the - // server actually received at least pins the exact value clmPreferredHref - // computed, derived from carol's existing group-finance sample. - wantHref, err := clmHrefWithID(srv.GroupHref("group-finance"), "group-legal") + // fallback-derived one. Asserting on the raw Href the server actually received, + // derived from the overridden group-finance sample (a different host than + // client.GroupHref's fallback would produce), pins that clmPreferredHref + // actually used the sample rather than falling back. + wantHref, err := clmHrefWithID(altGroupFinanceHref, "group-legal") if err != nil { t.Fatalf("clmHrefWithID (computing expected Href): %v", err) } From fa794e7f9b5e5b30db55b84e29a8747385f8f7c3 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 10:30:51 -0300 Subject: [PATCH 12/41] fix: validate newID and log unexpected sample-parse failures in clmHrefWithID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/client/clmtest/handlers.go | 11 +--- pkg/client/helper.go | 14 +++++ pkg/connector/helper.go | 94 ++++++++++++++++++++-------------- pkg/connector/helper_test.go | 33 ++++++++++-- 4 files changed, 102 insertions(+), 50 deletions(-) diff --git a/pkg/client/clmtest/handlers.go b/pkg/client/clmtest/handlers.go index 4e1e978e..37871637 100644 --- a/pkg/client/clmtest/handlers.go +++ b/pkg/client/clmtest/handlers.go @@ -4,20 +4,13 @@ import ( "encoding/json" "io" "net/http" - "strings" "github.com/conductorone/baton-docusign/pkg/client" ) -// idFromHref extracts the trailing path segment of a Href — mirrors -// pkg/connector/helper.go's clmIDFromHref, reimplemented locally so this test package -// has no dependency on the connector package. +// idFromHref extracts the trailing path segment of a Href — see client.IDFromHref's doc. func idFromHref(href string) string { - href = strings.TrimSuffix(href, "/") - if idx := strings.LastIndex(href, "/"); idx != -1 { - return href[idx+1:] - } - return href + return client.IDFromHref(href) } // Doc URL: https://developers.docusign.com/docs/clm-api/reference/objects/folders/ (Search). diff --git a/pkg/client/helper.go b/pkg/client/helper.go index 45286c93..4e24a45b 100644 --- a/pkg/client/helper.go +++ b/pkg/client/helper.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/ratelimit" @@ -14,6 +15,19 @@ import ( const DefaultPageSize = 100 +// IDFromHref extracts the trailing path segment from a CLM object's Href — CLM's +// Object API schemas expose a Href field ("Uri where the object can be retrieved") but +// no separate opaque Id field, so this is the closest thing to a native ID CLM exposes. +// Shared by pkg/connector (reading real Hrefs) and pkg/client/clmtest (generating seed +// Hrefs for the mock server), which otherwise each maintained an identical copy. +func IDFromHref(href string) string { + href = strings.TrimSuffix(href, "/") + if idx := strings.LastIndex(href, "/"); idx != -1 { + return href[idx+1:] + } + return href +} + // BuildURL combines the base API URL with a formatted endpoint path. func buildURL(base, path string, params ...any) (*url.URL, error) { baseURL, err := url.Parse(base) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index fb4456a9..49783310 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -1,12 +1,17 @@ package connector import ( + "context" "fmt" "net/url" "strings" + "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/pagination" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -55,14 +60,9 @@ func parsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, strin // - PermissionDenied/Unauthenticated: the account/token lacks the CLM subscription // or OAuth scope — the expected case for most eSignature-only accounts. // - NotFound: the discovery endpoint 404s for an account that was never provisioned -// in the legacy SpringCM system CLM discovery still runs through. Confirmed via -// DocuSign's own CLM API docs (Response and Error Codes) that 404 isn't unique to -// "this object doesn't exist" across the whole CLM Object API — it's the same -// response CLM returns for "this exists but you don't have access rights", -// specifically so a 403 never leaks whether the object exists ("If the user does -// not have permissions to see the object or the object does not exist, a 404 -// response code is returned"). So a 404 is just as plausible a "no access" signal -// as PermissionDenied/Unauthenticated are, not only a genuinely-missing account. +// in the legacy SpringCM system. CLM's Object API returns 404 both for "doesn't +// exist" and "exists but no access", so treat it as a plausible no-access signal, +// not proof the account lacks CLM. // - FailedPrecondition: ensureClmInitialized wraps its "response didn't contain a // recognized base-URL field" error with this code specifically — a non-CLM // account's discovery response plausibly has a different shape entirely (no CLM @@ -84,35 +84,27 @@ func isOptInFeatureUnavailableError(err error) bool { } } -// clmIDFromHref extracts the trailing path segment from a CLM object's Href — CLM's -// Object API schemas expose a Href field ("Uri where the object can be retrieved") but -// no separate opaque Id field, so this is the closest thing to a native ID CLM exposes. +// 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. func clmIDFromHref(href string) string { - href = strings.TrimSuffix(href, "/") - if idx := strings.LastIndex(href, "/"); idx != -1 { - return href[idx+1:] - } - return href + return client.IDFromHref(href) } -// clmHrefWithID rebuilds sampleHref with its trailing ID segment replaced by newID — -// used to derive a sibling object's Href from a known-real one, instead of guessing at -// the host from the discovered CLM base URL, whenever a real sample happens to be -// available. See clmPreferredHref for why this matters specifically for writes. -// -// Reuses sampleHref's entire path, not just scheme+host, on the assumption that -// same-collection Hrefs share the same path shape and only the trailing ID segment -// differs. As documented in clm_models.go (not observed against a live tenant): -// ClmGroupSecurityEntry.Href and ClmUserSecurityEntry.Href each carry the full -// Group/Member object's own native Href (not some folder-scoped nested path), and -// ClmGroupPage's doc states a member's-current-groups response (GetMemberGroups) shares -// the identical ClmGroup/Href shape as the top-level groups-list endpoint. -// -// This function has no way to enforce it, but every clmPreferredHref call site in this -// codebase only ever draws samples from one single collection at a time (e.g. -// groupSampleHrefs from write.Groups alone) — mixing sample sources across collections -// is the caller's contract, not something clmHrefWithID/clmPreferredHref check. +// clmHrefWithID rebuilds sampleHref with its trailing ID segment replaced by newID. +// Assumes same-collection Hrefs share path shape (only the ID differs) — not verified +// against a live tenant. Callers must only pass samples from a single collection; newID +// must be non-empty (see the check below for why). func clmHrefWithID(sampleHref, newID string) (string, error) { + if newID == "" { + // Without this check, an empty newID passes every shape check below (sampleHref + // still has a valid path) and silently returns a trailing-slash href with no ID + // segment at all — a malformed href that goes on to be sent as-is inside a + // PatchFolderSecurity/PatchMemberGroups request body, surfacing (if at all) as an + // opaque remote validation failure with no link back to "the ID was empty." + return "", fmt.Errorf("baton-docusign: cannot derive a sibling href from %q — newID is empty", sampleHref) + } trimmed := strings.TrimSuffix(sampleHref, "/") idx := strings.LastIndex(trimmed, "/") if idx == -1 { @@ -120,8 +112,8 @@ func clmHrefWithID(sampleHref, newID string) (string, error) { } // A bare scheme+host like "https://clm.example.com" also contains a "/" (the one // separating scheme from host), so the LastIndex check above alone accepts it — - // producing a garbage "https://" href with no real path. Require an actual - // path segment before the trailing one being replaced. + // producing a garbage "https://" href with no real path. Reject a sample with + // no path at all; a single-segment path like "https://host/group-old" is accepted. if u, err := url.Parse(trimmed); err != nil || u.Path == "" || u.Path == "/" { return "", fmt.Errorf("baton-docusign: cannot derive a sibling href from %q — no path segment found", sampleHref) } @@ -136,12 +128,38 @@ func clmHrefWithID(sampleHref, newID string) (string, error) { // inconsistently; a read-side comparison doesn't have this risk, since clmIDFromHref // only ever looks at the trailing ID. Falls back to deriveFallback when no real sample // href is available (e.g. a member with no other group memberships yet, or a folder -// with no other security entries yet). -func clmPreferredHref(id string, sampleHrefs []string, deriveFallback func() (string, error)) (string, error) { +// with no other security entries yet) — the expected, routine case (empty sampleHrefs +// never reaches clmHrefWithID at all). If sampleHrefs is non-empty but every sample +// fails to parse, that's the unexpected case: it means CLM returned a Href shape this +// codebase's assumptions don't cover, so it's logged before falling back, rather than +// silently masking exactly the wrong-host risk this function exists to avoid. +func clmPreferredHref(ctx context.Context, id string, sampleHrefs []string, deriveFallback func() (string, error)) (string, error) { + var lastErr error for _, sample := range sampleHrefs { - if derived, err := clmHrefWithID(sample, id); err == nil { + derived, err := clmHrefWithID(sample, id) + if err == nil { return derived, nil } + lastErr = err + } + if lastErr != nil { + ctxzap.Extract(ctx).Debug("baton-docusign: every sample href failed to derive a sibling href, falling back to a base-URL-derived href", + zap.Int("sample_count", len(sampleHrefs)), zap.Error(lastErr)) } return deriveFallback() } + +// clmSampleHrefsFrom builds the sampleHrefs slice clmPreferredHref expects: principal's +// own profile href first (the most direct sample when the resource happens to carry +// one — only ever a sample, never required, so an identity-only principal still falls +// through to the entries/fallback path unchanged), followed by every entry's Href. +func clmSampleHrefsFrom[T any](principal *v2.Resource, entries []T, hrefOf func(T) string) []string { + sampleHrefs := make([]string, 0, len(entries)+1) + if href, ok := rs.GetProfileStringValue(rs.GetProfile(principal), "href"); ok { + sampleHrefs = append(sampleHrefs, href) + } + for _, e := range entries { + sampleHrefs = append(sampleHrefs, hrefOf(e)) + } + return sampleHrefs +} diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index ecf5b510..827d2c04 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -1,6 +1,7 @@ package connector import ( + "context" "errors" "testing" @@ -52,9 +53,17 @@ func TestClmHrefWithID(t *testing.T) { if _, err := clmHrefWithID("https://clm.example.com", "x"); err == nil { t.Error("expected an error for a sample href with no path segment (bare scheme+host)") } + + // An empty newID would otherwise pass every shape check on sampleHref and silently + // return a trailing-slash href with no ID segment at all (e.g. + // ".../groups/") — a malformed href with nothing pointing back at "the ID was empty". + if _, err := clmHrefWithID("https://clm.example.com/v2/acct-1/groups/group-old", ""); err == nil { + t.Error("expected an error for an empty newID") + } } func TestClmPreferredHref(t *testing.T) { + ctx := context.Background() fallbackCalled := false fallback := func() (string, error) { fallbackCalled = true @@ -63,7 +72,7 @@ func TestClmPreferredHref(t *testing.T) { t.Run("prefers a real sample href over the fallback", func(t *testing.T) { fallbackCalled = false - got, err := clmPreferredHref("group-target", []string{"https://real.example.com/v2/acct-1/groups/group-other"}, fallback) + got, err := clmPreferredHref(ctx, "group-target", []string{"https://real.example.com/v2/acct-1/groups/group-other"}, fallback) if err != nil { t.Fatalf("clmPreferredHref: %v", err) } @@ -77,7 +86,7 @@ func TestClmPreferredHref(t *testing.T) { t.Run("skips empty sample hrefs", func(t *testing.T) { fallbackCalled = false - got, err := clmPreferredHref("group-target", []string{"", "https://real.example.com/v2/acct-1/groups/group-other"}, fallback) + got, err := clmPreferredHref(ctx, "group-target", []string{"", "https://real.example.com/v2/acct-1/groups/group-other"}, fallback) if err != nil { t.Fatalf("clmPreferredHref: %v", err) } @@ -88,7 +97,7 @@ func TestClmPreferredHref(t *testing.T) { t.Run("falls back when no sample href is available", func(t *testing.T) { fallbackCalled = false - got, err := clmPreferredHref("group-target", nil, fallback) + got, err := clmPreferredHref(ctx, "group-target", nil, fallback) if err != nil { t.Fatalf("clmPreferredHref: %v", err) } @@ -99,4 +108,22 @@ func TestClmPreferredHref(t *testing.T) { t.Error("expected the fallback to be called when no sample hrefs are available") } }) + + // Regression test: sampleHrefs is non-empty but every sample is malformed in an + // unexpected way (not the routine "no sample yet" case) — must still fall back + // safely rather than erroring, even though this case is now logged (see + // clmPreferredHref's doc). + t.Run("falls back when every sample href fails to parse", func(t *testing.T) { + fallbackCalled = false + got, err := clmPreferredHref(ctx, "group-target", []string{"no-path-separator", "https://clm.example.com"}, fallback) + if err != nil { + t.Fatalf("clmPreferredHref: %v", err) + } + if want := "https://derived.example.com/v2/acct-1/groups/group-target"; got != want { + t.Errorf("clmPreferredHref = %q, want %q", got, want) + } + if !fallbackCalled { + t.Error("expected the fallback to be called when every sample href fails to parse") + } + }) } From 07e8d06b96f0eccbaa549c987ff2907326a41e3f Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 10:30:59 -0300 Subject: [PATCH 13/41] refactor: dedupe GroupHref/MemberHref, sampleHrefs construction, and 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. --- pkg/client/clm_client.go | 39 ++++++++++--------------- pkg/connector/clm_folders.go | 56 ++++++++++++++---------------------- pkg/connector/clm_groups.go | 20 +++++-------- 3 files changed, 44 insertions(+), 71 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index fa561e97..2e75062f 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -111,6 +111,7 @@ const ( clmGetGroupMembers = "/v2/%s/groups/%s/groupmembers" clmGetMembers = "/v2/%s/members" clmGetMemberGroups = "/v2/%s/members/%s/groups" + clmGetMember = "/v2/%s/members/%s" clmPatchPutMember = "/v2/%s/members/%s" clmGetPermissionSet = "/v2/%s/permissionsets" ) @@ -421,39 +422,29 @@ func (c *Client) ListGroups(ctx context.Context, options PageOptions) ([]ClmGrou return page.Items, nextToken, anno, nil } -// GroupHref builds a CLM group's Href from its native ID and the already-resolved CLM -// base URL — for callers that only have a group's ResourceId, not a fully-hydrated -// Resource to read a stashed Href from. Needed because a Resource carrying only -// identity (no profile, no annotations) is not an edge case here: the pebble storage -// engine's V3EntitlementToV2 (vendor/.../dotc1z/engine/pebble/translate_v2.go) -// deliberately hydrates an Entitlement's Resource as an identity-only stub, by design, -// on every read — so an entitlement-side lookup can never rely on anything beyond the -// ID surviving. Asserts CLM's Href shape is "/v2/{account}/groups/{id}" — no CLM request -// in this codebase independently confirms that shape yet (clmGetGroup exists only to -// build this one), so this is currently only verified against clmtest's own -// Server.GroupHref, not a live tenant. -func (c *Client) GroupHref(ctx context.Context, groupID string) (string, error) { +// hrefFor builds an object's Href from its native ID and the resolved CLM base URL, +// shared by GroupHref and MemberHref, for callers that only have a ResourceId (no +// hydrated Resource to read a Href from). Assumes the shape "/v2/{account}/{collection}/{id}"; +// unverified against a live tenant. +func (c *Client) hrefFor(ctx context.Context, endpoint, id string) (string, error) { if err := c.ensureClmReady(ctx); err != nil { return "", err } - groupURL, err := c.buildClmClientURL(clmGetGroup, groupID) + objURL, err := c.buildClmClientURL(endpoint, id) if err != nil { return "", err } - return groupURL.String(), nil + return objURL.String(), nil +} + +// GroupHref builds a CLM group's Href from its native ID — see hrefFor's doc. +func (c *Client) GroupHref(ctx context.Context, groupID string) (string, error) { + return c.hrefFor(ctx, clmGetGroup, groupID) } -// MemberHref is GroupHref's counterpart for CLM members — see its doc for why this -// derives the Href from the ID rather than reading it off a Resource. +// MemberHref builds a CLM member's Href from its native ID — see hrefFor's doc. func (c *Client) MemberHref(ctx context.Context, memberID string) (string, error) { - if err := c.ensureClmReady(ctx); err != nil { - return "", err - } - memberURL, err := c.buildClmClientURL(clmPatchPutMember, memberID) - if err != nil { - return "", err - } - return memberURL.String(), nil + return c.hrefFor(ctx, clmGetMember, memberID) } // GetGroupMembers lists the members of a CLM group. diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index d881871a..31fc547f 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -131,7 +131,7 @@ func (f *clmFolderBuilder) StaticEntitlements(_ context.Context, _ rs.SyncOpAttr func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { folder, annos, err := f.client.GetFolder(ctx, folderResource.Id.Resource, "Security") if err != nil { - return nil, nil, fmt.Errorf("getting security for CLM folder %s: %w", folderResource.Id.Resource, err) + return nil, nil, fmt.Errorf("baton-docusign: getting security for CLM folder %s: %w", folderResource.Id.Resource, err) } var grants []*v2.Grant @@ -194,7 +194,7 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en folder, getAnnos, err := f.client.GetFolderFresh(ctx, folderID, "Security") if err != nil { - return nil, getAnnos, fmt.Errorf("getting security for CLM folder %s: %w", folderID, err) + return nil, getAnnos, fmt.Errorf("baton-docusign: getting security for CLM folder %s: %w", folderID, err) } write := clmFolderSecurityToWrite(folder.Security) @@ -208,14 +208,8 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en // carry one — only ever a sample, never required, so an identity-only principal // (no profile at all) still falls through to the other-entries/fallback path // unchanged. - groupSampleHrefs := make([]string, 0, len(write.Groups)+1) - if href, ok := rs.GetProfileStringValue(rs.GetProfile(principal), "href"); ok { - groupSampleHrefs = append(groupSampleHrefs, href) - } - for _, entry := range write.Groups { - groupSampleHrefs = append(groupSampleHrefs, entry.Href) - } - groupHref, err := clmPreferredHref(principal.Id.Resource, groupSampleHrefs, func() (string, error) { + groupSampleHrefs := clmSampleHrefsFrom(principal, write.Groups, func(e client.ClmGroupSecurityEntry) string { return e.Href }) + groupHref, err := clmPreferredHref(ctx, principal.Id.Resource, groupSampleHrefs, func() (string, error) { return f.client.GroupHref(ctx, principal.Id.Resource) }) if err != nil { @@ -241,14 +235,8 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en } case clmMemberResourceType.Id: // Same rationale as the group case above. - userSampleHrefs := make([]string, 0, len(write.Users)+1) - if href, ok := rs.GetProfileStringValue(rs.GetProfile(principal), "href"); ok { - userSampleHrefs = append(userSampleHrefs, href) - } - for _, entry := range write.Users { - userSampleHrefs = append(userSampleHrefs, entry.Href) - } - memberHref, err := clmPreferredHref(principal.Id.Resource, userSampleHrefs, func() (string, error) { + userSampleHrefs := clmSampleHrefsFrom(principal, write.Users, func(e client.ClmUserSecurityEntry) string { return e.Href }) + memberHref, err := clmPreferredHref(ctx, principal.Id.Resource, userSampleHrefs, func() (string, error) { return f.client.MemberHref(ctx, principal.Id.Resource) }) if err != nil { @@ -268,7 +256,7 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en patchAnnos, err := f.client.PatchFolderSecurity(ctx, folderID, write) if err != nil { - return nil, patchAnnos, fmt.Errorf("granting CLM folder security: %w", err) + return nil, patchAnnos, fmt.Errorf("baton-docusign: granting CLM folder security: %w", err) } return nil, patchAnnos, nil @@ -288,18 +276,25 @@ func clmFolderSecurityToWrite(sec client.ClmFolderSecurity) client.ClmFolderSecu return client.ClmFolderSecurityWrite{Groups: groups, Roles: roles, Users: users} } -// clmFindGroupSecurityIndex returns the index of entries whose Href identifies -// groupHref (compared via clmIDFromHref, since the read-side Href shape isn't -// guaranteed to match exactly — see client.GroupHref), or -1 if not found. -func clmFindGroupSecurityIndex(entries []client.ClmGroupSecurityEntry, groupHref string) int { +// clmFindSecurityIndexByHref returns the index of the entry whose Href identifies +// targetHref (compared via clmIDFromHref, since the read-side Href shape isn't +// guaranteed to match exactly — see client.GroupHref/MemberHref), or -1 if not found. +// Shared by clmFindGroupSecurityIndex and clmFindUserSecurityIndex, which differ only in +// entry type. +func clmFindSecurityIndexByHref[T any](entries []T, hrefOf func(T) string, targetHref string) int { + targetID := clmIDFromHref(targetHref) for i, e := range entries { - if clmIDFromHref(e.Href) == clmIDFromHref(groupHref) { + if clmIDFromHref(hrefOf(e)) == targetID { return i } } return -1 } +func clmFindGroupSecurityIndex(entries []client.ClmGroupSecurityEntry, groupHref string) int { + return clmFindSecurityIndexByHref(entries, func(e client.ClmGroupSecurityEntry) string { return e.Href }, groupHref) +} + // clmFindRoleSecurityIndex returns the index of the entry for roleName, or -1 if not // found. Roles are compared by exact name, not clmIDFromHref — a role's Item is // already the bare name, never a Href. @@ -312,15 +307,8 @@ func clmFindRoleSecurityIndex(entries []client.ClmRoleSecurityEntry, roleName st return -1 } -// clmFindUserSecurityIndex returns the index of the entry whose Href identifies -// memberHref (see clmFindGroupSecurityIndex's identical rationale), or -1 if not found. func clmFindUserSecurityIndex(entries []client.ClmUserSecurityEntry, memberHref string) int { - for i, e := range entries { - if clmIDFromHref(e.Href) == clmIDFromHref(memberHref) { - return i - } - } - return -1 + return clmFindSecurityIndexByHref(entries, func(e client.ClmUserSecurityEntry) string { return e.Href }, memberHref) } // Revoke sets the principal's folder-security entry to NoAccess (not removed — same @@ -332,7 +320,7 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno folder, getAnnos, err := f.client.GetFolderFresh(ctx, folderID, "Security") if err != nil { - return getAnnos, fmt.Errorf("getting security for CLM folder %s: %w", folderID, err) + return getAnnos, fmt.Errorf("baton-docusign: getting security for CLM folder %s: %w", folderID, err) } write := clmFolderSecurityToWrite(folder.Security) @@ -367,7 +355,7 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno patchAnnos, err := f.client.PatchFolderSecurity(ctx, folderID, write) if err != nil { - return patchAnnos, fmt.Errorf("revoking CLM folder security: %w", err) + return patchAnnos, fmt.Errorf("baton-docusign: revoking CLM folder security: %w", err) } return patchAnnos, nil diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index f57e18ef..ac12f3cd 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -110,7 +110,7 @@ func (g *clmGroupBuilder) Grants(ctx context.Context, groupResource *v2.Resource PageToken: pageToken, }) if err != nil { - return nil, nil, fmt.Errorf("getting members for CLM group %s: %w", groupResource.Id.Resource, err) + return nil, nil, fmt.Errorf("baton-docusign: getting members for CLM group %s: %w", groupResource.Id.Resource, err) } grants := make([]*v2.Grant, 0, len(members)) @@ -157,7 +157,7 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent // a real sample Href from currentGroups if one exists, else client.GroupHref. currentGroups, annos, err := g.client.GetMemberGroups(ctx, memberID) if err != nil { - return nil, annos, fmt.Errorf("getting current groups for CLM member %s: %w", memberID, err) + return nil, annos, fmt.Errorf("baton-docusign: getting current groups for CLM member %s: %w", memberID, err) } for _, current := range currentGroups { @@ -174,14 +174,8 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent // member's OTHER current groups) to derive from — only ever a sample, never // required, so an identity-only ent.Resource (the pebble-hydrated case this fix // exists for) still falls through to the other-groups/fallback path unchanged. - sampleHrefs := make([]string, 0, len(currentGroups)+1) - if href, ok := rs.GetProfileStringValue(rs.GetProfile(ent.Resource), "href"); ok { - sampleHrefs = append(sampleHrefs, href) - } - for _, current := range currentGroups { - sampleHrefs = append(sampleHrefs, current.Href) - } - groupHref, err := clmPreferredHref(groupID, sampleHrefs, func() (string, error) { + sampleHrefs := clmSampleHrefsFrom(ent.Resource, currentGroups, func(g client.ClmGroup) string { return g.Href }) + groupHref, err := clmPreferredHref(ctx, groupID, sampleHrefs, func() (string, error) { return g.client.GroupHref(ctx, groupID) }) if err != nil { @@ -193,7 +187,7 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent newGroups = append(newGroups, client.ClmGroup{Href: groupHref}) patchAnnos, err := g.client.PatchMemberGroups(ctx, memberID, newGroups) if err != nil { - return nil, patchAnnos, fmt.Errorf("granting CLM group membership: %w", err) + return nil, patchAnnos, fmt.Errorf("baton-docusign: granting CLM group membership: %w", err) } return nil, patchAnnos, nil @@ -209,7 +203,7 @@ func (g *clmGroupBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (annot currentGroups, annos, err := g.client.GetMemberGroups(ctx, memberID) if err != nil { - return annos, fmt.Errorf("getting current groups for CLM member %s: %w", memberID, err) + return annos, fmt.Errorf("baton-docusign: getting current groups for CLM member %s: %w", memberID, err) } remainingGroups := make([]client.ClmGroup, 0, len(currentGroups)) @@ -228,7 +222,7 @@ func (g *clmGroupBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (annot putAnnos, err := g.client.PutMemberGroups(ctx, memberID, remainingGroups) if err != nil { - return putAnnos, fmt.Errorf("revoking CLM group membership: %w", err) + return putAnnos, fmt.Errorf("baton-docusign: revoking CLM group membership: %w", err) } return putAnnos, nil From 53639922140cafe6b9c4d2682eb1eb25307e6a4f Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 10:31:08 -0300 Subject: [PATCH 14/41] test: make the folder-security SampleBranch test actually pin sample-preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/client/clmtest/server.go | 36 +++++++++++++++++++++++ pkg/connector/clm_folders_test.go | 47 +++++++++++++++---------------- pkg/connector/clm_groups_test.go | 16 ++++------- 3 files changed, 63 insertions(+), 36 deletions(-) diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index dfca91e4..9ddac5fa 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -168,6 +168,42 @@ func (s *Server) SetGroupHref(id, href string) { } } +// SetFolderGroupSecurityHref and SetFolderUserSecurityHref override the Href of an +// existing group/user security entry on a seeded folder (matched by ID via idFromHref), +// letting a test make it observably different from what client.GroupHref/MemberHref's +// fallback would produce for a different, derived ID — the same lever SetGroupHref gives +// GetMemberGroups-based tests, needed here because folder security entries carry no +// separate ID field of their own. +func (s *Server) SetFolderGroupSecurityHref(folderID, groupID, href string) { + s.mu.Lock() + defer s.mu.Unlock() + folder, ok := s.folders[folderID] + if !ok { + return + } + for i := range folder.Security.Groups.Items { + if idFromHref(folder.Security.Groups.Items[i].Href) == groupID { + folder.Security.Groups.Items[i].Href = href + return + } + } +} + +func (s *Server) SetFolderUserSecurityHref(folderID, memberID, href string) { + s.mu.Lock() + defer s.mu.Unlock() + folder, ok := s.folders[folderID] + if !ok { + return + } + for i := range folder.Security.Users.Items { + if idFromHref(folder.Security.Users.Items[i].Href) == memberID { + folder.Security.Users.Items[i].Href = href + return + } + } +} + func (s *Server) MemberHref(id string) string { return fmt.Sprintf("%s/v2/%s/members/%s", s.baseURL, AccountID, id) } diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 4b83a89e..bbe5d7e5 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -2,6 +2,7 @@ package connector import ( "context" + "fmt" "testing" "github.com/conductorone/baton-docusign/pkg/client" @@ -422,32 +423,17 @@ func TestClmFolderBuilder_GrantAndRevoke_ToleratesBareIDOnRead(t *testing.T) { } } -// clmIdentityOnlyResource builds a Resource carrying nothing but its Id — the worst -// case any code path in this connector can hand Grant/Revoke a principal in: it's what -// the SDK's local file-mode provisioner effectively reduces a principal to once you -// strip the fields no longer relied on (see the previous commit), and it's exactly what -// the pebble storage engine's V3EntitlementToV2 (vendor/.../dotc1z/engine/pebble/ -// translate_v2.go) hydrates an Entitlement's own Resource as on every read, by design, -// regardless of provisioning path. clmMemberHrefFromResource/clmGroupHrefFromResource no -// longer exist — Grant prefers a real, server-issued sample Href already on hand via -// clmPreferredHref, falling back to client.GroupHref/client.MemberHref only when none is -// available; Revoke needs nothing beyond Id, since clmFindGroupSecurityIndex/ -// clmFindUserSecurityIndex compare by clmIDFromHref. +// clmIdentityOnlyResource builds a Resource with nothing but Id — the shape pebble's +// V3EntitlementToV2 hands Grant/Revoke on every read. func clmIdentityOnlyResource(resourceType *v2.ResourceType, resourceID string) *v2.Resource { return &v2.Resource{ Id: &v2.ResourceId{ResourceType: resourceType.Id, Resource: resourceID}, } } -// TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal is a regression test -// for the bug originally fixed by carrying the CLM href on the resource (first via -// ExternalId, then an annotation) and, after the pebble-engine gap the annotation -// approach missed, fixed properly by deriving the Href from the ID instead. Every other -// Grant/Revoke test in this file passes the fully-populated resource -// parseIntoClm*Resource returns — so it would pass whether or not either fix actually -// worked. This test instead passes an identity-only principal — see -// clmIdentityOnlyResource — to confirm the Href is still derivable with nothing else on -// the resource to fall back to. +// TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal is a regression +// test: passes an identity-only principal (unlike every other test in this file, which +// uses a fully-populated resource) to confirm Href is still derivable. func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testing.T) { srv, c := clmtest.NewServer(t) b := newClmFolderBuilder(c) @@ -533,12 +519,21 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin // riskier sample branch (deriving a written Href from an existing folder-security // entry) never runs. folder-contracts is already seeded with real group/user security // entries (clmtest/seed.go), so granting a DIFFERENT group/member there exercises the -// sample branch and lets this assert the derived Href end to end. +// sample branch. Both existing samples are moved onto an alternate host first — +// otherwise the sample-derived Href and the fallback-derived one (both built from the +// same base URL and ID shape) would be byte-identical, and this test would pass whether +// or not the sample branch actually ran. func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *testing.T) { srv, c := clmtest.NewServer(t) b := newClmFolderBuilder(c) ctx := context.Background() + const sampleHost = "https://other.example.com" + altGroupLegalHref := fmt.Sprintf("%s/v2/%s/groups/group-legal", sampleHost, clmtest.AccountID) + srv.SetFolderGroupSecurityHref("folder-contracts", "group-legal", altGroupLegalHref) + altMemberBobHref := fmt.Sprintf("%s/v2/%s/members/%s", sampleHost, clmtest.AccountID, "member-bob") + srv.SetFolderUserSecurityHref("folder-contracts", "member-bob", altMemberBobHref) + folderResource, err := rs.NewResource("Contracts", clmFolderResourceType, "folder-contracts") if err != nil { t.Fatalf("NewResource: %v", err) @@ -555,14 +550,15 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te t.Fatalf("Grant with an identity-only principal: %v", err) } groups := srv.FolderSecurity("folder-contracts").Groups.Items + wantHref := fmt.Sprintf("%s/v2/%s/groups/group-ops", sampleHost, clmtest.AccountID) var found *client.ClmGroupSecurityEntry for i := range groups { - if groups[i].Href == srv.GroupHref("group-ops") { + if groups[i].Href == wantHref { found = &groups[i] } } if found == nil { - t.Fatalf("expected a group-ops entry with the sample-derived Href, got %+v", groups) + t.Fatalf("expected a group-ops entry with the sample-derived Href %q, got %+v", wantHref, groups) } if found.AccessType != client.ClmAccessTypeView { t.Errorf("expected View AccessType, got %q", found.AccessType) @@ -579,14 +575,15 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te t.Fatalf("Grant with an identity-only principal: %v", err) } users := srv.FolderSecurity("folder-contracts").Users.Items + wantHref := fmt.Sprintf("%s/v2/%s/members/member-dave", sampleHost, clmtest.AccountID) var found *client.ClmUserSecurityEntry for i := range users { - if users[i].Href == srv.MemberHref("member-dave") { + if users[i].Href == wantHref { found = &users[i] } } if found == nil { - t.Fatalf("expected a member-dave entry with the sample-derived Href, got %+v", users) + t.Fatalf("expected a member-dave entry with the sample-derived Href %q, got %+v", wantHref, users) } if found.AccessType != client.ClmAccessTypeView { t.Errorf("expected View AccessType, got %q", found.AccessType) diff --git a/pkg/connector/clm_groups_test.go b/pkg/connector/clm_groups_test.go index 67c6ddf1..793b7a65 100644 --- a/pkg/connector/clm_groups_test.go +++ b/pkg/connector/clm_groups_test.go @@ -177,17 +177,11 @@ func TestClmGroupBuilder_GrantAndRevoke_Idempotent(t *testing.T) { } } -// TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource is a regression -// test for a gap the annotation-based fix in an earlier commit missed: the pebble -// storage engine's V3EntitlementToV2 (vendor/.../dotc1z/engine/pebble/translate_v2.go) -// deliberately hydrates an Entitlement's Resource as an identity-only stub on every -// read — no profile, no annotations, nothing but Id. clmGroupBuilder.Grant previously -// read the group's Href off ent.Resource; on pebble that would always fail, in every -// version of this connector including the one before this test existed. It now -// resolves the groupHref to write via clmPreferredHref — nothing on ent.Resource itself -// is ever needed, whether that resolves from a real sample Href already on hand (the -// member's other current groups) or, absent one, client.GroupHref's ID-only fallback. -// The two subtests below exercise both of clmPreferredHref's branches. +// TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource is a regression test: +// passes an identity-only entitlement Resource (pebble's V3EntitlementToV2 shape — no +// profile, no annotations, nothing but Id) to confirm Grant resolves the groupHref to +// write via clmPreferredHref without needing anything on ent.Resource itself. The two +// subtests below exercise both of clmPreferredHref's branches. func TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource(t *testing.T) { // An identity-only Resource — exactly what V3EntitlementToV2 hands back on pebble, // carrying nothing but the group's ID. From b333dbe4e74f410c68624ff89c101805ac083953 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 10:44:28 -0300 Subject: [PATCH 15/41] fix: address 3 findings from the latest automated review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- pkg/client/clmtest/server.go | 34 ++++++++++++++++++------------- pkg/connector/clm_folders_test.go | 2 ++ pkg/connector/clm_groups.go | 7 +++---- pkg/connector/clm_members.go | 9 ++++---- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index 9ddac5fa..d9368a62 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -178,30 +178,36 @@ func (s *Server) SetFolderGroupSecurityHref(folderID, groupID, href string) { s.mu.Lock() defer s.mu.Unlock() folder, ok := s.folders[folderID] - if !ok { - return - } - for i := range folder.Security.Groups.Items { - if idFromHref(folder.Security.Groups.Items[i].Href) == groupID { - folder.Security.Groups.Items[i].Href = href - return + if ok { + for i := range folder.Security.Groups.Items { + if idFromHref(folder.Security.Groups.Items[i].Href) == groupID { + folder.Security.Groups.Items[i].Href = href + return + } } } + if s.t != nil { + //nolint:gocritic // s.t is testing.TB; ruleguard only exempts concrete *testing.T/B/F, not the interface + s.t.Fatalf("SetFolderGroupSecurityHref: no group %q security entry on folder %q", groupID, folderID) + } } func (s *Server) SetFolderUserSecurityHref(folderID, memberID, href string) { s.mu.Lock() defer s.mu.Unlock() folder, ok := s.folders[folderID] - if !ok { - return - } - for i := range folder.Security.Users.Items { - if idFromHref(folder.Security.Users.Items[i].Href) == memberID { - folder.Security.Users.Items[i].Href = href - return + if ok { + for i := range folder.Security.Users.Items { + if idFromHref(folder.Security.Users.Items[i].Href) == memberID { + folder.Security.Users.Items[i].Href = href + return + } } } + if s.t != nil { + //nolint:gocritic // s.t is testing.TB; ruleguard only exempts concrete *testing.T/B/F, not the interface + s.t.Fatalf("SetFolderUserSecurityHref: no member %q security entry on folder %q", memberID, folderID) + } } func (s *Server) MemberHref(id string) string { diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index bbe5d7e5..e0e0d220 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -531,6 +531,8 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te const sampleHost = "https://other.example.com" altGroupLegalHref := fmt.Sprintf("%s/v2/%s/groups/group-legal", sampleHost, clmtest.AccountID) srv.SetFolderGroupSecurityHref("folder-contracts", "group-legal", altGroupLegalHref) + altGroupFinanceHref := fmt.Sprintf("%s/v2/%s/groups/group-finance", sampleHost, clmtest.AccountID) + srv.SetFolderGroupSecurityHref("folder-contracts", "group-finance", altGroupFinanceHref) altMemberBobHref := fmt.Sprintf("%s/v2/%s/members/%s", sampleHost, clmtest.AccountID, "member-bob") srv.SetFolderUserSecurityHref("folder-contracts", "member-bob", altMemberBobHref) diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index ac12f3cd..e46b30e9 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -236,10 +236,9 @@ func newClmGroupBuilder(c *client.Client) *clmGroupBuilder { } // parseIntoClmGroupResource maps a client.ClmGroup to a Baton v2.Resource. The Href is -// kept in the profile for display only — Grant/Revoke derive it directly from the -// group's ID via client.GroupHref instead of reading it off the resource, since neither -// a top-level profile nor an annotation is guaranteed to survive to where it's needed -// (see client.GroupHref's doc for why). +// kept in the profile both for display and as the preferred sample href for Grant; +// Grant falls back to client.GroupHref when it's absent, since neither a profile nor an +// annotation is guaranteed to survive to where it's needed. func parseIntoClmGroupResource(group *client.ClmGroup) (*v2.Resource, error) { profile := map[string]any{ "name": group.Name, diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 9af1b54a..4d3e3d2c 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -83,11 +83,10 @@ func newClmMemberBuilder(c *client.Client) *clmMemberBuilder { } } -// parseIntoClmMemberResource maps a client.ClmMember to a Baton v2.Resource. The Href -// is kept in the profile for display only — Grant/Revoke derive it directly from the -// member's ID via client.MemberHref instead of reading it off the resource, since -// neither a top-level profile nor an annotation is guaranteed to survive to where it's -// needed (see client.GroupHref's doc, MemberHref's counterpart, for why). +// parseIntoClmMemberResource maps a client.ClmMember to a Baton v2.Resource. The Href is +// kept in the profile both for display and as the preferred sample href for Grant; +// Grant falls back to client.MemberHref when it's absent, since neither a profile nor +// an annotation is guaranteed to survive to where it's needed. func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) { profile := map[string]any{ profileFieldEmail: member.Email, From a71e90c859b2c0128f54b65db778d0a331627746 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 10:54:40 -0300 Subject: [PATCH 16/41] fix: SetGroupHref fails loudly on a typo'd ID, matching its new siblings Consistency follow-up: SetFolderGroupSecurityHref/SetFolderUserSecurityHref were just changed to Fatalf on a miss, but the original SetGroupHref still silently no-op'd. --- pkg/client/clmtest/server.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index d9368a62..de87d7ef 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -165,6 +165,11 @@ func (s *Server) SetGroupHref(id, href string) { defer s.mu.Unlock() if g, ok := s.groups[id]; ok { g.Href = href + return + } + if s.t != nil { + //nolint:gocritic // s.t is testing.TB; ruleguard only exempts concrete *testing.T/B/F, not the interface + s.t.Fatalf("SetGroupHref: no seeded group %q", id) } } From 699f1f123ffd0193308fce9ffd0c0a8662faff75 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 11:51:22 -0300 Subject: [PATCH 17/41] fix: address 3 more findings from the automated review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- pkg/client/clm_client.go | 21 ++++++++++------ pkg/connector/clm_groups.go | 6 ++--- pkg/connector/clm_members.go | 2 +- pkg/connector/helper.go | 13 +++++++--- pkg/connector/helper_test.go | 46 ++++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 14 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 2e75062f..daa6aaf6 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -106,14 +106,21 @@ const ( clmSearchFolders = "/v2/%s/folders/search" clmGetFolder = "/v2/%s/folders/%s" clmPatchFolder = "/v2/%s/folders/%s" - clmGetGroup = "/v2/%s/groups/%s" clmGetGroups = "/v2/%s/groups" clmGetGroupMembers = "/v2/%s/groups/%s/groupmembers" clmGetMembers = "/v2/%s/members" clmGetMemberGroups = "/v2/%s/members/%s/groups" - clmGetMember = "/v2/%s/members/%s" clmPatchPutMember = "/v2/%s/members/%s" clmGetPermissionSet = "/v2/%s/permissionsets" + + // clmGroupPath and clmMemberPath are path *shapes*, not endpoints this connector + // calls — hrefFor builds a Href string locally from these, never issuing an HTTP + // request, so neither corresponds to an entry in this file's "API Endpoints Used" + // doc. clmMemberPath is deliberately not clmPatchPutMember despite the identical + // shape: naming it after a real PATCH/PUT endpoint would be just as misleading in + // the other direction. + clmGroupPath = "/v2/%s/groups/%s" + clmMemberPath = "/v2/%s/members/%s" ) // ensureClmInitialized resolves the CLM Object API base URL, separately from @@ -425,12 +432,12 @@ func (c *Client) ListGroups(ctx context.Context, options PageOptions) ([]ClmGrou // hrefFor builds an object's Href from its native ID and the resolved CLM base URL, // shared by GroupHref and MemberHref, for callers that only have a ResourceId (no // hydrated Resource to read a Href from). Assumes the shape "/v2/{account}/{collection}/{id}"; -// unverified against a live tenant. -func (c *Client) hrefFor(ctx context.Context, endpoint, id string) (string, error) { +// unverified against a live tenant. Builds the string locally — never issues a request. +func (c *Client) hrefFor(ctx context.Context, pathShape, id string) (string, error) { if err := c.ensureClmReady(ctx); err != nil { return "", err } - objURL, err := c.buildClmClientURL(endpoint, id) + objURL, err := c.buildClmClientURL(pathShape, id) if err != nil { return "", err } @@ -439,12 +446,12 @@ func (c *Client) hrefFor(ctx context.Context, endpoint, id string) (string, erro // GroupHref builds a CLM group's Href from its native ID — see hrefFor's doc. func (c *Client) GroupHref(ctx context.Context, groupID string) (string, error) { - return c.hrefFor(ctx, clmGetGroup, groupID) + return c.hrefFor(ctx, clmGroupPath, groupID) } // MemberHref builds a CLM member's Href from its native ID — see hrefFor's doc. func (c *Client) MemberHref(ctx context.Context, memberID string) (string, error) { - return c.hrefFor(ctx, clmGetMember, memberID) + return c.hrefFor(ctx, clmMemberPath, memberID) } // GetGroupMembers lists the members of a CLM group. diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index e46b30e9..92779a69 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -241,9 +241,9 @@ func newClmGroupBuilder(c *client.Client) *clmGroupBuilder { // annotation is guaranteed to survive to where it's needed. func parseIntoClmGroupResource(group *client.ClmGroup) (*v2.Resource, error) { profile := map[string]any{ - "name": group.Name, - "groupType": group.GroupType, - "href": group.Href, + "name": group.Name, + "groupType": group.GroupType, + profileFieldHref: group.Href, } return rs.NewGroupResource( diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 4d3e3d2c..74352724 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -94,7 +94,7 @@ func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) "role": member.Role, "exemptFromUserSync": member.ExemptFromUserSync, "portalOnly": member.PortalOnly, - "href": member.Href, + profileFieldHref: member.Href, } displayName := member.UserName diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 49783310..dd785609 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -22,6 +22,7 @@ const ( profileFieldEmail = "email" profileFieldUsername = "username" profileFieldGroupName = "group_name" + profileFieldHref = "href" ) // parsePageToken deserializes the Baton token and returns the Bag and page number for upstream. @@ -131,8 +132,9 @@ func clmHrefWithID(sampleHref, newID string) (string, error) { // with no other security entries yet) — the expected, routine case (empty sampleHrefs // never reaches clmHrefWithID at all). If sampleHrefs is non-empty but every sample // fails to parse, that's the unexpected case: it means CLM returned a Href shape this -// codebase's assumptions don't cover, so it's logged before falling back, rather than -// silently masking exactly the wrong-host risk this function exists to avoid. +// codebase's assumptions don't cover, so it's logged at Debug before falling back — not +// visible by default, but distinguishable from the routine case for anyone who does +// raise verbosity, rather than discarded with no trace at all. func clmPreferredHref(ctx context.Context, id string, sampleHrefs []string, deriveFallback func() (string, error)) (string, error) { var lastErr error for _, sample := range sampleHrefs { @@ -155,7 +157,12 @@ func clmPreferredHref(ctx context.Context, id string, sampleHrefs []string, deri // through to the entries/fallback path unchanged), followed by every entry's Href. func clmSampleHrefsFrom[T any](principal *v2.Resource, entries []T, hrefOf func(T) string) []string { sampleHrefs := make([]string, 0, len(entries)+1) - if href, ok := rs.GetProfileStringValue(rs.GetProfile(principal), "href"); ok { + // GetProfileStringValue returns ok == true for a present-but-empty "href" key + // (every parseIntoClm*Resource always writes it), so this must also reject "" — + // otherwise an absent-sample account looks identical to an unexpected-shape one: + // the empty string always fails clmHrefWithID, tripping clmPreferredHref's + // unexpected-failure log on what is actually the routine no-sample case. + if href, ok := rs.GetProfileStringValue(rs.GetProfile(principal), profileFieldHref); ok && href != "" { sampleHrefs = append(sampleHrefs, href) } for _, e := range entries { diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index 827d2c04..f5250725 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -5,6 +5,8 @@ import ( "errors" "testing" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -127,3 +129,47 @@ func TestClmPreferredHref(t *testing.T) { } }) } + +func TestClmSampleHrefsFrom(t *testing.T) { + type entry struct{ Href string } + hrefOf := func(e entry) string { return e.Href } + + t.Run("includes a non-empty profile href first", func(t *testing.T) { + principal, err := rs.NewResource("g", clmGroupResourceType, "group-1", rs.WithResourceProfile(map[string]any{"href": "https://real.example.com/v2/acct-1/groups/group-1"})) + if err != nil { + t.Fatalf("NewResource: %v", err) + } + got := clmSampleHrefsFrom(principal, []entry{{Href: "https://other.example.com/v2/acct-1/groups/group-2"}}, hrefOf) + want := []string{"https://real.example.com/v2/acct-1/groups/group-1", "https://other.example.com/v2/acct-1/groups/group-2"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("clmSampleHrefsFrom = %v, want %v", got, want) + } + }) + + // Regression test: parseIntoClm*Resource always writes the "href" profile key, even + // when the underlying CLM object has no Href yet, so GetProfileStringValue reports + // ok == true for an empty string. Appending it anyway would make an + // identity-only-of-real-samples principal look identical to one with a genuinely + // malformed sample, tripping clmPreferredHref's unexpected-failure log on what is + // actually the routine no-sample case. + t.Run("excludes an empty profile href", func(t *testing.T) { + principal, err := rs.NewResource("g", clmGroupResourceType, "group-1", rs.WithResourceProfile(map[string]any{"href": ""})) + if err != nil { + t.Fatalf("NewResource: %v", err) + } + got := clmSampleHrefsFrom(principal, []entry{{Href: "https://other.example.com/v2/acct-1/groups/group-2"}}, hrefOf) + want := []string{"https://other.example.com/v2/acct-1/groups/group-2"} + if len(got) != len(want) || got[0] != want[0] { + t.Errorf("clmSampleHrefsFrom = %v, want %v", got, want) + } + }) + + t.Run("no profile at all", func(t *testing.T) { + principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: clmGroupResourceType.Id, Resource: "group-1"}} + got := clmSampleHrefsFrom(principal, []entry{{Href: "https://other.example.com/v2/acct-1/groups/group-2"}}, hrefOf) + want := []string{"https://other.example.com/v2/acct-1/groups/group-2"} + if len(got) != len(want) || got[0] != want[0] { + t.Errorf("clmSampleHrefsFrom = %v, want %v", got, want) + } + }) +} From 2ad413cf4d574a33f0f2feeab1ef80faf9a3608b Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 11:59:57 -0300 Subject: [PATCH 18/41] fix: also exclude empty entry hrefs in clmSampleHrefsFrom 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. --- pkg/connector/helper.go | 7 ++++++- pkg/connector/helper_test.go | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index dd785609..bc6f7bf4 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -166,7 +166,12 @@ func clmSampleHrefsFrom[T any](principal *v2.Resource, entries []T, hrefOf func( sampleHrefs = append(sampleHrefs, href) } for _, e := range entries { - sampleHrefs = append(sampleHrefs, hrefOf(e)) + // Same reasoning as the profile href above: an empty entry Href would fail + // clmHrefWithID the same way and re-trip the unexpected-failure log on + // otherwise-routine degenerate data. + if href := hrefOf(e); href != "" { + sampleHrefs = append(sampleHrefs, href) + } } return sampleHrefs } diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index f5250725..87855207 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -164,6 +164,17 @@ func TestClmSampleHrefsFrom(t *testing.T) { } }) + // Same reasoning as the profile-href case above, but for an entry's Href — degenerate + // data, not an unexpected shape, so it must be excluded the same way. + t.Run("excludes an empty entry href", func(t *testing.T) { + principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: clmGroupResourceType.Id, Resource: "group-1"}} + got := clmSampleHrefsFrom(principal, []entry{{Href: ""}, {Href: "https://other.example.com/v2/acct-1/groups/group-2"}}, hrefOf) + want := []string{"https://other.example.com/v2/acct-1/groups/group-2"} + if len(got) != len(want) || got[0] != want[0] { + t.Errorf("clmSampleHrefsFrom = %v, want %v", got, want) + } + }) + t.Run("no profile at all", func(t *testing.T) { principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: clmGroupResourceType.Id, Resource: "group-1"}} got := clmSampleHrefsFrom(principal, []entry{{Href: "https://other.example.com/v2/acct-1/groups/group-2"}}, hrefOf) From d89cf737c1246ae6bdb8dfd2403311fad369be11 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 13:14:03 -0300 Subject: [PATCH 19/41] fix: close clmPreferredHref's empty-id gap, fix stale Grant comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/connector/clm_groups.go | 9 +++++---- pkg/connector/helper.go | 9 +++++++++ pkg/connector/helper_test.go | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 92779a69..f20d072f 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -151,10 +151,11 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent memberID := principal.Id.Resource groupID := ent.Resource.Id.Resource - // Don't read the group's Href off ent.Resource: the pebble storage engine hydrates - // an entitlement's Resource as an identity-only stub (no profile, no annotations). - // The groupHref this Grant actually writes below is resolved via clmPreferredHref — - // a real sample Href from currentGroups if one exists, else client.GroupHref. + // Don't require the group's Href off ent.Resource: the pebble storage engine hydrates + // an entitlement's Resource as an identity-only stub (no profile, no annotations), so + // it isn't always available. The groupHref this Grant actually writes below is + // resolved via clmPreferredHref, preferring (in order): ent.Resource's own profile + // Href if present, then a real sample Href from currentGroups, else client.GroupHref. currentGroups, annos, err := g.client.GetMemberGroups(ctx, memberID) if err != nil { return nil, annos, fmt.Errorf("baton-docusign: getting current groups for CLM member %s: %w", memberID, err) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index bc6f7bf4..b5e01c64 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -136,6 +136,15 @@ func clmHrefWithID(sampleHref, newID string) (string, error) { // visible by default, but distinguishable from the routine case for anyone who does // raise verbosity, rather than discarded with no trace at all. func clmPreferredHref(ctx context.Context, id string, sampleHrefs []string, deriveFallback func() (string, error)) (string, error) { + if id == "" { + // clmHrefWithID rejects an empty id on the sample path, but deriveFallback (a + // caller-supplied closure, typically client.GroupHref/MemberHref built from this + // same id) has no such guard — an empty id would sail through it and produce the + // same malformed trailing-slash href this function exists to prevent. Reject here + // once, before either path runs, rather than relying on every deriveFallback + // closure to check it independently. + return "", fmt.Errorf("baton-docusign: cannot resolve a CLM href — id is empty") + } var lastErr error for _, sample := range sampleHrefs { derived, err := clmHrefWithID(sample, id) diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index 87855207..09bc4e9d 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -97,6 +97,21 @@ func TestClmPreferredHref(t *testing.T) { } }) + // Regression test: id == "" used to sail through to deriveFallback (e.g. + // client.GroupHref(ctx, "")) whenever no sample href was available, producing a + // malformed trailing-slash href — the exact failure mode clmHrefWithID's own + // empty-newID check exists to catch on the sample path, but that check never ran + // here since an empty id with no samples skips clmHrefWithID entirely. + t.Run("rejects an empty id before trying either path", func(t *testing.T) { + fallbackCalled = false + if _, err := clmPreferredHref(ctx, "", nil, fallback); err == nil { + t.Error("expected an error for an empty id, got nil") + } + if fallbackCalled { + t.Error("expected the fallback NOT to be called for an empty id") + } + }) + t.Run("falls back when no sample href is available", func(t *testing.T) { fallbackCalled = false got, err := clmPreferredHref(ctx, "group-target", nil, fallback) From 672b2d0a2475ab5fff3e6fc17c694881ca5c0d4b Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 18:15:37 -0300 Subject: [PATCH 20/41] fix: address bot review findings on PR #63 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/client/clm_client.go | 13 +++++++------ pkg/connector/clm_folders.go | 9 +++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index daa6aaf6..a2b636b8 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -114,11 +114,11 @@ const ( clmGetPermissionSet = "/v2/%s/permissionsets" // clmGroupPath and clmMemberPath are path *shapes*, not endpoints this connector - // calls — hrefFor builds a Href string locally from these, never issuing an HTTP - // request, so neither corresponds to an entry in this file's "API Endpoints Used" - // doc. clmMemberPath is deliberately not clmPatchPutMember despite the identical - // shape: naming it after a real PATCH/PUT endpoint would be just as misleading in - // the other direction. + // calls — hrefFor builds a Href string locally from these, issuing no request beyond + // the one-time CLM base-URL discovery ensureClmReady may trigger, so neither + // corresponds to an entry in this file's "API Endpoints Used" doc. clmMemberPath is + // deliberately not clmPatchPutMember despite the identical shape: naming it after a + // real PATCH/PUT endpoint would be just as misleading in the other direction. clmGroupPath = "/v2/%s/groups/%s" clmMemberPath = "/v2/%s/members/%s" ) @@ -432,7 +432,8 @@ func (c *Client) ListGroups(ctx context.Context, options PageOptions) ([]ClmGrou // hrefFor builds an object's Href from its native ID and the resolved CLM base URL, // shared by GroupHref and MemberHref, for callers that only have a ResourceId (no // hydrated Resource to read a Href from). Assumes the shape "/v2/{account}/{collection}/{id}"; -// unverified against a live tenant. Builds the string locally — never issues a request. +// unverified against a live tenant. Builds the string locally — issues no request beyond +// the one-time CLM base-URL discovery ensureClmReady may trigger. func (c *Client) hrefFor(ctx context.Context, pathShape, id string) (string, error) { if err := c.ensureClmReady(ctx); err != nil { return "", err diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 31fc547f..eb593541 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -318,6 +318,15 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno folderID := grantObj.Entitlement.Resource.Id.Resource principal := grantObj.Principal + // Guards all three branches below in one place: clmFindGroupSecurityIndex and + // clmFindUserSecurityIndex reduce an empty ID to "" via clmIDFromHref, which would + // match any security entry whose Href is empty or ends in a trailing slash; the role + // branch compares principal.Id.Resource to Item by exact string, so an empty ID would + // just as wrongly match an entry with an empty Item. + if principal.Id.Resource == "" { + return nil, fmt.Errorf("baton-docusign: revoking CLM folder security: principal missing native ID") + } + folder, getAnnos, err := f.client.GetFolderFresh(ctx, folderID, "Security") if err != nil { return getAnnos, fmt.Errorf("baton-docusign: getting security for CLM folder %s: %w", folderID, err) From 46655a3977c96f4cb1a2c575bafb69f31d8ee3a8 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 18:34:14 -0300 Subject: [PATCH 21/41] fix: reject empty principal ID in Grant, matching Revoke's guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/connector/clm_folders.go | 11 +++++++++- pkg/connector/clm_folders_test.go | 34 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index eb593541..d898a131 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -192,6 +192,14 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en } folderID := ent.Resource.Id.Resource + // Same guard as Revoke, and for the same reason: the group/member branches below are + // only incidentally covered by clmPreferredHref's own empty-id check, but the role + // branch compares principal.Id.Resource to Item by exact string with nothing else + // upstream to reject an empty value — this catches all three uniformly. + if principal.Id.Resource == "" { + return nil, nil, fmt.Errorf("baton-docusign: granting CLM folder security: principal missing native ID") + } + folder, getAnnos, err := f.client.GetFolderFresh(ctx, folderID, "Security") if err != nil { return nil, getAnnos, fmt.Errorf("baton-docusign: getting security for CLM folder %s: %w", folderID, err) @@ -322,7 +330,8 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno // clmFindUserSecurityIndex reduce an empty ID to "" via clmIDFromHref, which would // match any security entry whose Href is empty or ends in a trailing slash; the role // branch compares principal.Id.Resource to Item by exact string, so an empty ID would - // just as wrongly match an entry with an empty Item. + // just as wrongly match an entry with an empty Item. Grant carries the identical + // guard for the identical reason — keep the two in sync. if principal.Id.Resource == "" { return nil, fmt.Errorf("baton-docusign: revoking CLM folder security: principal missing native ID") } diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index e0e0d220..9498dfaf 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -512,6 +512,40 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin }) } +// TestClmFolderBuilder_GrantAndRevoke_RejectEmptyPrincipalID is a regression test for +// both bot-flagged findings on this file: an empty principal.Id.Resource must be +// rejected before Grant or Revoke touch write.Groups/Roles/Users, for all three +// principal kinds — the role branch has no other guard (roleName is compared/written +// directly), and the group/member branches' own guard (inside clmPreferredHref, or +// clmIDFromHref's reduction of "" to "") must not be bypassed by this earlier check +// firing instead of it. +func TestClmFolderBuilder_GrantAndRevoke_RejectEmptyPrincipalID(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmFolderBuilder(c) + ctx := context.Background() + + folderResource, err := rs.NewResource("Templates", clmFolderResourceType, "folder-templates") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + ent := &v2.Entitlement{Slug: "view", Resource: folderResource} + + for _, resourceType := range []*v2.ResourceType{clmGroupResourceType, clmRoleResourceType, clmMemberResourceType} { + t.Run(resourceType.Id, func(t *testing.T) { + principal := clmIdentityOnlyResource(resourceType, "") + + if _, _, err := b.Grant(ctx, principal, ent); err == nil { + t.Error("expected Grant to reject an empty principal ID, got nil error") + } + + grantObj := &v2.Grant{Principal: principal, Entitlement: ent} + if _, err := b.Revoke(ctx, grantObj); err == nil { + t.Error("expected Revoke to reject an empty principal ID, got nil error") + } + }) + } +} + // TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch covers the // clmPreferredHref branch TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal // doesn't: folder-templates always starts with no security entries, so every Grant From c3f07fed3344fa9307d75d5b7072c83355a14a7a Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 18:48:45 -0300 Subject: [PATCH 22/41] fix: reject empty member/group ID in clmGroupBuilder Grant/Revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/connector/clm_groups.go | 16 ++++++++++++ pkg/connector/clm_groups_test.go | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index f20d072f..8ba1d0d4 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -151,6 +151,14 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent memberID := principal.Id.Resource groupID := ent.Resource.Id.Resource + // clmIDFromHref reduces an empty Href to "" too, so an empty groupID or memberID + // would falsely match a degenerate currentGroups entry below (empty groupID hits the + // "already a member" check before clmPreferredHref's own empty-id guard ever runs) — + // same class of bug as clm_folders.go's Grant/Revoke, guarded the same way. + if memberID == "" || groupID == "" { + return nil, nil, fmt.Errorf("baton-docusign: granting CLM group membership: member or group missing native ID") + } + // Don't require the group's Href off ent.Resource: the pebble storage engine hydrates // an entitlement's Resource as an identity-only stub (no profile, no annotations), so // it isn't always available. The groupHref this Grant actually writes below is @@ -202,6 +210,14 @@ func (g *clmGroupBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (annot memberID := grantObj.Principal.Id.Resource groupID := grantObj.Entitlement.Resource.Id.Resource + // Same guard as Grant, and for the same reason: an empty groupID would falsely match + // a currentGroups entry with an empty Href (clmIDFromHref("") == ""), excluding it + // from remainingGroups — and PutMemberGroups is a full-replace, so that unrelated + // membership would actually be removed from the real account. + if memberID == "" || groupID == "" { + return nil, fmt.Errorf("baton-docusign: revoking CLM group membership: member or group missing native ID") + } + currentGroups, annos, err := g.client.GetMemberGroups(ctx, memberID) if err != nil { return annos, fmt.Errorf("baton-docusign: getting current groups for CLM member %s: %w", memberID, err) diff --git a/pkg/connector/clm_groups_test.go b/pkg/connector/clm_groups_test.go index 793b7a65..34c6630f 100644 --- a/pkg/connector/clm_groups_test.go +++ b/pkg/connector/clm_groups_test.go @@ -177,6 +177,48 @@ func TestClmGroupBuilder_GrantAndRevoke_Idempotent(t *testing.T) { } } +// TestClmGroupBuilder_GrantAndRevoke_RejectEmptyID is a regression test mirroring +// TestClmFolderBuilder_GrantAndRevoke_RejectEmptyPrincipalID: clmIDFromHref reduces an +// empty Href to "", so an empty memberID or groupID must be rejected before Grant/Revoke +// reach the currentGroups matching loop — otherwise an empty groupID could match a +// currentGroups entry with an empty Href, causing Grant to falsely report +// GrantAlreadyExists (bypassing clmPreferredHref's own empty-id check) or Revoke to +// silently drop that unrelated membership via PutMemberGroups' full-replace semantics. +func TestClmGroupBuilder_GrantAndRevoke_RejectEmptyID(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmGroupBuilder(c) + ctx := context.Background() + + validMember := clmIdentityOnlyResource(clmMemberResourceType, "member-carol") + validGroup := clmIdentityOnlyResource(clmGroupResourceType, "group-legal") + emptyMember := clmIdentityOnlyResource(clmMemberResourceType, "") + emptyGroup := clmIdentityOnlyResource(clmGroupResourceType, "") + + cases := []struct { + name string + principal *v2.Resource + groupRes *v2.Resource + }{ + {"empty member ID", emptyMember, validGroup}, + {"empty group ID", validMember, emptyGroup}, + {"both empty", emptyMember, emptyGroup}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ent := &v2.Entitlement{Slug: entitlementClmGroupMember, Resource: tc.groupRes} + + if _, _, err := b.Grant(ctx, tc.principal, ent); err == nil { + t.Error("expected Grant to reject an empty member or group ID, got nil error") + } + + grantObj := &v2.Grant{Principal: tc.principal, Entitlement: ent} + if _, err := b.Revoke(ctx, grantObj); err == nil { + t.Error("expected Revoke to reject an empty member or group ID, got nil error") + } + }) + } +} + // TestClmGroupBuilder_Grant_SurvivesIdentityOnlyEntitlementResource is a regression test: // passes an identity-only entitlement Resource (pebble's V3EntitlementToV2 shape — no // profile, no annotations, nothing but Id) to confirm Grant resolves the groupHref to From e1d1c6677cb4adcc9c8766f9dfeb2b23aed8330c Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 19:02:08 -0300 Subject: [PATCH 23/41] fix: reject empty folder ID in clmFolderBuilder Grant/Revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/connector/clm_folders.go | 13 +++++++++++++ pkg/connector/clm_folders_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index d898a131..dc05b41b 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -199,6 +199,14 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en if principal.Id.Resource == "" { return nil, nil, fmt.Errorf("baton-docusign: granting CLM folder security: principal missing native ID") } + // folderID has the same theoretical gap: parseIntoClmFolderResource derives it via + // clmIDFromHref(folder.Href) with no non-empty check, so an empty Href would produce + // a folder resource with an empty ID. An empty folderID here would hit the + // collection-root path (buildClmClientURL's "/v2/%s/folders/%s" with an empty last + // segment) instead of failing clearly client-side. + if folderID == "" { + return nil, nil, fmt.Errorf("baton-docusign: granting CLM folder security: folder missing native ID") + } folder, getAnnos, err := f.client.GetFolderFresh(ctx, folderID, "Security") if err != nil { @@ -335,6 +343,11 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno if principal.Id.Resource == "" { return nil, fmt.Errorf("baton-docusign: revoking CLM folder security: principal missing native ID") } + // Same reasoning as Grant's identical guard: an empty folderID would hit the CLM + // API's folders collection root instead of failing clearly client-side. + if folderID == "" { + return nil, fmt.Errorf("baton-docusign: revoking CLM folder security: folder missing native ID") + } folder, getAnnos, err := f.client.GetFolderFresh(ctx, folderID, "Security") if err != nil { diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 9498dfaf..2f823d0c 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -546,6 +546,31 @@ func TestClmFolderBuilder_GrantAndRevoke_RejectEmptyPrincipalID(t *testing.T) { } } +// TestClmFolderBuilder_GrantAndRevoke_RejectEmptyFolderID is a regression test for the +// same class of bug as the principal-ID guard above, applied to the folder itself: +// parseIntoClmFolderResource derives a clm_folder's ID via clmIDFromHref(folder.Href) +// with no non-empty check, so an empty folderID must be rejected before it reaches +// GetFolderFresh/PatchFolderSecurity — otherwise it would build a URL hitting the +// folders collection root instead of failing clearly client-side. +func TestClmFolderBuilder_GrantAndRevoke_RejectEmptyFolderID(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmFolderBuilder(c) + ctx := context.Background() + + principal := clmIdentityOnlyResource(clmMemberResourceType, "member-dave") + emptyFolder := clmIdentityOnlyResource(clmFolderResourceType, "") + ent := &v2.Entitlement{Slug: "view", Resource: emptyFolder} + + if _, _, err := b.Grant(ctx, principal, ent); err == nil { + t.Error("expected Grant to reject an empty folder ID, got nil error") + } + + grantObj := &v2.Grant{Principal: principal, Entitlement: ent} + if _, err := b.Revoke(ctx, grantObj); err == nil { + t.Error("expected Revoke to reject an empty folder ID, got nil error") + } +} + // TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch covers the // clmPreferredHref branch TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal // doesn't: folder-templates always starts with no security entries, so every Grant From af12f7317df6f50994233c92487cc0f30e3a10a5 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 19:14:31 -0300 Subject: [PATCH 24/41] fix: use codes.InvalidArgument for provisioning validation errors; reject trailing-slash sample hrefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ".../" instead of ".../groups/". Now rejects any sample already ending in "/" outright, with a regression test. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_folders.go | 10 ++++++---- pkg/connector/clm_groups.go | 6 ++++-- pkg/connector/helper.go | 25 ++++++++++++++++--------- pkg/connector/helper_test.go | 9 +++++++++ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index dc05b41b..d957fb2f 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -12,6 +12,8 @@ import ( rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmFolderBuilder)(nil) @@ -197,7 +199,7 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en // branch compares principal.Id.Resource to Item by exact string with nothing else // upstream to reject an empty value — this catches all three uniformly. if principal.Id.Resource == "" { - return nil, nil, fmt.Errorf("baton-docusign: granting CLM folder security: principal missing native ID") + return nil, nil, status.Errorf(codes.InvalidArgument, "baton-docusign: granting CLM folder security: principal missing native ID") } // folderID has the same theoretical gap: parseIntoClmFolderResource derives it via // clmIDFromHref(folder.Href) with no non-empty check, so an empty Href would produce @@ -205,7 +207,7 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en // collection-root path (buildClmClientURL's "/v2/%s/folders/%s" with an empty last // segment) instead of failing clearly client-side. if folderID == "" { - return nil, nil, fmt.Errorf("baton-docusign: granting CLM folder security: folder missing native ID") + return nil, nil, status.Errorf(codes.InvalidArgument, "baton-docusign: granting CLM folder security: folder missing native ID") } folder, getAnnos, err := f.client.GetFolderFresh(ctx, folderID, "Security") @@ -341,12 +343,12 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno // just as wrongly match an entry with an empty Item. Grant carries the identical // guard for the identical reason — keep the two in sync. if principal.Id.Resource == "" { - return nil, fmt.Errorf("baton-docusign: revoking CLM folder security: principal missing native ID") + return nil, status.Errorf(codes.InvalidArgument, "baton-docusign: revoking CLM folder security: principal missing native ID") } // Same reasoning as Grant's identical guard: an empty folderID would hit the CLM // API's folders collection root instead of failing clearly client-side. if folderID == "" { - return nil, fmt.Errorf("baton-docusign: revoking CLM folder security: folder missing native ID") + return nil, status.Errorf(codes.InvalidArgument, "baton-docusign: revoking CLM folder security: folder missing native ID") } folder, getAnnos, err := f.client.GetFolderFresh(ctx, folderID, "Security") diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 8ba1d0d4..cb6866bd 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -12,6 +12,8 @@ import ( rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmGroupBuilder)(nil) @@ -156,7 +158,7 @@ func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent // "already a member" check before clmPreferredHref's own empty-id guard ever runs) — // same class of bug as clm_folders.go's Grant/Revoke, guarded the same way. if memberID == "" || groupID == "" { - return nil, nil, fmt.Errorf("baton-docusign: granting CLM group membership: member or group missing native ID") + return nil, nil, status.Errorf(codes.InvalidArgument, "baton-docusign: granting CLM group membership: member or group missing native ID") } // Don't require the group's Href off ent.Resource: the pebble storage engine hydrates @@ -215,7 +217,7 @@ func (g *clmGroupBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (annot // from remainingGroups — and PutMemberGroups is a full-replace, so that unrelated // membership would actually be removed from the real account. if memberID == "" || groupID == "" { - return nil, fmt.Errorf("baton-docusign: revoking CLM group membership: member or group missing native ID") + return nil, status.Errorf(codes.InvalidArgument, "baton-docusign: revoking CLM group membership: member or group missing native ID") } currentGroups, annos, err := g.client.GetMemberGroups(ctx, memberID) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index b5e01c64..39adddda 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -2,7 +2,6 @@ package connector import ( "context" - "fmt" "net/url" "strings" @@ -104,21 +103,29 @@ func clmHrefWithID(sampleHref, newID string) (string, error) { // segment at all — a malformed href that goes on to be sent as-is inside a // PatchFolderSecurity/PatchMemberGroups request body, surfacing (if at all) as an // opaque remote validation failure with no link back to "the ID was empty." - return "", fmt.Errorf("baton-docusign: cannot derive a sibling href from %q — newID is empty", sampleHref) + return "", status.Errorf(codes.InvalidArgument, "baton-docusign: cannot derive a sibling href from %q — newID is empty", sampleHref) } - trimmed := strings.TrimSuffix(sampleHref, "/") - idx := strings.LastIndex(trimmed, "/") + // A sample already ending in "/" has no ID segment to replace — the same degenerate + // shape the empty-newID check above guards against on the output side. Trimming it + // instead of rejecting it would silently drop the real collection segment: e.g. + // ".../groups/" trims to ".../groups", and the LastIndex split below would then + // wrongly treat "groups" as the ID to replace, producing ".../" with the + // actual collection segment gone. + if strings.HasSuffix(sampleHref, "/") { + 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(sampleHref, "/") if idx == -1 { - return "", fmt.Errorf("baton-docusign: cannot derive a sibling href from %q — no path separator found", sampleHref) + return "", status.Errorf(codes.InvalidArgument, "baton-docusign: cannot derive a sibling href from %q — no path separator found", sampleHref) } // A bare scheme+host like "https://clm.example.com" also contains a "/" (the one // separating scheme from host), so the LastIndex check above alone accepts it — // producing a garbage "https://" href with no real path. Reject a sample with // no path at all; a single-segment path like "https://host/group-old" is accepted. - if u, err := url.Parse(trimmed); err != nil || u.Path == "" || u.Path == "/" { - return "", fmt.Errorf("baton-docusign: cannot derive a sibling href from %q — no path segment found", sampleHref) + if u, err := url.Parse(sampleHref); err != nil || u.Path == "" || u.Path == "/" { + return "", status.Errorf(codes.InvalidArgument, "baton-docusign: cannot derive a sibling href from %q — no path segment found", sampleHref) } - return trimmed[:idx+1] + newID, nil + return sampleHref[:idx+1] + newID, nil } // clmPreferredHref resolves the href to send in a WRITE targeting id: it prefers @@ -143,7 +150,7 @@ func clmPreferredHref(ctx context.Context, id string, sampleHrefs []string, deri // same malformed trailing-slash href this function exists to prevent. Reject here // once, before either path runs, rather than relying on every deriveFallback // closure to check it independently. - return "", fmt.Errorf("baton-docusign: cannot resolve a CLM href — id is empty") + return "", status.Errorf(codes.InvalidArgument, "baton-docusign: cannot resolve a CLM href — id is empty") } var lastErr error for _, sample := range sampleHrefs { diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index 09bc4e9d..0b7eec4a 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -62,6 +62,15 @@ func TestClmHrefWithID(t *testing.T) { if _, err := clmHrefWithID("https://clm.example.com/v2/acct-1/groups/group-old", ""); err == nil { t.Error("expected an error for an empty newID") } + + // A sample already ending in "/" (e.g. a collection root, or some other degenerate + // shape) must be rejected outright rather than trimmed: trimming would make + // ".../groups/" indistinguishable from ".../groups" (a href whose ID happens to be + // "groups"), so the ID-replacement below would wrongly drop the real collection + // segment and produce ".../" instead of ".../groups/". + if _, err := clmHrefWithID("https://clm.example.com/v2/acct-1/groups/", "group-new"); err == nil { + t.Error("expected an error for a sample href with a trailing slash (no ID segment)") + } } func TestClmPreferredHref(t *testing.T) { From 124bef5d7c646efad573231b14813947c0600d5a Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 19:30:09 -0300 Subject: [PATCH 25/41] fix: use codes.InvalidArgument for remaining plain-Errorf validation 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 --- pkg/connector/clm_folders.go | 6 +++--- pkg/connector/clm_groups.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index d957fb2f..2f46dc3f 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -190,7 +190,7 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, ent *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { accessType, ok := clmAccessTypeForSlug(ent.Slug) if !ok { - return nil, nil, fmt.Errorf("baton-docusign: unknown CLM folder entitlement slug %q", ent.Slug) + return nil, nil, status.Errorf(codes.InvalidArgument, "baton-docusign: unknown CLM folder entitlement slug %q", ent.Slug) } folderID := ent.Resource.Id.Resource @@ -269,7 +269,7 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en write.Users = append(write.Users, client.ClmUserSecurityEntry{AccessType: accessType, Href: memberHref}) } 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) } patchAnnos, err := f.client.PatchFolderSecurity(ctx, folderID, write) @@ -383,7 +383,7 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno } write.Users[i].AccessType = client.ClmAccessTypeNoAccess default: - return nil, fmt.Errorf("baton-docusign: invalid principal type for CLM folder security: %s", principal.Id.ResourceType) + return nil, status.Errorf(codes.InvalidArgument, "baton-docusign: invalid principal type for CLM folder security: %s", principal.Id.ResourceType) } patchAnnos, err := f.client.PatchFolderSecurity(ctx, folderID, write) diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index cb6866bd..318e0302 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -147,7 +147,7 @@ func (g *clmGroupBuilder) Grants(ctx context.Context, groupResource *v2.Resource // group appended (additive per the confirmed Members.Patch semantics). func (g *clmGroupBuilder) Grant(ctx context.Context, principal *v2.Resource, ent *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { if principal.Id.ResourceType != clmMemberResourceType.Id { - return nil, nil, fmt.Errorf("baton-docusign: invalid principal type: expected %s, got %s", clmMemberResourceType.Id, principal.Id.ResourceType) + return nil, nil, status.Errorf(codes.InvalidArgument, "baton-docusign: invalid principal type: expected %s, got %s", clmMemberResourceType.Id, principal.Id.ResourceType) } memberID := principal.Id.Resource From 1165916b4d76a421780a9282b25e76a7f5b35259 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 19:32:33 -0300 Subject: [PATCH 26/41] fix: derive sibling hrefs from the parsed URL path, not the raw string 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 --- pkg/connector/helper.go | 28 +++++++++++++++++----------- pkg/connector/helper_test.go | 12 ++++++++++++ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 39adddda..f141bfe1 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -105,27 +105,33 @@ func clmHrefWithID(sampleHref, newID string) (string, error) { // opaque remote validation failure with no link back to "the ID was empty." return "", status.Errorf(codes.InvalidArgument, "baton-docusign: cannot derive a sibling href from %q — newID is empty", sampleHref) } - // A sample already ending in "/" has no ID segment to replace — the same degenerate + // Split on the parsed URL's Path, not the raw string: a "/" inside a query string or + // fragment (e.g. ".../groups/g1?filter=a/b") would otherwise win over the real path + // separator, corrupting the query instead of replacing the ID segment. A bare + // scheme+host like "https://clm.example.com" parses with an empty Path, so this also + // covers that case — no real path at all to derive from. + u, err := url.Parse(sampleHref) + if err != nil || u.Path == "" { + return "", status.Errorf(codes.InvalidArgument, "baton-docusign: cannot derive a sibling href from %q — no path segment found", sampleHref) + } + // A Path already ending in "/" has no ID segment to replace — the same degenerate // shape the empty-newID check above guards against on the output side. Trimming it // instead of rejecting it would silently drop the real collection segment: e.g. // ".../groups/" trims to ".../groups", and the LastIndex split below would then // wrongly treat "groups" as the ID to replace, producing ".../" with the // actual collection segment gone. - if strings.HasSuffix(sampleHref, "/") { + 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(sampleHref, "/") + idx := strings.LastIndex(u.Path, "/") if idx == -1 { + // A relative, scheme-less sample (e.g. "no-path-separator") parses as an opaque + // Path with no leading "/" at all — url.Parse doesn't error on it, so this catches + // 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) } - // A bare scheme+host like "https://clm.example.com" also contains a "/" (the one - // separating scheme from host), so the LastIndex check above alone accepts it — - // producing a garbage "https://" href with no real path. Reject a sample with - // no path at all; a single-segment path like "https://host/group-old" is accepted. - if u, err := url.Parse(sampleHref); err != nil || u.Path == "" || u.Path == "/" { - return "", status.Errorf(codes.InvalidArgument, "baton-docusign: cannot derive a sibling href from %q — no path segment found", sampleHref) - } - return sampleHref[:idx+1] + newID, nil + u.Path = u.Path[:idx+1] + newID + return u.String(), nil } // clmPreferredHref resolves the href to send in a WRITE targeting id: it prefers diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index 0b7eec4a..d9532efc 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -71,6 +71,18 @@ func TestClmHrefWithID(t *testing.T) { if _, err := clmHrefWithID("https://clm.example.com/v2/acct-1/groups/", "group-new"); err == nil { t.Error("expected an error for a sample href with a trailing slash (no ID segment)") } + + // Regression test: a "/" inside a query string must not be mistaken for the real + // path separator. Splitting on the raw string instead of the parsed URL's Path used + // to corrupt the query (".../group-old?filter=a/b" became ".../group-old?filter=a/" + // + newID) instead of replacing the actual ID segment. + got, err = clmHrefWithID("https://clm.example.com/v2/acct-1/groups/group-old?filter=a/b", "group-new") + if err != nil { + t.Fatalf("clmHrefWithID: %v", err) + } + if want := "https://clm.example.com/v2/acct-1/groups/group-new?filter=a/b"; got != want { + t.Errorf("clmHrefWithID = %q, want %q", got, want) + } } func TestClmPreferredHref(t *testing.T) { From d0f44d7468d23fe59abdba4e7c04e2abe1020ed7 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 18 Aug 2026 01:43:04 -0300 Subject: [PATCH 27/41] fix: serialize CI jobs that hit the shared DocuSign demo account 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 --- .github/workflows/ci.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bc83b7d6..32a2f22f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,6 +5,16 @@ on: push: branches: - main +# Workflow-level (not per-job): all three jobs below hit the same shared DocuSign demo +# account, and running two runs' Grant/Revoke cycles concurrently races on that account's +# real state (one run's mid-cycle Grant/Revoke can make another run's "should be zero +# grants after Revoke" assertion fail). A per-job concurrency block only protects a +# *running* job from cancellation — GitHub Actions still cancels a *pending* job in the +# same group when a newer one queues. Declaring it once here makes the whole +# needs-chained run (all three jobs) queue/cancel as one unit against the shared group. +concurrency: + group: docusign-demo-account + cancel-in-progress: false jobs: test-groups: runs-on: ubuntu-latest From 1a72aecbbf5d7e260f41f81eb3ee5727489147ed Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 18 Aug 2026 17:35:13 -0300 Subject: [PATCH 28/41] fix: log clmPreferredHref's no-samples fallback at Debug 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 --- pkg/connector/helper.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index f141bfe1..cc39ffbd 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -169,6 +169,14 @@ func clmPreferredHref(ctx context.Context, id string, sampleHrefs []string, deri if lastErr != nil { ctxzap.Extract(ctx).Debug("baton-docusign: every sample href failed to derive a sibling href, falling back to a base-URL-derived href", zap.Int("sample_count", len(sampleHrefs)), zap.Error(lastErr)) + } else { + // No samples at all (as opposed to samples that failed to parse, above) — the + // routine case for e.g. a group/member with no other memberships yet. Still + // worth a Debug line: deriveFallback's shape is unverified against a live + // tenant, so if CLM ever rejects or silently ignores the derived href, this is + // the only record that a guess (not a real sample) produced it. + ctxzap.Extract(ctx).Debug("baton-docusign: no sample href available, falling back to a base-URL-derived href", + zap.String("id", id)) } return deriveFallback() } From a4c7cb82d9e752b2a5e58f492764d16ec9deef9b Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 19 Aug 2026 16:49:21 -0300 Subject: [PATCH 29/41] fix: silence staticcheck SA5011 false positives in clm_folders_test.go 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 --- pkg/connector/clm_folders_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 2f823d0c..b3b1d73a 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -620,6 +620,7 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te } if found == nil { t.Fatalf("expected a group-ops entry with the sample-derived Href %q, got %+v", wantHref, groups) + return } if found.AccessType != client.ClmAccessTypeView { t.Errorf("expected View AccessType, got %q", found.AccessType) @@ -645,6 +646,7 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te } if found == nil { t.Fatalf("expected a member-dave entry with the sample-derived Href %q, got %+v", wantHref, users) + return } if found.AccessType != client.ClmAccessTypeView { t.Errorf("expected View AccessType, got %q", found.AccessType) From 71407ba5811f39fa9a287b9a9062ec7b9bc5102c Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 19 Aug 2026 16:52:01 -0300 Subject: [PATCH 30/41] fix: strip query/fragment from clmHrefWithID's derived href 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 --- pkg/connector/helper.go | 6 ++++++ pkg/connector/helper_test.go | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index cc39ffbd..369f6a4c 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -131,6 +131,12 @@ func clmHrefWithID(sampleHref, newID string) (string, error) { 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 + // Clear any query/fragment carried over from the sample — the derived href + // identifies a different object than the sample's, so a leftover query string or + // fragment (e.g. ".../group-old?filter=a/b" deriving ".../group-new?filter=a/b") + // would misrepresent the sample's, not the target's, state in a write body. + u.RawQuery = "" + u.Fragment = "" return u.String(), nil } diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index d9532efc..b6ac2361 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -75,12 +75,15 @@ func TestClmHrefWithID(t *testing.T) { // Regression test: a "/" inside a query string must not be mistaken for the real // path separator. Splitting on the raw string instead of the parsed URL's Path used // to corrupt the query (".../group-old?filter=a/b" became ".../group-old?filter=a/" - // + newID) instead of replacing the actual ID segment. + // + newID) instead of replacing the actual ID segment. The derived href also drops + // the sample's query entirely — it identifies a different object, so the sample's + // query/fragment (irrelevant, and potentially misleading, for the target) is cleared + // rather than carried over. got, err = clmHrefWithID("https://clm.example.com/v2/acct-1/groups/group-old?filter=a/b", "group-new") if err != nil { t.Fatalf("clmHrefWithID: %v", err) } - if want := "https://clm.example.com/v2/acct-1/groups/group-new?filter=a/b"; got != want { + if want := "https://clm.example.com/v2/acct-1/groups/group-new"; got != want { t.Errorf("clmHrefWithID = %q, want %q", got, want) } } From e3951252b9ed37bb36790fb68395b0c5cb566027 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 19 Aug 2026 17:05:50 -0300 Subject: [PATCH 31/41] fix: propagate getAnnos in default branches, fix stale clmIDFromHref doc 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 --- pkg/connector/clm_folders.go | 4 ++-- pkg/connector/helper.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 2f46dc3f..513ba142 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -269,7 +269,7 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en write.Users = append(write.Users, client.ClmUserSecurityEntry{AccessType: accessType, Href: memberHref}) } default: - return nil, nil, status.Errorf(codes.InvalidArgument, "baton-docusign: invalid principal type for CLM folder security: %s", principal.Id.ResourceType) + return nil, getAnnos, status.Errorf(codes.InvalidArgument, "baton-docusign: invalid principal type for CLM folder security: %s", principal.Id.ResourceType) } patchAnnos, err := f.client.PatchFolderSecurity(ctx, folderID, write) @@ -383,7 +383,7 @@ func (f *clmFolderBuilder) Revoke(ctx context.Context, grantObj *v2.Grant) (anno } write.Users[i].AccessType = client.ClmAccessTypeNoAccess default: - return nil, status.Errorf(codes.InvalidArgument, "baton-docusign: invalid principal type for CLM folder security: %s", principal.Id.ResourceType) + return getAnnos, status.Errorf(codes.InvalidArgument, "baton-docusign: invalid principal type for CLM folder security: %s", principal.Id.ResourceType) } patchAnnos, err := f.client.PatchFolderSecurity(ctx, folderID, write) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 369f6a4c..1795e533 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -85,9 +85,9 @@ func isOptInFeatureUnavailableError(err error) bool { } // 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. +// client.IDFromHref's doc. pkg/client/clmtest can't import pkg/connector, so the single +// definition lives in pkg/client and both packages delegate to it instead of +// maintaining two copies. func clmIDFromHref(href string) string { return client.IDFromHref(href) } From 004b78b501eaf3db725e3992a5472a68a7f4cd91 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 19 Aug 2026 22:18:05 -0300 Subject: [PATCH 32/41] fix: request impersonation scope for CLM, parse CLM's real error envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/client/clm_models.go | 21 +++++++++++++----- pkg/client/clm_models_test.go | 42 +++++++++++++++++++++++++++++++++++ pkg/client/oauth.go | 28 +++++++++++------------ pkg/client/oauth_test.go | 15 +++++++------ 4 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 pkg/client/clm_models_test.go diff --git a/pkg/client/clm_models.go b/pkg/client/clm_models.go index aae60104..eba52cb5 100644 --- a/pkg/client/clm_models.go +++ b/pkg/client/clm_models.go @@ -17,18 +17,27 @@ type ClmPage struct { Total int `json:"Total"` } -// ClmErrorResponse is CLM's error envelope. DocuSign's API reference does not document -// an error response shape, so this is intentionally loose; callers should log the raw -// body if fields don't populate as expected. +// ClmErrorResponse is CLM's error envelope — confirmed via a live 401 from a real CLM +// tenant: {"Error":{"HttpStatusCode":401,"UserMessage":"Access Denied", +// "DeveloperMessage":"Access Denied","ErrorCode":103,"ReferenceId":"..."}}. Errors are +// nested under "Error", not a top-level "Message" field. type ClmErrorResponse struct { - Msg string `json:"Message"` + Error struct { + UserMessage string `json:"UserMessage"` + DeveloperMessage string `json:"DeveloperMessage"` + ErrorCode int `json:"ErrorCode"` + ReferenceId string `json:"ReferenceId"` + } `json:"Error"` } func (e *ClmErrorResponse) Message() string { - if e.Msg == "" { + if e.Error.UserMessage == "" && e.Error.DeveloperMessage == "" { return "unknown CLM API error" } - return fmt.Sprintf("CLM API error: %s", e.Msg) + 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) } // ClmFolder represents a CLM Folder object. diff --git a/pkg/client/clm_models_test.go b/pkg/client/clm_models_test.go new file mode 100644 index 00000000..7cdac240 --- /dev/null +++ b/pkg/client/clm_models_test.go @@ -0,0 +1,42 @@ +package client + +import ( + "encoding/json" + "testing" +) + +// TestClmErrorResponse_Message is a regression test: a live 401 from a real CLM tenant +// returned {"Error":{"UserMessage":"Access Denied","DeveloperMessage":"Access +// Denied","ErrorCode":103,...}} — the previous top-level "Message" field assumption +// never matched this shape, so every real CLM error surfaced as "unknown CLM API +// error" regardless of what CLM actually reported. +func TestClmErrorResponse_Message(t *testing.T) { + t.Run("parses a real CLM error envelope", func(t *testing.T) { + body := `{"Error":{"HttpStatusCode":401,"UserMessage":"Access Denied","DeveloperMessage":"Access Denied","ErrorCode":103,"ReferenceId":"4c2e455a-9d5e-42c8-8328-cf25bfec684f"}}` + var e ClmErrorResponse + if err := json.Unmarshal([]byte(body), &e); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if want := "CLM API error 103: Access Denied"; e.Message() != want { + t.Errorf("Message() = %q, want %q", e.Message(), want) + } + }) + + t.Run("includes DeveloperMessage when it differs from UserMessage", func(t *testing.T) { + body := `{"Error":{"UserMessage":"Access Denied","DeveloperMessage":"token missing impersonation scope","ErrorCode":103}}` + var e ClmErrorResponse + if err := json.Unmarshal([]byte(body), &e); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if want := "CLM API error 103: Access Denied (token missing impersonation scope)"; e.Message() != want { + t.Errorf("Message() = %q, want %q", e.Message(), want) + } + }) + + t.Run("falls back to a generic message when the body is empty", func(t *testing.T) { + var e ClmErrorResponse + if want := "unknown CLM API error"; e.Message() != want { + t.Errorf("Message() = %q, want %q", e.Message(), want) + } + }) +} diff --git a/pkg/client/oauth.go b/pkg/client/oauth.go index c792e151..11dd6e69 100644 --- a/pkg/client/oauth.go +++ b/pkg/client/oauth.go @@ -18,20 +18,20 @@ var ( tokenURLProd = "https://account.docusign.com/oauth/token" //nolint:gosec // token URL does not contain sensitive credentials. defaultScope = "signature" // clmScopes are additional OAuth scopes required to call the DocuSign CLM API. - // "spring_read"/"spring_write" are confirmed via the CLM Authentication Overview - // docs (combine with eSignature's "signature" scope on the same authorization - // request); "springcm_read"/"springcm_write" are ALSO requested defensively - // because a separate DocuSign docs page (for the account-discovery endpoint - // ensureClmInitialized calls, auth.springcm.com/api/v2/{accountId}/account) lists - // the required scope as "springcm_read" instead — possibly a docs typo, possibly - // a genuinely distinct scope given CLM/SpringCM's inconsistent legacy naming, and - // unconfirmed against a real CLM tenant either way. Requesting all 4 covers both - // possibilities at once: an unrecognized/unauthorized scope name is expected to be - // dropped by DocuSign's OAuth consent rather than rejecting the whole - // authorization (standard OAuth2 behavior), so this is a low-risk hedge, not a - // confirmed-safe one — the same "check every plausible candidate" pattern - // ensureClmInitialized uses for the base-URL response field name. - clmScopes = []string{"spring_read", "spring_write", "springcm_read", "springcm_write"} + // "impersonation"/"spring_read"/"spring_write" match the CLM Authentication + // Overview docs' example authorization request exactly. "impersonation" was + // missing here until a live test against a real CLM tenant confirmed the gap: + // with only spring_read/spring_write granted, ensureClmInitialized's account + // discovery call succeeded (200, real base URLs), but every subsequent CLM data + // call (ListGroups, ListPermissionSets) failed with 401 and CLM's own error body + // ({"Error":{"UserMessage":"Access Denied",...,"ErrorCode":103}}) — i.e. CLM + // gates data-plane access on impersonation consent separately from account + // discovery. "springcm_read"/"springcm_write" are kept as an additional + // defensive hedge for the same reason as before (a separate docs page names the + // 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"} ) // buildScopes returns the OAuth scopes to request, adding CLM scopes when includeClm is set. diff --git a/pkg/client/oauth_test.go b/pkg/client/oauth_test.go index e0a46ce1..c68d8235 100644 --- a/pkg/client/oauth_test.go +++ b/pkg/client/oauth_test.go @@ -5,11 +5,12 @@ import ( "testing" ) -// TestBuildScopes_RequestsBothClmScopeNameVariants is a regression test: the CLM -// scope name is unconfirmed between "spring_read"/"spring_write" (the general CLM API -// docs) and "springcm_read"/"springcm_write" (the account-discovery endpoint's own -// docs) — see clmScopes' doc for why both are requested defensively. -func TestBuildScopes_RequestsBothClmScopeNameVariants(t *testing.T) { +// TestBuildScopes_RequestsImpersonationAndBothClmScopeNameVariants is a regression +// test: "impersonation" is required alongside "spring_read"/"spring_write" (confirmed +// live — see clmScopes' doc), and "springcm_read"/"springcm_write" are still requested +// defensively for the account-discovery endpoint's own docs, which name the scope +// "springcm_read" instead. +func TestBuildScopes_RequestsImpersonationAndBothClmScopeNameVariants(t *testing.T) { t.Run("includeClm false requests only the base scope", func(t *testing.T) { got := buildScopes(false) want := []string{"signature"} @@ -18,9 +19,9 @@ func TestBuildScopes_RequestsBothClmScopeNameVariants(t *testing.T) { } }) - t.Run("includeClm true requests all 4 CLM scope name candidates", func(t *testing.T) { + t.Run("includeClm true requests impersonation and all 4 CLM scope name candidates", func(t *testing.T) { got := buildScopes(true) - want := []string{"signature", "spring_read", "spring_write", "springcm_read", "springcm_write"} + want := []string{"signature", "impersonation", "spring_read", "spring_write", "springcm_read", "springcm_write"} if !reflect.DeepEqual(got, want) { t.Errorf("buildScopes(true) = %v, want %v", got, want) } From fd579b22ce8e3bafa11fbc6fa5af665cfe5cb4b4 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 20 Aug 2026 00:26:50 -0300 Subject: [PATCH 33/41] fix: Message() empty-UserMessage fallback, stale impersonation-scope doc 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 --- pkg/client/clm_client.go | 2 +- pkg/client/clm_models.go | 12 ++++++++---- pkg/client/clm_models_test.go | 11 +++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index a2b636b8..f1f94ea3 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -1,7 +1,7 @@ // Package client — DocuSign CLM (Contract Lifecycle Management) support. // // CLM is a separate DocuSign product from eSignature, on a different host, with its -// own OAuth scopes ("spring_read"/"spring_write", see oauth.go) and a different Object +// own OAuth scopes ("impersonation"/"spring_read"/"spring_write", see oauth.go) and a different Object // API surface. Endpoints below are derived from DocuSign's CLM API reference (method // tables and request/response schemas). Validate against cmd/test-server during // development; a production CLM tenant was not available to exercise this integration diff --git a/pkg/client/clm_models.go b/pkg/client/clm_models.go index eba52cb5..e9b9a78b 100644 --- a/pkg/client/clm_models.go +++ b/pkg/client/clm_models.go @@ -31,13 +31,17 @@ type ClmErrorResponse struct { } func (e *ClmErrorResponse) Message() string { - if e.Error.UserMessage == "" && e.Error.DeveloperMessage == "" { + primary := e.Error.UserMessage + if primary == "" { + primary = e.Error.DeveloperMessage + } + if primary == "" { return "unknown CLM API error" } - 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) + if e.Error.UserMessage != "" && e.Error.DeveloperMessage != "" && e.Error.DeveloperMessage != e.Error.UserMessage { + return fmt.Sprintf("CLM API error %d: %s (%s)", e.Error.ErrorCode, primary, e.Error.DeveloperMessage) } - return fmt.Sprintf("CLM API error %d: %s", e.Error.ErrorCode, e.Error.UserMessage) + return fmt.Sprintf("CLM API error %d: %s", e.Error.ErrorCode, primary) } // ClmFolder represents a CLM Folder object. diff --git a/pkg/client/clm_models_test.go b/pkg/client/clm_models_test.go index 7cdac240..0e4e74df 100644 --- a/pkg/client/clm_models_test.go +++ b/pkg/client/clm_models_test.go @@ -39,4 +39,15 @@ func TestClmErrorResponse_Message(t *testing.T) { t.Errorf("Message() = %q, want %q", e.Message(), want) } }) + + t.Run("uses DeveloperMessage as the primary text when UserMessage is empty", func(t *testing.T) { + body := `{"Error":{"DeveloperMessage":"token missing impersonation scope","ErrorCode":103}}` + var e ClmErrorResponse + if err := json.Unmarshal([]byte(body), &e); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if want := "CLM API error 103: token missing impersonation scope"; e.Message() != want { + t.Errorf("Message() = %q, want %q", e.Message(), want) + } + }) } From a090a700b42d32ffe4ba6c67e8c7cdea2d0f0466 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 20 Aug 2026 16:17:30 -0300 Subject: [PATCH 34/41] =?UTF-8?q?fix:=20revert=20impersonation=20scope=20?= =?UTF-8?q?=E2=80=94=20confirmed=20JWT-Grant-only,=20not=20the=20real=20ca?= =?UTF-8?q?use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/client/clm_client.go | 11 ++++++++--- pkg/client/oauth.go | 28 +++++++++++++--------------- pkg/client/oauth_test.go | 16 ++++++++-------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index f1f94ea3..b69dccef 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -1,11 +1,16 @@ // Package client — DocuSign CLM (Contract Lifecycle Management) support. // // CLM is a separate DocuSign product from eSignature, on a different host, with its -// own OAuth scopes ("impersonation"/"spring_read"/"spring_write", see oauth.go) and a different Object +// own OAuth scopes ("spring_read"/"spring_write", see oauth.go) and a different Object // API surface. Endpoints below are derived from DocuSign's CLM API reference (method // tables and request/response schemas). Validate against cmd/test-server during -// development; a production CLM tenant was not available to exercise this integration -// directly. +// development. Live-tested against a real (demo/UAT) CLM tenant: account discovery +// succeeded, but every data-plane call (ListGroups, ListPermissionSets) returned CLM's +// own 401 "Access Denied" (ErrorCode 103) even with the scopes DocuSign's own CLM +// Authentication Overview docs specify for Authorization Code Grant. The demo tenant's +// admin status was fixed mid-investigation but not yet re-tested — see the CLM auth +// docs' repeated note that CLM API access requires a production account; this may be a +// demo/UAT-environment limitation rather than a scope or permission bug. // // # API Endpoints Used // diff --git a/pkg/client/oauth.go b/pkg/client/oauth.go index 11dd6e69..01576516 100644 --- a/pkg/client/oauth.go +++ b/pkg/client/oauth.go @@ -17,21 +17,19 @@ var ( authURLProd = "https://account.docusign.com/oauth/auth" tokenURLProd = "https://account.docusign.com/oauth/token" //nolint:gosec // token URL does not contain sensitive credentials. defaultScope = "signature" - // clmScopes are additional OAuth scopes required to call the DocuSign CLM API. - // "impersonation"/"spring_read"/"spring_write" match the CLM Authentication - // Overview docs' example authorization request exactly. "impersonation" was - // missing here until a live test against a real CLM tenant confirmed the gap: - // with only spring_read/spring_write granted, ensureClmInitialized's account - // discovery call succeeded (200, real base URLs), but every subsequent CLM data - // call (ListGroups, ListPermissionSets) failed with 401 and CLM's own error body - // ({"Error":{"UserMessage":"Access Denied",...,"ErrorCode":103}}) — i.e. CLM - // gates data-plane access on impersonation consent separately from account - // discovery. "springcm_read"/"springcm_write" are kept as an additional - // defensive hedge for the same reason as before (a separate docs page names the - // 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"} + // clmScopes are the OAuth scopes required to call the DocuSign CLM API, per + // DocuSign's own CLM Authentication Overview docs (pasted live, since the site is + // JS-rendered and unreachable by automated tools): the "Required scopes" section's + // Authorization Code Grant example is exactly signature+spring_read+spring_write, + // with an explicit note that "impersonation" is a JWT-Grant-only requirement — NOT + // needed here. An earlier version of this code added "impersonation" and + // "springcm_read"/"springcm_write" based on a live 401 ("Access Denied", CLM + // ErrorCode 103) against a real CLM tenant plus unreliable secondary docs sources; + // that live tenant already had exactly this scope set granted when it failed, so + // the 401 is not a scope problem — see clm_client.go's package doc for the current + // working theory (demo/UAT environment limitation, per CLM's docs requiring a + // production account). + clmScopes = []string{"spring_read", "spring_write"} ) // buildScopes returns the OAuth scopes to request, adding CLM scopes when includeClm is set. diff --git a/pkg/client/oauth_test.go b/pkg/client/oauth_test.go index c68d8235..4f2dee70 100644 --- a/pkg/client/oauth_test.go +++ b/pkg/client/oauth_test.go @@ -5,12 +5,12 @@ import ( "testing" ) -// TestBuildScopes_RequestsImpersonationAndBothClmScopeNameVariants is a regression -// test: "impersonation" is required alongside "spring_read"/"spring_write" (confirmed -// live — see clmScopes' doc), and "springcm_read"/"springcm_write" are still requested -// defensively for the account-discovery endpoint's own docs, which name the scope -// "springcm_read" instead. -func TestBuildScopes_RequestsImpersonationAndBothClmScopeNameVariants(t *testing.T) { +// TestBuildScopes_MatchesDocuSignsAuthCodeGrantExample is a regression test: the scope +// list must match DocuSign's own CLM Authentication Overview docs' Authorization Code +// Grant example exactly (signature+spring_read+spring_write) — see clmScopes' doc for +// why "impersonation" (JWT-Grant-only) and "springcm_read"/"springcm_write" +// (unrecognized) were tried and removed. +func TestBuildScopes_MatchesDocuSignsAuthCodeGrantExample(t *testing.T) { t.Run("includeClm false requests only the base scope", func(t *testing.T) { got := buildScopes(false) want := []string{"signature"} @@ -19,9 +19,9 @@ func TestBuildScopes_RequestsImpersonationAndBothClmScopeNameVariants(t *testing } }) - t.Run("includeClm true requests impersonation and all 4 CLM scope name candidates", func(t *testing.T) { + t.Run("includeClm true requests exactly the documented CLM scopes", func(t *testing.T) { got := buildScopes(true) - want := []string{"signature", "impersonation", "spring_read", "spring_write", "springcm_read", "springcm_write"} + want := []string{"signature", "spring_read", "spring_write"} if !reflect.DeepEqual(got, want) { t.Errorf("buildScopes(true) = %v, want %v", got, want) } From c4416b243f02185bdb012312d585b72cd2c7b678 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 20 Aug 2026 18:24:41 -0300 Subject: [PATCH 35/41] fix: rework SearchFolders on CLM's real async Task API, fix Security wire shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- pkg/client/clm_client.go | 199 +++++++++++++++++++++++------- pkg/client/clm_client_test.go | 52 +++++--- pkg/client/clm_helper.go | 28 ++++- pkg/client/clm_helper_test.go | 12 +- pkg/client/clm_models.go | 193 ++++++++++++++++++++++------- pkg/client/clmtest/handlers.go | 74 +++++++++-- pkg/client/clmtest/seed.go | 12 +- pkg/client/clmtest/server.go | 56 ++++++--- pkg/connector/clm_folders.go | 18 +-- pkg/connector/clm_folders_test.go | 44 +++---- 10 files changed, 511 insertions(+), 177 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index b69dccef..84512285 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -4,23 +4,34 @@ // own OAuth scopes ("spring_read"/"spring_write", see oauth.go) and a different Object // API surface. Endpoints below are derived from DocuSign's CLM API reference (method // tables and request/response schemas). Validate against cmd/test-server during -// development. Live-tested against a real (demo/UAT) CLM tenant: account discovery -// succeeded, but every data-plane call (ListGroups, ListPermissionSets) returned CLM's -// own 401 "Access Denied" (ErrorCode 103) even with the scopes DocuSign's own CLM -// Authentication Overview docs specify for Authorization Code Grant. The demo tenant's -// admin status was fixed mid-investigation but not yet re-tested — see the CLM auth -// docs' repeated note that CLM API access requires a production account; this may be a -// demo/UAT-environment limitation rather than a scope or permission bug. +// development. Live-tested end-to-end against a real CLM tenant (demo/UAT +// environment): Groups/Members/PermissionSets/Roles all synced correctly with their +// entitlements and grants, once the authorizing user had admin rights in that +// environment — an earlier 401 "Access Denied" on every data call (discovery +// succeeded) turned out to be exactly that, not a scope, licensing, or demo-vs- +// production issue as initially suspected. Folders required a separate fix — see +// SearchFolders' doc for the confirmed request/response shape. // // # API Endpoints Used // // Folders: -// - POST /v2/{accountId}/folders/search - Discover folders (no flat list-all exists) +// - POST /v2/{accountId}/foldersearchtasks - Search for folders (async Task API — see SearchFolders' doc) // - GET /v2/{accountId}/folders/{id}?expand=Security - Get a folder with its explicit security entries. // Security is three separate collections by principal type (Groups/Roles/Users), confirmed via // DocuSign's own Folders.Patch reference page - see ClmFolderSecurity's doc in clm_models.go. -// - PATCH /v2/{accountId}/folders/{id} - Update folder security (grant: set an AccessType on the -// relevant Groups/Roles/Users entry; revoke: set that entry's AccessType to "NoAccess") +// - PATCH /v2/{accountId}/folders/{id} - does NOT actually update Security, despite Security being a +// documented field on this same Patch reference page and the write appearing to succeed (200, +// no error). Confirmed live: a trivial PATCH of another field (Description) applies and bumps +// UpdatedDate; the identical request with only Security populated returns 200 but a subsequent +// fresh GET shows Security unchanged and UpdatedDate untouched. Tried against a freshly-created, +// disposable test folder (created and deleted solely for this check — never against real +// customer data): both the {Item: {...}, AccessType} shape confirmed on reads and a flat +// {Href, AccessType} shape, both PATCH and PUT, all had zero effect. CLM's own error code list +// (see the Response and Error Codes page) names a distinct "136 - Missing Change Security Task", +// which strongly suggests folder security changes require a dedicated Task API endpoint (like +// SearchFolders' FolderSearchTasks) rather than this generic object Patch — not yet located. +// PatchFolderSecurity/clm_folders.go's Grant/Revoke are UNCONFIRMED to work against a real +// tenant as a result; treat this as a known, open gap, not a confirmed-working path. // // Groups: // - GET /v2/{accountId}/groups - List CLM groups (GetAllGroups) @@ -74,10 +85,10 @@ import ( "fmt" "net/http" "net/url" + "time" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/uhttp" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -108,15 +119,18 @@ var clmBaseURLCandidateFields = []string{ // CLM API endpoint constants. const ( - clmSearchFolders = "/v2/%s/folders/search" - clmGetFolder = "/v2/%s/folders/%s" - clmPatchFolder = "/v2/%s/folders/%s" - clmGetGroups = "/v2/%s/groups" - clmGetGroupMembers = "/v2/%s/groups/%s/groupmembers" - clmGetMembers = "/v2/%s/members" - clmGetMemberGroups = "/v2/%s/members/%s/groups" - clmPatchPutMember = "/v2/%s/members/%s" - clmGetPermissionSet = "/v2/%s/permissionsets" + // clmCreateFolderSearchTask creates a CLM FolderSearchTasks task — see + // SearchFolders' doc. The previously-assumed synchronous "/v2/%s/folders/search" + // endpoint does not exist for folders (confirmed live: 405 Method Not Allowed). + clmCreateFolderSearchTask = "/v2/%s/foldersearchtasks" + clmGetFolder = "/v2/%s/folders/%s" + clmPatchFolder = "/v2/%s/folders/%s" + clmGetGroups = "/v2/%s/groups" + clmGetGroupMembers = "/v2/%s/groups/%s/groupmembers" + clmGetMembers = "/v2/%s/members" + clmGetMemberGroups = "/v2/%s/members/%s/groups" + clmPatchPutMember = "/v2/%s/members/%s" + clmGetPermissionSet = "/v2/%s/permissionsets" // clmGroupPath and clmMemberPath are path *shapes*, not endpoints this connector // calls — hrefFor builds a Href string locally from these, issuing no request beyond @@ -288,46 +302,143 @@ func (c *Client) doClmRequest(ctx context.Context, method string, reqURL *url.UR return anno, err } -// SearchFolders discovers folders via the CLM Folders search endpoint (there is no -// flat list-all endpoint for folders, unlike Groups/Members/PermissionSets). +// clmMaxFolderSearchTaskPolls bounds how many times SearchFolders polls a "Processing" +// FolderSearchTasks task before giving up. Against a live CLM tenant the task always +// resolved inline (Status "Success" already in the POST response), so this branch is +// implemented per the Task API's documented contract but unverified live; the cap +// exists so a task that genuinely never resolves fails loudly instead of hanging. +const clmMaxFolderSearchTaskPolls = 30 + +// ClmFolderSearchTaskPollInterval is how long SearchFolders waits between polls of a +// "Processing" FolderSearchTasks task. Exported, like DefaultPageSize, so tests can +// override it — a real poll cadence would make a test exercising this branch +// needlessly slow. +var ClmFolderSearchTaskPollInterval = 2 * time.Second + +// SearchFolders discovers folders via CLM's FolderSearchTasks — there is no flat +// list-all or synchronous search endpoint for folders (unlike Groups/Members/ +// PermissionSets). Folder search is part of CLM's async Task API (CLM Task API 101 / +// FolderSearchTasks reference, pasted live since the site is JS-rendered): a POST +// creates a search task, which either resolves inline or must be polled via its own +// Href until Status leaves "Processing", after which the paginated folder list is read +// from the task's Result. // -// Pagination: offset/limit, see package doc. +// Confirmed live against a real CLM tenant: +// - POST /v2/{accountId}/folders/search (a plain synchronous search — this function's +// original implementation) returns 405 Method Not Allowed: that endpoint doesn't +// exist for folders. +// - POST /v2/{accountId}/foldersearchtasks requires a recognized search parameter in +// the body — an empty body, or {"Name": ...} (the field ClmFolder's own JSON tag +// uses), is rejected with CLM ErrorCode 1024 "no valid search parameter" against +// every property name tried except "Title". {"Title": ""} is accepted and matches +// every folder (Title is a substring match, so empty matches everything) — +// confirmed against a real account with 100 folders. +// - The task resolved inline (Status "Success" already in the POST response, Result +// already populated) on every live test; the "Processing" polling branch below is +// unverified live. +// - Continuation pages don't re-POST a new search: they GET the task's own Result +// href (offset/limit appended) directly, confirmed live to return the same flat, +// paginated shape as every other CLM list endpoint. func (c *Client) SearchFolders(ctx context.Context, options PageOptions) ([]ClmFolder, string, annotations.Annotations, error) { if err := c.ensureClmReady(ctx); err != nil { return nil, "", nil, err } - searchURL, requestedPage, err := c.prepareClmPagedRequest(clmSearchFolders, options) + if options.PageToken != "" { + decoded, err := decodeClmPageToken(options.PageToken) + if err != nil { + return nil, "", nil, fmt.Errorf("baton-docusign: invalid CLM page token: %w", err) + } + if decoded.ResultHref != "" { + return c.getClmFolderSearchResultPage(ctx, decoded.ResultHref, options) + } + } + + createURL, err := c.buildClmClientURL(clmCreateFolderSearchTask) if err != nil { return nil, "", nil, err } - var page ClmFolderPage - anno, err := c.doClmRequest(ctx, http.MethodPost, searchURL, struct{}{}, &page) + var task ClmFolderSearchTaskResponse + anno, err := c.doClmRequest(ctx, http.MethodPost, createURL, map[string]string{"Title": ""}, &task) if err != nil { - return nil, "", nil, fmt.Errorf("baton-docusign: failed to search CLM folders: %w", err) + return nil, "", nil, fmt.Errorf("baton-docusign: failed to create CLM folder search task: %w", err) } - // The request body is empty (no search criteria) because the schema for scoping - // this search to "all folders" was never confirmed against a live CLM tenant. If - // that empty body means "no criteria -> no matches" rather than "match all", the - // very first page would come back empty and every folder/folder-security sync - // would silently report success while syncing zero folders. Surface that - // possibility in the logs rather than fail silently, without treating it as a - // hard error since an account with genuinely zero folders is also a valid state. - if requestedPage.Offset == 0 && len(page.Items) == 0 { - ctxzap.Extract(ctx).Debug("baton-docusign: CLM folder search returned zero results on the first page; " + - "if this account has CLM folders, this may indicate the empty search body is being interpreted as " + - "'no criteria -> no matches' rather than 'match all' — please report this to ConductorOne") + task, err = c.awaitClmFolderSearchTask(ctx, task) + if err != nil { + return nil, "", anno, err + } + if task.Result == nil { + return nil, "", anno, fmt.Errorf("baton-docusign: CLM folder search task %s succeeded with no Result", task.Href) } - nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total) + 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) + if err != nil { + return nil, "", anno, err + } + return task.Result.Items, nextToken, anno, nil +} + +// getClmFolderSearchResultPage fetches one continuation page of an already-completed +// CLM folder search — see SearchFolders' doc for why this reads from a server-issued +// Result href instead of re-POSTing a new search task. +func (c *Client) getClmFolderSearchResultPage(ctx context.Context, resultHref string, options PageOptions) ([]ClmFolder, string, annotations.Annotations, error) { + base, err := url.Parse(resultHref) + if err != nil { + return nil, "", nil, fmt.Errorf("baton-docusign: invalid CLM folder search result href %q: %w", resultHref, err) + } + pageURL, requestedPage, err := appendClmPageQuery(base, options) + if err != nil { + return nil, "", nil, err + } + + var page ClmFolderPage + anno, err := c.doClmRequest(ctx, http.MethodGet, pageURL, nil, &page) + if err != nil { + return nil, "", nil, fmt.Errorf("baton-docusign: failed to read CLM folder search results: %w", err) + } + + nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total, resultHref) if err != nil { return nil, "", anno, err } return page.Items, nextToken, anno, nil } +// awaitClmFolderSearchTask polls a CLM FolderSearchTasks task until it leaves +// "Processing", per the CLM Task API's documented contract (GET the task's own Href; +// Status becomes "Success" or "Failure") — see SearchFolders' doc for why this branch +// is unverified against a live tenant. +func (c *Client) awaitClmFolderSearchTask(ctx context.Context, task ClmFolderSearchTaskResponse) (ClmFolderSearchTaskResponse, error) { + for attempt := 0; task.Status == "Processing"; attempt++ { + if attempt >= clmMaxFolderSearchTaskPolls { + return task, fmt.Errorf("baton-docusign: CLM folder search task %s did not finish after %d polls", task.Href, clmMaxFolderSearchTaskPolls) + } + select { + case <-ctx.Done(): + return task, ctx.Err() + case <-time.After(ClmFolderSearchTaskPollInterval): + } + + 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 { + return task, fmt.Errorf("baton-docusign: failed to poll CLM folder search task %s: %w", task.Href, err) + } + } + if task.Status == "Failure" { + return task, fmt.Errorf("baton-docusign: CLM folder search task %s failed", task.Href) + } + return task, nil +} + // GetFolder fetches a single folder, optionally expanding Security to get its explicit // (non-inherited) folder security entries. The response may be served from the shared // HTTP GET cache. @@ -427,7 +538,7 @@ func (c *Client) ListGroups(ctx context.Context, options PageOptions) ([]ClmGrou return nil, "", nil, fmt.Errorf("baton-docusign: failed to list CLM groups: %w", err) } - nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total) + nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total, "") if err != nil { return nil, "", anno, err } @@ -479,7 +590,7 @@ func (c *Client) GetGroupMembers(ctx context.Context, groupID string, options Pa return nil, "", nil, fmt.Errorf("baton-docusign: failed to list members of CLM group %s: %w", groupID, err) } - nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total) + nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total, "") if err != nil { return nil, "", anno, err } @@ -507,7 +618,7 @@ func (c *Client) ListMembers(ctx context.Context, options PageOptions) ([]ClmMem return nil, "", nil, fmt.Errorf("baton-docusign: failed to list CLM members: %w", err) } - nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total) + nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total, "") if err != nil { return nil, "", anno, err } @@ -580,7 +691,7 @@ func (c *Client) getMemberGroupsPage(ctx context.Context, memberID string, optio return nil, "", nil, fmt.Errorf("baton-docusign: failed to get groups for CLM member %s: %w", memberID, err) } - nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total) + nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total, "") if err != nil { return nil, "", anno, err } @@ -656,7 +767,7 @@ func (c *Client) ListPermissionSets(ctx context.Context, options PageOptions) ([ return nil, "", nil, fmt.Errorf("baton-docusign: failed to list CLM permission sets: %w", err) } - nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total) + nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total, "") if err != nil { return nil, "", anno, err } diff --git a/pkg/client/clm_client_test.go b/pkg/client/clm_client_test.go index 70da9684..ee137e46 100644 --- a/pkg/client/clm_client_test.go +++ b/pkg/client/clm_client_test.go @@ -3,6 +3,7 @@ package client_test import ( "context" "testing" + "time" "github.com/conductorone/baton-docusign/pkg/client" "github.com/conductorone/baton-docusign/pkg/client/clmtest" @@ -31,12 +32,35 @@ func TestSearchFolders_Pagination(t *testing.T) { } // Search results are summaries — no Security field. for _, f := range all { - if len(f.Security.Groups.Items) != 0 || len(f.Security.Roles.Items) != 0 || len(f.Security.Users.Items) != 0 { + if len(f.Security.Groups) != 0 || len(f.Security.Roles) != 0 || len(f.Security.Users) != 0 { t.Errorf("folder %s: expected Search to omit Security, got %+v", f.Name, f.Security) } } } +// TestSearchFolders_PollsUntilSuccess is a regression test for SearchFolders' +// awaitClmFolderSearchTask branch: every live test against a real CLM tenant resolved +// the task inline (Status "Success" already in the POST response), leaving the polling +// branch itself unexercised until now. +func TestSearchFolders_PollsUntilSuccess(t *testing.T) { + original := client.ClmFolderSearchTaskPollInterval + client.ClmFolderSearchTaskPollInterval = time.Millisecond + defer func() { client.ClmFolderSearchTaskPollInterval = original }() + + srv, c := clmtest.NewServer(t) + ctx := context.Background() + + srv.SetPendingFolderSearchPolls(2) + + folders, _, _, err := c.SearchFolders(ctx, client.PageOptions{PageSize: 10}) + if err != nil { + t.Fatalf("SearchFolders: %v", err) + } + if len(folders) != 3 { + t.Fatalf("expected all 3 seeded folders once the task resolves, got %d", len(folders)) + } +} + func TestGetFolder_ExpandSecurity(t *testing.T) { _, c := clmtest.NewServer(t) ctx := context.Background() @@ -46,7 +70,7 @@ func TestGetFolder_ExpandSecurity(t *testing.T) { if err != nil { t.Fatalf("GetFolder: %v", err) } - if len(folder.Security.Groups.Items) != 0 || len(folder.Security.Roles.Items) != 0 || len(folder.Security.Users.Items) != 0 { + if len(folder.Security.Groups) != 0 || len(folder.Security.Roles) != 0 || len(folder.Security.Users) != 0 { t.Errorf("expected no Security without ?expand=Security, got %+v", folder.Security) } }) @@ -56,14 +80,14 @@ func TestGetFolder_ExpandSecurity(t *testing.T) { if err != nil { t.Fatalf("GetFolder: %v", err) } - if len(folder.Security.Groups.Items) != 2 { - t.Fatalf("expected 2 seeded group security entries, got %d: %+v", len(folder.Security.Groups.Items), folder.Security.Groups.Items) + if len(folder.Security.Groups) != 2 { + t.Fatalf("expected 2 seeded group security entries, got %d: %+v", len(folder.Security.Groups), folder.Security.Groups) } - if len(folder.Security.Roles.Items) != 1 { - t.Fatalf("expected 1 seeded role security entry, got %d: %+v", len(folder.Security.Roles.Items), folder.Security.Roles.Items) + if len(folder.Security.Roles) != 1 { + t.Fatalf("expected 1 seeded role security entry, got %d: %+v", len(folder.Security.Roles), folder.Security.Roles) } - if len(folder.Security.Users.Items) != 1 { - t.Fatalf("expected 1 seeded user security entry, got %d: %+v", len(folder.Security.Users.Items), folder.Security.Users.Items) + if len(folder.Security.Users) != 1 { + t.Fatalf("expected 1 seeded user security entry, got %d: %+v", len(folder.Security.Users), folder.Security.Users) } }) @@ -93,8 +117,8 @@ func TestPatchFolderSecurity_SendsExactEntries(t *testing.T) { } sec := srv.FolderSecurity("folder-templates") - if len(sec.Groups.Items) != 1 || sec.Groups.Items[0].AccessType != client.ClmAccessTypeView || sec.Groups.Items[0].Href != groupHref { - t.Fatalf("expected one View entry for %s, got %+v", groupHref, sec.Groups.Items) + if len(sec.Groups) != 1 || sec.Groups[0].AccessType != client.ClmAccessTypeView || sec.Groups[0].Href != groupHref { + t.Fatalf("expected one View entry for %s, got %+v", groupHref, sec.Groups) } // Sending a single-entry Groups list for the same Href again replaces the prior entry. @@ -105,11 +129,11 @@ func TestPatchFolderSecurity_SendsExactEntries(t *testing.T) { } sec = srv.FolderSecurity("folder-templates") - if len(sec.Groups.Items) != 1 { - t.Fatalf("expected the existing entry to be updated in place, not duplicated: %+v", sec.Groups.Items) + if len(sec.Groups) != 1 { + t.Fatalf("expected the existing entry to be updated in place, not duplicated: %+v", sec.Groups) } - if sec.Groups.Items[0].AccessType != client.ClmAccessTypeNoAccess { - t.Errorf("expected AccessType NoAccess after revoke, got %q", sec.Groups.Items[0].AccessType) + if sec.Groups[0].AccessType != client.ClmAccessTypeNoAccess { + t.Errorf("expected AccessType NoAccess after revoke, got %q", sec.Groups[0].AccessType) } } diff --git a/pkg/client/clm_helper.go b/pkg/client/clm_helper.go index 1c4dfcb8..11461933 100644 --- a/pkg/client/clm_helper.go +++ b/pkg/client/clm_helper.go @@ -31,7 +31,16 @@ func preparePagedRequestClm(baseURL *url.URL, endpoint string, options PageOptio return nil, clmRequestedPage{}, fmt.Errorf("baton-docusign: invalid CLM endpoint: %w", err) } - fullURL := baseURL.ResolveReference(endpointURL) + return appendClmPageQuery(baseURL.ResolveReference(endpointURL), options) +} + +// appendClmPageQuery appends CLM's pageSortParams.offset/limit query params to an +// already-resolved absolute URL and decodes options.PageToken — the part of +// preparePagedRequestClm that doesn't depend on resolving a relative endpoint against +// the CLM base URL. Split out for SearchFolders' continuation pages, which paginate +// against a server-issued Result href (a per-search URL CLM hands back, not one of this +// package's own static endpoint constants) rather than a fixed collection endpoint. +func appendClmPageQuery(fullURL *url.URL, options PageOptions) (*url.URL, clmRequestedPage, error) { q := fullURL.Query() offset := 0 @@ -58,10 +67,15 @@ func preparePagedRequestClm(baseURL *url.URL, endpoint string, options PageOptio // clmPageToken is the internal offset-based continuation token for CLM pagination. // Requests counts how many requests this pagination sequence has made so far — see -// maxClmListPages. +// maxClmListPages. ResultHref is only set by SearchFolders' continuation pages: unlike +// every other CLM list endpoint (a fixed collection URL re-queried with a different +// offset), a folder search's results live at a per-search URL CLM hands back from the +// FolderSearchTasks create call, so the token must carry it forward — the collection +// URL isn't otherwise derivable from the resource type alone. type clmPageToken struct { - Offset int `json:"offset"` - Requests int `json:"requests"` + Offset int `json:"offset"` + Requests int `json:"requests"` + ResultHref string `json:"resultHref,omitempty"` } func encodeClmPageToken(pt *clmPageToken) string { @@ -171,7 +185,9 @@ func decodeClmPageToken(token string) (*clmPageToken, error) { // matters when the floor is the larger of the two estimates. const maxClmListPages = 1000 -func getClmNextToken(requested clmRequestedPage, itemCount int, hasNext bool, total int) (string, error) { +// resultHref is embedded in the returned token as-is (see clmPageToken's doc) — pass "" +// for every endpoint except SearchFolders' continuation pages. +func getClmNextToken(requested clmRequestedPage, itemCount int, hasNext bool, total int, resultHref string) (string, error) { if itemCount == 0 { return "", nil } @@ -195,5 +211,5 @@ func getClmNextToken(requested clmRequestedPage, itemCount int, hasNext bool, to return "", fmt.Errorf("baton-docusign: exceeded %d pages paginating a CLM list — the API may be ignoring the requested offset", maxClmListPages) } - return encodeClmPageToken(&clmPageToken{Offset: nextOffset, Requests: nextRequests}), nil + return encodeClmPageToken(&clmPageToken{Offset: nextOffset, Requests: nextRequests, ResultHref: resultHref}), nil } diff --git a/pkg/client/clm_helper_test.go b/pkg/client/clm_helper_test.go index b0343ff7..d8b9e1ca 100644 --- a/pkg/client/clm_helper_test.go +++ b/pkg/client/clm_helper_test.go @@ -50,7 +50,7 @@ func TestGetClmNextToken_ComputesFromRequestNotResponse(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := getClmNextToken(tt.requested, tt.itemCount, tt.hasNext, tt.total) + got, err := getClmNextToken(tt.requested, tt.itemCount, tt.hasNext, tt.total, "") if err != nil { t.Fatalf("getClmNextToken: %v", err) } @@ -78,7 +78,7 @@ func TestGetClmNextToken_ComputesFromRequestNotResponse(t *testing.T) { // multiple of the page size, a full page landing exactly on Total stops immediately — // no need to wait for an empty page in this case, since Total confirms it. func TestGetClmNextToken_ExactBoundaryDoesNotLoop(t *testing.T) { - got, err := getClmNextToken(clmRequestedPage{Offset: 100, PageSize: 100}, 100, false, 200) + got, err := getClmNextToken(clmRequestedPage{Offset: 100, PageSize: 100}, 100, false, 200, "") if err != nil { t.Fatalf("getClmNextToken: %v", err) } @@ -113,7 +113,7 @@ func TestGetClmNextToken_ExactBoundaryDoesNotLoop(t *testing.T) { func TestGetClmNextToken_CapsRunawayPagination(t *testing.T) { t.Run("reaches the cap through normal advancement", func(t *testing.T) { requested := clmRequestedPage{Offset: (maxClmListPages - 1) * 100, PageSize: 100} - _, err := getClmNextToken(requested, 100, false, 0) + _, err := getClmNextToken(requested, 100, false, 0, "") if err == nil { t.Fatal("expected an error once maxClmListPages is reached, got nil") } @@ -121,7 +121,7 @@ func TestGetClmNextToken_CapsRunawayPagination(t *testing.T) { t.Run("does not fire just below the cap", func(t *testing.T) { requested := clmRequestedPage{Offset: (maxClmListPages - 2) * 100, PageSize: 100} - got, err := getClmNextToken(requested, 100, false, 0) + got, err := getClmNextToken(requested, 100, false, 0, "") if err != nil { t.Fatalf("expected no error just below the cap, got: %v", err) } @@ -134,7 +134,7 @@ func TestGetClmNextToken_CapsRunawayPagination(t *testing.T) { // Offset alone, with Requests at its zero value (as a pre-cap or // round-tripped token would decode to), must still trigger the cap. requested := clmRequestedPage{Offset: maxClmListPages * 100, PageSize: 100} - _, err := getClmNextToken(requested, 100, false, 0) + _, err := getClmNextToken(requested, 100, false, 0, "") if err == nil { t.Fatal("expected the cap to fire from Offset alone, got nil error") } @@ -147,7 +147,7 @@ func TestGetClmNextToken_CapsRunawayPagination(t *testing.T) { // requested.Requests to reach the same cap. Set Requests to exactly one below // the cap to isolate that it alone is what trips it here. requested := clmRequestedPage{Offset: maxClmListPages - 1, PageSize: 100, Requests: maxClmListPages - 1} - _, err := getClmNextToken(requested, 1, true, 0) + _, err := getClmNextToken(requested, 1, true, 0, "") if err == nil { t.Fatal("expected the request-count estimate to trip the cap even though the offset floor would not have") } diff --git a/pkg/client/clm_models.go b/pkg/client/clm_models.go index e9b9a78b..47b64885 100644 --- a/pkg/client/clm_models.go +++ b/pkg/client/clm_models.go @@ -1,6 +1,9 @@ package client -import "fmt" +import ( + "encoding/json" + "fmt" +) // ClmPage is the pagination metadata CLM's Object API embeds in every list response — // distinct from eSignature's Page (see models.go). Each *Page wrapper type below embeds @@ -73,25 +76,53 @@ type ClmFolderPage struct { Items []ClmFolder `json:"Items"` } -// ClmFolderSecurity is a folder's explicit (non-inherited) security assignments, -// confirmed via DocuSign's own Folders.Patch reference page (pasted live, since the -// site is JS-rendered and unreachable by automated tools) to be three SEPARATE -// collections by principal type — not a single flat list, and not the -// AccessType-vs-boolean-flags dual representation an earlier version of this file -// assumed. No boolean flags (Create/Move/Read/See/SetAccess/Write) appear anywhere in -// the confirmed schema; every entry across all three collections carries AccessType -// directly. +// ClmFolderSearchTaskResponse is CLM's FolderSearchTasks response envelope — the same +// shape whether returned by the initial POST (create) or a poll GET on the task's own +// Href. Confirmed live for the POST/"Success" case (Result already populated with the +// same Items/Offset/Limit/Total/Next fields as every other CLM list endpoint); the CLM +// Task API 101 docs describe the identical {Status, Href, Result} envelope for +// DocumentSearchTasks, CLM's sibling search-task resource — see SearchFolders' doc in +// clm_client.go for what's confirmed vs. assumed. +type ClmFolderSearchTaskResponse struct { + Status string `json:"Status"` + Href string `json:"Href"` + Result *ClmFolderPage `json:"Result,omitempty"` +} + +// ClmFolderSecurity is a folder's explicit (non-inherited) security assignments. +// Confirmed live against a real CLM tenant (GetFolder?expand=Security) to be three +// SEPARATE, flat (non-paginated) arrays by principal type — no First/Href/Last/Limit/ +// Next/Offset/Previous/Total pagination envelope wraps them the way every other CLM +// list response does; an earlier version of this struct wrapped each in a page type +// based on an unconfirmed reading of the Folders.Patch reference page, which caused a +// live sync to fail entirely with a JSON unmarshal error (array where an object with an +// Items field was expected). type ClmFolderSecurity struct { - Groups ClmGroupSecurityPage `json:"Groups,omitempty"` - Roles ClmRoleSecurityPage `json:"Roles,omitempty"` - Users ClmUserSecurityPage `json:"Users,omitempty"` + Groups []ClmGroupSecurityEntry `json:"Groups,omitempty"` + Roles []ClmRoleSecurityEntry `json:"Roles,omitempty"` + Users []ClmUserSecurityEntry `json:"Users,omitempty"` } -// ClmGroupSecurityEntry is one folder-security grant to a CLM Group. Confirmed shape: -// the full Group object's own fields (Href/Name/GroupType/Description/CreatedDate/ -// UpdatedDate) plus AccessType — not a lean {Item, AccessType} pair. +// ClmGroupSecurityEntry is one folder-security grant to a CLM Group. Confirmed live: +// the wire shape nests the group's own fields under an "Item" key, sibling to +// AccessType — {"Item": {"Href":...,"Name":...,...}, "AccessType":"View"} — not a flat +// merge of AccessType into the group's fields as an earlier version of this struct +// assumed (that assumption was never actually confirmed against a live tenant, despite +// its doc comment's claim otherwise). Kept as a flat Go struct via custom (Un)MarshalJSON +// so callers in pkg/connector/clm_folders.go don't need to know about the wire-level +// nesting — mirrors ClmRoleSecurityEntry's existing bare-Item shape, just for an object +// Item instead of a string one. type ClmGroupSecurityEntry struct { - AccessType string `json:"AccessType,omitempty"` + AccessType string + Href string + Name string + GroupType string + Description string + CreatedDate string + UpdatedDate string +} + +type clmGroupSecurityItem struct { Href string `json:"Href"` Name string `json:"Name,omitempty"` GroupType string `json:"GroupType,omitempty"` @@ -100,12 +131,41 @@ type ClmGroupSecurityEntry struct { UpdatedDate string `json:"UpdatedDate,omitempty"` } -// ClmGroupSecurityPage is the paginated collection of ClmGroupSecurityEntry returned -// on a read (GetFolder?expand=Security). See ClmFolderSecurityWrite for the plain-list -// shape used on writes. -type ClmGroupSecurityPage struct { - ClmPage - Items []ClmGroupSecurityEntry `json:"Items"` +func (e ClmGroupSecurityEntry) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Item clmGroupSecurityItem `json:"Item"` + AccessType string `json:"AccessType,omitempty"` + }{ + Item: clmGroupSecurityItem{ + Href: e.Href, + Name: e.Name, + GroupType: e.GroupType, + Description: e.Description, + CreatedDate: e.CreatedDate, + UpdatedDate: e.UpdatedDate, + }, + AccessType: e.AccessType, + }) +} + +func (e *ClmGroupSecurityEntry) UnmarshalJSON(data []byte) error { + var wire struct { + Item clmGroupSecurityItem `json:"Item"` + AccessType string `json:"AccessType"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + *e = ClmGroupSecurityEntry{ + AccessType: wire.AccessType, + Href: wire.Item.Href, + Name: wire.Item.Name, + GroupType: wire.Item.GroupType, + Description: wire.Item.Description, + CreatedDate: wire.Item.CreatedDate, + UpdatedDate: wire.Item.UpdatedDate, + } + return nil } // ClmRoleSecurityEntry is one folder-security grant to a CLM Role. Confirmed shape: @@ -116,31 +176,68 @@ type ClmRoleSecurityEntry struct { Item string `json:"Item"` } -// ClmRoleSecurityPage is the paginated collection of ClmRoleSecurityEntry. -type ClmRoleSecurityPage struct { - ClmPage - Items []ClmRoleSecurityEntry `json:"Items"` +// ClmUserSecurityEntry is one folder-security grant to a CLM Member (user). Assumed to +// follow ClmGroupSecurityEntry's confirmed {Item: {...}, AccessType} wire shape — not +// independently confirmed live for Users specifically (every populated folder-security +// entry found on the live tenant this was tested against was a Group), but consistent +// with a single API design for all three principal types, and with the equally +// unconfirmed prior assumption this replaces. Deliberately doesn't repeat every field +// ClmMember has (Address*, City, Company, etc.): Grant/Revoke only ever need Href to +// identify the member, never reconstruct a full member profile from a security entry. +type ClmUserSecurityEntry struct { + AccessType string + Href string + Email string + UserName string + FirstName string + LastName string + Role string } -// ClmUserSecurityEntry is one folder-security grant to a CLM Member (user). Confirmed -// shape: the Member object's own identifying fields plus AccessType — mirrors -// ClmGroupSecurityEntry's pattern. Deliberately doesn't repeat every field ClmMember -// has (Address*, City, Company, etc.): Grant/Revoke only ever need Href to identify -// the member, never reconstruct a full member profile from a security entry. -type ClmUserSecurityEntry struct { - AccessType string `json:"AccessType,omitempty"` - Href string `json:"Href"` - Email string `json:"Email,omitempty"` - UserName string `json:"UserName,omitempty"` - FirstName string `json:"FirstName,omitempty"` - LastName string `json:"LastName,omitempty"` - Role string `json:"Role,omitempty"` +type clmUserSecurityItem struct { + Href string `json:"Href"` + Email string `json:"Email,omitempty"` + UserName string `json:"UserName,omitempty"` + FirstName string `json:"FirstName,omitempty"` + LastName string `json:"LastName,omitempty"` + Role string `json:"Role,omitempty"` } -// ClmUserSecurityPage is the paginated collection of ClmUserSecurityEntry. -type ClmUserSecurityPage struct { - ClmPage - Items []ClmUserSecurityEntry `json:"Items"` +func (e ClmUserSecurityEntry) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Item clmUserSecurityItem `json:"Item"` + AccessType string `json:"AccessType,omitempty"` + }{ + Item: clmUserSecurityItem{ + Href: e.Href, + Email: e.Email, + UserName: e.UserName, + FirstName: e.FirstName, + LastName: e.LastName, + Role: e.Role, + }, + AccessType: e.AccessType, + }) +} + +func (e *ClmUserSecurityEntry) UnmarshalJSON(data []byte) error { + var wire struct { + Item clmUserSecurityItem `json:"Item"` + AccessType string `json:"AccessType"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + *e = ClmUserSecurityEntry{ + AccessType: wire.AccessType, + Href: wire.Item.Href, + Email: wire.Item.Email, + UserName: wire.Item.UserName, + FirstName: wire.Item.FirstName, + LastName: wire.Item.LastName, + Role: wire.Item.Role, + } + return nil } // ClmFolderSecurityPatch is the request body for PATCH .../folders/{id} when updating @@ -156,6 +253,16 @@ type ClmFolderSecurityPatch struct { // folder's complete current security (see clm_folders.go's clmFolderSecurityToWrite), // not just the one changed entry: Folders.Patch's merge-vs-replace semantics for // Security are undocumented, and sending the complete state is correct either way. +// +// Entries serialize via ClmGroupSecurityEntry/ClmUserSecurityEntry's MarshalJSON, so a +// PATCH sends the same {Item: {...}, AccessType} shape confirmed live on reads. This +// WAS tested live, against a disposable folder created and deleted solely for the +// check — and had no effect: see clm_client.go's package doc "Folders" section for the +// full evidence chain. Neither this shape nor a flat {Href, AccessType} one worked, +// with either PATCH or PUT; a distinct CLM error code ("136 - Missing Change Security +// Task") suggests the real mechanism is a dedicated Task API endpoint, not this generic +// object Patch. Grant/Revoke on clm_folder are NOT confirmed to work against a real CLM +// tenant — this is a known, open gap, not a residual unconfirmed assumption. type ClmFolderSecurityWrite struct { Groups []ClmGroupSecurityEntry `json:"Groups,omitempty"` Roles []ClmRoleSecurityEntry `json:"Roles,omitempty"` diff --git a/pkg/client/clmtest/handlers.go b/pkg/client/clmtest/handlers.go index 37871637..44770217 100644 --- a/pkg/client/clmtest/handlers.go +++ b/pkg/client/clmtest/handlers.go @@ -2,8 +2,10 @@ package clmtest import ( "encoding/json" + "fmt" "io" "net/http" + "strconv" "github.com/conductorone/baton-docusign/pkg/client" ) @@ -13,19 +15,71 @@ func idFromHref(href string) string { return client.IDFromHref(href) } -// Doc URL: https://developers.docusign.com/docs/clm-api/reference/objects/folders/ (Search). -func (s *Server) handleSearchFolders(w http.ResponseWriter, r *http.Request) { - s.mu.Lock() - defer s.mu.Unlock() - +// folderSearchResults builds the flat, paginated ClmFolderPage for a folder search — +// shared by the create/poll (wrapped in {Status, Href, Result}) and continuation +// (bare) responses. Search results are summaries; Security only comes via ?expand on +// Get, mirroring the real API. +func (s *Server) folderSearchResults(r *http.Request) client.ClmFolderPage { page, meta := pageSlice(r, s.folderOrder) items := make([]client.ClmFolder, 0, len(page)) for _, id := range page { f := *s.folders[id] - f.Security = client.ClmFolderSecurity{} // Search results are summaries; Security only comes via ?expand on Get + f.Security = client.ClmFolderSecurity{} items = append(items, f) } - writeJSON(w, client.ClmFolderPage{ClmPage: meta, Items: items}) + return client.ClmFolderPage{ClmPage: meta, Items: items} +} + +// Doc URL: https://developers.docusign.com/docs/clm-api/reference/tasks/foldersearchtasks/ +// (Post). Real CLM requires a recognized search parameter in the body (confirmed live: +// {"Title": ""} matches every folder) — this mock doesn't replicate that validation, +// since every real client call already sends the confirmed-working body. +func (s *Server) handleCreateFolderSearchTask(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + s.nextFolderSearchTaskID++ + taskID := strconv.Itoa(s.nextFolderSearchTaskID) + taskHref := fmt.Sprintf("%s/v2/%s/foldersearchtasks/%s", s.baseURL, AccountID, taskID) + + status := "Success" + if s.pendingFolderSearchPolls > 0 { + status = "Processing" + } + resp := client.ClmFolderSearchTaskResponse{Status: status, Href: taskHref} + if status == "Success" { + result := s.folderSearchResults(r) + result.Href = taskHref + "/result" + resp.Result = &result + } + writeJSON(w, resp) +} + +// Doc URL: https://developers.docusign.com/docs/clm-api/reference/tasks/foldersearchtasks/get/ +func (s *Server) handlePollFolderSearchTask(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + taskID := r.PathValue("id") + taskHref := fmt.Sprintf("%s/v2/%s/foldersearchtasks/%s", s.baseURL, AccountID, taskID) + + if s.pendingFolderSearchPolls > 0 { + s.pendingFolderSearchPolls-- + writeJSON(w, client.ClmFolderSearchTaskResponse{Status: "Processing", Href: taskHref}) + return + } + + result := s.folderSearchResults(r) + result.Href = taskHref + "/result" + writeJSON(w, client.ClmFolderSearchTaskResponse{Status: "Success", Href: taskHref, Result: &result}) +} + +// Doc URL: https://developers.docusign.com/docs/clm-api/reference/tasks/foldersearchtasks/getsearchresult/ +func (s *Server) handleFolderSearchTaskResult(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + writeJSON(w, s.folderSearchResults(r)) } // Doc URL: https://developers.docusign.com/docs/clm-api/reference/objects/folders/get/ @@ -108,9 +162,9 @@ func (s *Server) handlePatchFolder(w http.ResponseWriter, r *http.Request) { } f.Security = client.ClmFolderSecurity{ - Groups: client.ClmGroupSecurityPage{Items: body.Security.Groups}, - Roles: client.ClmRoleSecurityPage{Items: body.Security.Roles}, - Users: client.ClmUserSecurityPage{Items: body.Security.Users}, + Groups: body.Security.Groups, + Roles: body.Security.Roles, + Users: body.Security.Users, } writeJSON(w, *f) diff --git a/pkg/client/clmtest/seed.go b/pkg/client/clmtest/seed.go index cee585c9..b7ce863f 100644 --- a/pkg/client/clmtest/seed.go +++ b/pkg/client/clmtest/seed.go @@ -122,7 +122,7 @@ func seed(s *Server) { Name: "Contracts", Path: "/Contracts", Security: client.ClmFolderSecurity{ - Groups: client.ClmGroupSecurityPage{Items: []client.ClmGroupSecurityEntry{ + Groups: []client.ClmGroupSecurityEntry{ // Known tier granted to a group — tests slug-from-AccessType and // group-Href routing (should carry GrantExpandable at the connector // layer). @@ -130,15 +130,15 @@ func seed(s *Server) { // "Custom" — not one of the 5 grantable tiers — tests that Grants() // skips rather than guesses. {AccessType: client.ClmAccessTypeCustom, Href: s.GroupHref("group-finance")}, - }}, - Roles: client.ClmRoleSecurityPage{Items: []client.ClmRoleSecurityEntry{ + }, + Roles: []client.ClmRoleSecurityEntry{ // Role-granted entry — tests clm_role routing. {AccessType: client.ClmAccessTypeView, Item: roleFullSubscriberName}, - }}, - Users: client.ClmUserSecurityPage{Items: []client.ClmUserSecurityEntry{ + }, + Users: []client.ClmUserSecurityEntry{ // Known tier granted to a member — tests clm_member routing. {AccessType: client.ClmAccessTypeView, Href: s.MemberHref(memberBobID)}, - }}, + }, }, } contractsFolder.Href = s.FolderHref("folder-contracts") diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index de87d7ef..7d9bbbbd 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -27,7 +27,9 @@ // // GET /oauth/userinfo — eSignature account discovery (ensureInitialized) // GET /api/v2/{accountId}/account — CLM account discovery (ensureClmInitialized) -// POST /v2/{accountId}/folders/search — SearchFolders +// POST /v2/{accountId}/foldersearchtasks — SearchFolders (create task; resolves inline) +// GET /v2/{accountId}/foldersearchtasks/{id} — SearchFolders (poll a task — see PendingFolderSearchPolls) +// GET /v2/{accountId}/foldersearchtasks/{id}/result — SearchFolders (continuation pages) // GET /v2/{accountId}/folders/{id} — GetFolder (supports ?expand=Security) // PATCH /v2/{accountId}/folders/{id} — PatchFolderSecurity // GET /v2/{accountId}/groups — ListGroups @@ -115,6 +117,24 @@ type Server struct { memberGroupsRequests int // count of GET .../members/{id}/groups calls, for pagination assertions lastPatchedMemberGroupHrefs map[string][]string // memberID -> the raw Href strings the last PATCH request body carried, for tests + + nextFolderSearchTaskID int // incrementing counter for mock FolderSearchTasks task IDs + + // pendingFolderSearchPolls, when > 0, makes the next SearchFolders task created via + // POST /foldersearchtasks come back "Processing" this many times before resolving to + // "Success" on poll — see SetPendingFolderSearchPolls. Decremented on each poll. + pendingFolderSearchPolls int +} + +// SetPendingFolderSearchPolls makes the next folder search task created by this server +// require n polls of GET .../foldersearchtasks/{id} before resolving to "Success" — +// exercises SearchFolders' awaitClmFolderSearchTask polling loop, which every live test +// against a real CLM tenant resolved past on the first try (inline in the POST +// response), leaving that branch otherwise untested. +func (s *Server) SetPendingFolderSearchPolls(n int) { + s.mu.Lock() + defer s.mu.Unlock() + s.pendingFolderSearchPolls = n } // MemberGroupsRequestCount returns how many times GET .../members/{id}/groups has been @@ -184,9 +204,9 @@ func (s *Server) SetFolderGroupSecurityHref(folderID, groupID, href string) { defer s.mu.Unlock() folder, ok := s.folders[folderID] if ok { - for i := range folder.Security.Groups.Items { - if idFromHref(folder.Security.Groups.Items[i].Href) == groupID { - folder.Security.Groups.Items[i].Href = href + for i := range folder.Security.Groups { + if idFromHref(folder.Security.Groups[i].Href) == groupID { + folder.Security.Groups[i].Href = href return } } @@ -202,9 +222,9 @@ func (s *Server) SetFolderUserSecurityHref(folderID, memberID, href string) { defer s.mu.Unlock() folder, ok := s.folders[folderID] if ok { - for i := range folder.Security.Users.Items { - if idFromHref(folder.Security.Users.Items[i].Href) == memberID { - folder.Security.Users.Items[i].Href = href + for i := range folder.Security.Users { + if idFromHref(folder.Security.Users[i].Href) == memberID { + folder.Security.Users[i].Href = href return } } @@ -239,16 +259,16 @@ func (s *Server) FolderSecurity(folderID string) client.ClmFolderSecurity { if !ok { return client.ClmFolderSecurity{} } - groups := make([]client.ClmGroupSecurityEntry, len(f.Security.Groups.Items)) - copy(groups, f.Security.Groups.Items) - roles := make([]client.ClmRoleSecurityEntry, len(f.Security.Roles.Items)) - copy(roles, f.Security.Roles.Items) - users := make([]client.ClmUserSecurityEntry, len(f.Security.Users.Items)) - copy(users, f.Security.Users.Items) + groups := make([]client.ClmGroupSecurityEntry, len(f.Security.Groups)) + copy(groups, f.Security.Groups) + roles := make([]client.ClmRoleSecurityEntry, len(f.Security.Roles)) + copy(roles, f.Security.Roles) + users := make([]client.ClmUserSecurityEntry, len(f.Security.Users)) + copy(users, f.Security.Users) return client.ClmFolderSecurity{ - Groups: client.ClmGroupSecurityPage{Items: groups}, - Roles: client.ClmRoleSecurityPage{Items: roles}, - Users: client.ClmUserSecurityPage{Items: users}, + Groups: groups, + Roles: roles, + Users: users, } } @@ -272,7 +292,9 @@ func newMux(s *Server) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("GET /oauth/userinfo", s.handleUserInfo) mux.HandleFunc("GET /api/v2/{accountId}/account", s.requireAuth(s.handleClmAccountDiscovery)) - mux.HandleFunc("POST /v2/{accountId}/folders/search", s.requireAuth(s.handleSearchFolders)) + mux.HandleFunc("POST /v2/{accountId}/foldersearchtasks", s.requireAuth(s.handleCreateFolderSearchTask)) + mux.HandleFunc("GET /v2/{accountId}/foldersearchtasks/{id}", s.requireAuth(s.handlePollFolderSearchTask)) + mux.HandleFunc("GET /v2/{accountId}/foldersearchtasks/{id}/result", s.requireAuth(s.handleFolderSearchTaskResult)) mux.HandleFunc("GET /v2/{accountId}/folders/{id}", s.requireAuth(s.handleGetFolder)) mux.HandleFunc("PATCH /v2/{accountId}/folders/{id}", s.requireAuth(s.handlePatchFolder)) mux.HandleFunc("GET /v2/{accountId}/groups", s.requireAuth(s.handleListGroups)) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 513ba142..1e39e10a 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -138,7 +138,7 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour var grants []*v2.Grant - for _, entry := range folder.Security.Groups.Items { + for _, entry := range folder.Security.Groups { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { continue @@ -153,7 +153,7 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour grants = append(grants, grant.NewGrant(folderResource, slug, principalID, grantOpts...)) } - for _, entry := range folder.Security.Roles.Items { + for _, entry := range folder.Security.Roles { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { continue @@ -168,7 +168,7 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour grants = append(grants, grant.NewGrant(folderResource, slug, principalID)) } - for _, entry := range folder.Security.Users.Items { + for _, entry := range folder.Security.Users { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { continue @@ -285,12 +285,12 @@ func (f *clmFolderBuilder) Grant(ctx context.Context, principal *v2.Resource, en // back to PatchFolderSecurity, taking a defensive copy of each collection so callers // can mutate the result without aliasing the original read. func clmFolderSecurityToWrite(sec client.ClmFolderSecurity) client.ClmFolderSecurityWrite { - groups := make([]client.ClmGroupSecurityEntry, len(sec.Groups.Items)) - copy(groups, sec.Groups.Items) - roles := make([]client.ClmRoleSecurityEntry, len(sec.Roles.Items)) - copy(roles, sec.Roles.Items) - users := make([]client.ClmUserSecurityEntry, len(sec.Users.Items)) - copy(users, sec.Users.Items) + groups := make([]client.ClmGroupSecurityEntry, len(sec.Groups)) + copy(groups, sec.Groups) + roles := make([]client.ClmRoleSecurityEntry, len(sec.Roles)) + copy(roles, sec.Roles) + users := make([]client.ClmUserSecurityEntry, len(sec.Users)) + copy(users, sec.Users) return client.ClmFolderSecurityWrite{Groups: groups, Roles: roles, Users: users} } diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index b3b1d73a..084196ce 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -230,7 +230,7 @@ func TestClmFolderBuilder_GrantAndRevoke_Idempotent(t *testing.T) { } else if hasAlreadyExists(annos) { t.Error("first Grant should not report GrantAlreadyExists") } - groups := srv.FolderSecurity("folder-templates").Groups.Items + groups := srv.FolderSecurity("folder-templates").Groups if len(groups) != 1 || groups[0].AccessType != client.ClmAccessTypeViewEdit { t.Fatalf("expected one ViewEdit group entry after Grant, got %+v", groups) } @@ -241,7 +241,7 @@ func TestClmFolderBuilder_GrantAndRevoke_Idempotent(t *testing.T) { } else if !hasAlreadyExists(annos) { t.Error("repeat Grant should report GrantAlreadyExists") } - if groups := srv.FolderSecurity("folder-templates").Groups.Items; len(groups) != 1 { + if groups := srv.FolderSecurity("folder-templates").Groups; len(groups) != 1 { t.Fatalf("expected still exactly one entry after a repeat Grant, got %d", len(groups)) } @@ -252,7 +252,7 @@ func TestClmFolderBuilder_GrantAndRevoke_Idempotent(t *testing.T) { } else if hasAlreadyRevoked(annos) { t.Error("first Revoke should not report GrantAlreadyRevoked") } - groups = srv.FolderSecurity("folder-templates").Groups.Items + groups = srv.FolderSecurity("folder-templates").Groups if len(groups) != 1 || groups[0].AccessType != client.ClmAccessTypeNoAccess { t.Fatalf("expected the entry's AccessType to become NoAccess after Revoke, got %+v", groups) } @@ -281,7 +281,7 @@ func TestClmFolderBuilder_GrantAndRevoke_PreservesOtherPrincipals(t *testing.T) ctx := context.Background() before := srv.FolderSecurity("folder-contracts") - if total := len(before.Groups.Items) + len(before.Roles.Items) + len(before.Users.Items); total != 4 { + if total := len(before.Groups) + len(before.Roles) + len(before.Users); total != 4 { t.Fatalf("expected folder-contracts seeded with 4 entries total, got %d: %+v", total, before) } @@ -302,12 +302,12 @@ func TestClmFolderBuilder_GrantAndRevoke_PreservesOtherPrincipals(t *testing.T) } afterGrant := srv.FolderSecurity("folder-contracts") - if len(afterGrant.Groups.Items) != 3 { // group-legal, group-finance, + the new group-ops - t.Fatalf("expected 3 group entries after granting a new group principal, got %d: %+v", len(afterGrant.Groups.Items), afterGrant.Groups.Items) + if len(afterGrant.Groups) != 3 { // group-legal, group-finance, + the new group-ops + t.Fatalf("expected 3 group entries after granting a new group principal, got %d: %+v", len(afterGrant.Groups), afterGrant.Groups) } - assertGroupsPreserved(t, before.Groups.Items, afterGrant.Groups.Items, "Grant") - assertRolesPreserved(t, before.Roles.Items, afterGrant.Roles.Items, "Grant") - assertUsersPreserved(t, before.Users.Items, afterGrant.Users.Items, "Grant") + assertGroupsPreserved(t, before.Groups, afterGrant.Groups, "Grant") + assertRolesPreserved(t, before.Roles, afterGrant.Roles, "Grant") + assertUsersPreserved(t, before.Users, afterGrant.Users, "Grant") grantObj := &v2.Grant{Principal: groupResource, Entitlement: ent} if annos, err := b.Revoke(ctx, grantObj); err != nil { @@ -317,12 +317,12 @@ func TestClmFolderBuilder_GrantAndRevoke_PreservesOtherPrincipals(t *testing.T) } afterRevoke := srv.FolderSecurity("folder-contracts") - if len(afterRevoke.Groups.Items) != 3 { // still 3 (NoAccess, not removed) - t.Fatalf("expected still 3 group entries after Revoke (NoAccess, not removed), got %d: %+v", len(afterRevoke.Groups.Items), afterRevoke.Groups.Items) + if len(afterRevoke.Groups) != 3 { // still 3 (NoAccess, not removed) + t.Fatalf("expected still 3 group entries after Revoke (NoAccess, not removed), got %d: %+v", len(afterRevoke.Groups), afterRevoke.Groups) } - assertGroupsPreserved(t, before.Groups.Items, afterRevoke.Groups.Items, "Revoke") - assertRolesPreserved(t, before.Roles.Items, afterRevoke.Roles.Items, "Revoke") - assertUsersPreserved(t, before.Users.Items, afterRevoke.Users.Items, "Revoke") + assertGroupsPreserved(t, before.Groups, afterRevoke.Groups, "Revoke") + assertRolesPreserved(t, before.Roles, afterRevoke.Roles, "Revoke") + assertUsersPreserved(t, before.Users, afterRevoke.Users, "Revoke") } func assertGroupsPreserved(t *testing.T, before, after []client.ClmGroupSecurityEntry, when string) { @@ -406,7 +406,7 @@ func TestClmFolderBuilder_GrantAndRevoke_ToleratesBareIDOnRead(t *testing.T) { } else if !hasAlreadyExists(annos) { t.Error("Grant should recognize a bare-ID entry.Href as already granted, not duplicate it") } - if groups := srv.FolderSecurity("folder-templates").Groups.Items; len(groups) != 1 { + if groups := srv.FolderSecurity("folder-templates").Groups; len(groups) != 1 { t.Fatalf("expected still exactly one entry, got %d: %+v", len(groups), groups) } @@ -417,7 +417,7 @@ func TestClmFolderBuilder_GrantAndRevoke_ToleratesBareIDOnRead(t *testing.T) { } else if hasAlreadyRevoked(annos) { t.Fatal("Revoke incorrectly reported GrantAlreadyRevoked for a bare-ID entry.Href — access was left in place instead of being revoked") } - groups := srv.FolderSecurity("folder-templates").Groups.Items + groups := srv.FolderSecurity("folder-templates").Groups if len(groups) != 1 || groups[0].AccessType != client.ClmAccessTypeNoAccess { t.Fatalf("expected the entry's AccessType to become NoAccess after Revoke, got %+v", groups) } @@ -451,7 +451,7 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin if _, _, err := b.Grant(ctx, principal, ent); err != nil { t.Fatalf("Grant with an identity-only principal: %v", err) } - groups := srv.FolderSecurity("folder-templates").Groups.Items + groups := srv.FolderSecurity("folder-templates").Groups if len(groups) != 1 || groups[0].AccessType != client.ClmAccessTypeView { t.Fatalf("expected one View group entry after Grant, got %+v", groups) } @@ -473,7 +473,7 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin if hasAlreadyRevoked(annos) { t.Fatal("Revoke incorrectly reported GrantAlreadyRevoked — the derived Href didn't match the entry Grant wrote") } - groups = srv.FolderSecurity("folder-templates").Groups.Items + groups = srv.FolderSecurity("folder-templates").Groups if len(groups) != 1 || groups[0].AccessType != client.ClmAccessTypeNoAccess { t.Fatalf("expected the entry's AccessType to become NoAccess after Revoke, got %+v", groups) } @@ -486,7 +486,7 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin if _, _, err := b.Grant(ctx, principal, ent); err != nil { t.Fatalf("Grant with an identity-only principal: %v", err) } - users := srv.FolderSecurity("folder-templates").Users.Items + users := srv.FolderSecurity("folder-templates").Users if len(users) != 1 || users[0].AccessType != client.ClmAccessTypeView { t.Fatalf("expected one View user entry after Grant, got %+v", users) } @@ -505,7 +505,7 @@ func TestClmFolderBuilder_GrantAndRevoke_SurvivesIdentityOnlyPrincipal(t *testin if hasAlreadyRevoked(annos) { t.Fatal("Revoke incorrectly reported GrantAlreadyRevoked — the derived Href didn't match the entry Grant wrote") } - users = srv.FolderSecurity("folder-templates").Users.Items + users = srv.FolderSecurity("folder-templates").Users if len(users) != 1 || users[0].AccessType != client.ClmAccessTypeNoAccess { t.Fatalf("expected the entry's AccessType to become NoAccess after Revoke, got %+v", users) } @@ -610,7 +610,7 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te if _, _, err := b.Grant(ctx, principal, ent); err != nil { t.Fatalf("Grant with an identity-only principal: %v", err) } - groups := srv.FolderSecurity("folder-contracts").Groups.Items + groups := srv.FolderSecurity("folder-contracts").Groups wantHref := fmt.Sprintf("%s/v2/%s/groups/group-ops", sampleHost, clmtest.AccountID) var found *client.ClmGroupSecurityEntry for i := range groups { @@ -636,7 +636,7 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te if _, _, err := b.Grant(ctx, principal, ent); err != nil { t.Fatalf("Grant with an identity-only principal: %v", err) } - users := srv.FolderSecurity("folder-contracts").Users.Items + users := srv.FolderSecurity("folder-contracts").Users wantHref := fmt.Sprintf("%s/v2/%s/members/member-dave", sampleHost, clmtest.AccountID) var found *client.ClmUserSecurityEntry for i := range users { From aec02e005663de8b15e7103e5498fa7f8581814b Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 21 Aug 2026 10:47:59 -0300 Subject: [PATCH 36/41] fix: rework PatchFolderSecurity on CLM's ChangeSecurityTasks endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PatchFolderSecurity called the generic PATCH /v2/{accountId}/folders/{id} with a Security payload. Confirmed live that this silently no-ops: 200 OK, no error, but a fresh GET shows no change — while a trivial PATCH of another field (Description) on the same folder DID apply and bump UpdatedDate, isolating the failure to Security specifically. CLM's own error code list names a distinct "136 - Missing Change Security Task", and the CLM API Reference confirms ChangeSecurityTasks as its own async Task API resource ("Post: Set the security on a folder") for exactly this. Rewritten to POST /v2/{accountId}/changesecuritytasks with {Href: , Security: } (this endpoint takes no {id} path parameter, so the target folder is identified by the body's Href), then poll GET .../changesecuritytasks/{taskid} until Status leaves "waiting"/"processing" — ChangeSecurityTasks uses a distinct lowercase Status vocabulary from FolderSearchTasks' PascalCase, confirmed via the CLM API Reference. This is implemented against ChangeSecurityTasks' documented schema but, per explicit instruction to stop live-testing against the customer's tenant, is NOT independently verified live — see clm_client.go's package doc and PatchFolderSecurity's doc for the full evidence chain and what would need re-checking if this doesn't work in practice. Co-Authored-By: Claude Sonnet 5 --- pkg/client/clm_client.go | 142 +++++++++++++++++++++++++-------- pkg/client/clm_client_test.go | 27 +++++++ pkg/client/clm_models.go | 20 ++++- pkg/client/clmtest/handlers.go | 46 ++++++++--- pkg/client/clmtest/server.go | 53 +++++++++++- 5 files changed, 240 insertions(+), 48 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 84512285..4f68c002 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -19,19 +19,16 @@ // - GET /v2/{accountId}/folders/{id}?expand=Security - Get a folder with its explicit security entries. // Security is three separate collections by principal type (Groups/Roles/Users), confirmed via // DocuSign's own Folders.Patch reference page - see ClmFolderSecurity's doc in clm_models.go. -// - PATCH /v2/{accountId}/folders/{id} - does NOT actually update Security, despite Security being a -// documented field on this same Patch reference page and the write appearing to succeed (200, -// no error). Confirmed live: a trivial PATCH of another field (Description) applies and bumps -// UpdatedDate; the identical request with only Security populated returns 200 but a subsequent -// fresh GET shows Security unchanged and UpdatedDate untouched. Tried against a freshly-created, -// disposable test folder (created and deleted solely for this check — never against real -// customer data): both the {Item: {...}, AccessType} shape confirmed on reads and a flat -// {Href, AccessType} shape, both PATCH and PUT, all had zero effect. CLM's own error code list -// (see the Response and Error Codes page) names a distinct "136 - Missing Change Security Task", -// which strongly suggests folder security changes require a dedicated Task API endpoint (like -// SearchFolders' FolderSearchTasks) rather than this generic object Patch — not yet located. -// PatchFolderSecurity/clm_folders.go's Grant/Revoke are UNCONFIRMED to work against a real -// tenant as a result; treat this as a known, open gap, not a confirmed-working path. +// - POST /v2/{accountId}/changesecuritytasks - Update folder security (grant: set an AccessType on +// the relevant Groups/Roles/Users entry; revoke: set that entry's AccessType to "NoAccess") — see +// PatchFolderSecurity's doc. NOT the generic Folders Patch: that endpoint accepts a Security field +// in its documented schema but silently ignores it live (confirmed: 200 OK, no error, but a fresh +// GET shows no change, while a trivial PATCH of another field on the same folder did apply). CLM's +// own error code list names a distinct "136 - Missing Change Security Task", and the CLM API +// Reference confirms ChangeSecurityTasks as its own Task API resource for this. This rewrite +// matches ChangeSecurityTasks' documented request/response schema but is NOT independently +// verified live — per explicit instruction, this project is done live-testing against the +// customer's tenant. // // Groups: // - GET /v2/{accountId}/groups - List CLM groups (GetAllGroups) @@ -123,14 +120,17 @@ const ( // SearchFolders' doc. The previously-assumed synchronous "/v2/%s/folders/search" // endpoint does not exist for folders (confirmed live: 405 Method Not Allowed). clmCreateFolderSearchTask = "/v2/%s/foldersearchtasks" - clmGetFolder = "/v2/%s/folders/%s" - clmPatchFolder = "/v2/%s/folders/%s" - clmGetGroups = "/v2/%s/groups" - clmGetGroupMembers = "/v2/%s/groups/%s/groupmembers" - clmGetMembers = "/v2/%s/members" - clmGetMemberGroups = "/v2/%s/members/%s/groups" - clmPatchPutMember = "/v2/%s/members/%s" - clmGetPermissionSet = "/v2/%s/permissionsets" + // clmCreateChangeSecurityTask creates a CLM ChangeSecurityTasks task — see + // PatchFolderSecurity's doc. The generic Folders Patch this replaced silently + // ignores a Security payload (confirmed live: 200 OK, but no effect). + clmCreateChangeSecurityTask = "/v2/%s/changesecuritytasks" + clmGetFolder = "/v2/%s/folders/%s" + clmGetGroups = "/v2/%s/groups" + clmGetGroupMembers = "/v2/%s/groups/%s/groupmembers" + clmGetMembers = "/v2/%s/members" + clmGetMemberGroups = "/v2/%s/members/%s/groups" + clmPatchPutMember = "/v2/%s/members/%s" + clmGetPermissionSet = "/v2/%s/permissionsets" // clmGroupPath and clmMemberPath are path *shapes*, not endpoints this connector // calls — hrefFor builds a Href string locally from these, issuing no request beyond @@ -302,12 +302,13 @@ func (c *Client) doClmRequest(ctx context.Context, method string, reqURL *url.UR return anno, err } -// clmMaxFolderSearchTaskPolls bounds how many times SearchFolders polls a "Processing" -// FolderSearchTasks task before giving up. Against a live CLM tenant the task always -// resolved inline (Status "Success" already in the POST response), so this branch is -// implemented per the Task API's documented contract but unverified live; the cap -// exists so a task that genuinely never resolves fails loudly instead of hanging. -const clmMaxFolderSearchTaskPolls = 30 +// clmMaxTaskPolls bounds how many times a CLM Task API poll loop (SearchFolders, +// PatchFolderSecurity) retries a not-yet-resolved task before giving up. Against a live +// CLM tenant, FolderSearchTasks always resolved inline (Status "Success" already in the +// POST response), so both polling branches are implemented per the Task API's +// documented contract but unverified live; the cap exists so a task that genuinely +// never resolves fails loudly instead of hanging. +const clmMaxTaskPolls = 30 // ClmFolderSearchTaskPollInterval is how long SearchFolders waits between polls of a // "Processing" FolderSearchTasks task. Exported, like DefaultPageSize, so tests can @@ -413,8 +414,8 @@ func (c *Client) getClmFolderSearchResultPage(ctx context.Context, resultHref st // is unverified against a live tenant. func (c *Client) awaitClmFolderSearchTask(ctx context.Context, task ClmFolderSearchTaskResponse) (ClmFolderSearchTaskResponse, error) { for attempt := 0; task.Status == "Processing"; attempt++ { - if attempt >= clmMaxFolderSearchTaskPolls { - return task, fmt.Errorf("baton-docusign: CLM folder search task %s did not finish after %d polls", task.Href, clmMaxFolderSearchTaskPolls) + if attempt >= clmMaxTaskPolls { + return task, fmt.Errorf("baton-docusign: CLM folder search task %s did not finish after %d polls", task.Href, clmMaxTaskPolls) } select { case <-ctx.Done(): @@ -500,25 +501,100 @@ func (c *Client) getFolder(ctx context.Context, folderID string, noCache bool, e // Grant/Revoke on clm_folder relies on - could silently no-op or 404 against a real // tenant. Verify which endpoint the real API expects before treating folder // provisioning here as more than best-effort. +// PatchFolderSecurity sets a folder's security via CLM's ChangeSecurityTasks — a +// dedicated async Task API endpoint, NOT the generic Folders Patch this function used +// to call. Confirmed live that the generic PATCH /v2/{accountId}/folders/{id} silently +// ignores a Security payload entirely (200 OK, no error, but a fresh GET shows no +// change — a trivial PATCH of another field on the same folder DID apply and bump +// UpdatedDate, isolating the failure to Security specifically); CLM's own error code +// list separately names "136 - Missing Change Security Task", and the CLM API +// Reference confirms a distinct ChangeSecurityTasks resource ("Post: Set the security +// on a folder") exists for exactly this. See clm_client.go's package doc "Folders" +// section for the live evidence that led here. +// +// This rewrite is implemented against ChangeSecurityTasks' documented request/response +// schema but is NOT independently verified live: per explicit instruction, this +// project is done live-testing against the customer's tenant. Two things are worth a +// second look if this doesn't work in practice: +// - The request body's top-level Href is assumed to identify the target folder +// (paralleling ParentFolder/other object references elsewhere in this API), since +// ChangeSecurityTasks' POST takes no {id} path parameter at all — the docs' own +// schema table doesn't say this in words, only implies it from the shape. +// - ChangeSecurityTasks' Status values are documented in lowercase (success/waiting/ +// failure/processing) — confirmed distinct from FolderSearchTasks' PascalCase +// (Success/Processing), not a documentation inconsistency to normalize away. func (c *Client) PatchFolderSecurity(ctx context.Context, folderID string, write ClmFolderSecurityWrite) (annotations.Annotations, error) { if err := c.ensureClmReady(ctx); err != nil { return nil, err } - folderURL, err := c.buildClmClientURL(clmPatchFolder, folderID) + folderURL, err := c.buildClmClientURL(clmGetFolder, folderID) + if err != nil { + return nil, err + } + + createURL, err := c.buildClmClientURL(clmCreateChangeSecurityTask) if err != nil { return nil, err } - body := ClmFolderSecurityPatch{Security: write} - anno, err := c.doClmRequest(ctx, http.MethodPatch, folderURL, body, nil) + body := ClmChangeSecurityTaskRequest{Href: folderURL.String(), Security: write} + + var task ClmChangeSecurityTaskResponse + anno, err := c.doClmRequest(ctx, http.MethodPost, createURL, body, &task) if err != nil { - return anno, fmt.Errorf("baton-docusign: failed to update CLM folder %s security: %w", folderID, err) + return anno, fmt.Errorf("baton-docusign: failed to create CLM change-security task for folder %s: %w", folderID, err) + } + + task, err = c.awaitClmChangeSecurityTask(ctx, task) + if err != nil { + return anno, err + } + if task.Status != ClmChangeSecurityStatusSuccess { + return anno, fmt.Errorf("baton-docusign: CLM change-security task %s for folder %s did not succeed (status %q)", task.Href, folderID, task.Status) } return anno, nil } +// clmChangeSecurityStatus* are ChangeSecurityTasks' documented Status values — +// lowercase, distinct from FolderSearchTasks' PascalCase. See PatchFolderSecurity's doc. +const ( + ClmChangeSecurityStatusSuccess = "success" + ClmChangeSecurityStatusWaiting = "waiting" + ClmChangeSecurityStatusFailure = "failure" + ClmChangeSecurityStatusProcessing = "processing" +) + +// awaitClmChangeSecurityTask polls a CLM ChangeSecurityTasks task until it leaves +// "waiting"/"processing" — mirrors awaitClmFolderSearchTask's polling loop, but against +// ChangeSecurityTasks' distinct (lowercase) Status vocabulary and leaner response shape +// (no Result field: this task mutates rather than returns data, so success/failure is +// the only thing to observe). Unverified live — see PatchFolderSecurity's doc. +func (c *Client) awaitClmChangeSecurityTask(ctx context.Context, task ClmChangeSecurityTaskResponse) (ClmChangeSecurityTaskResponse, error) { + for attempt := 0; task.Status == ClmChangeSecurityStatusWaiting || task.Status == ClmChangeSecurityStatusProcessing; attempt++ { + if attempt >= clmMaxTaskPolls { + return task, fmt.Errorf("baton-docusign: CLM change-security task %s did not finish after %d polls", task.Href, clmMaxTaskPolls) + } + select { + case <-ctx.Done(): + return task, ctx.Err() + case <-time.After(ClmFolderSearchTaskPollInterval): + } + + pollURL, err := url.Parse(task.Href) + if err != nil { + return task, fmt.Errorf("baton-docusign: invalid CLM change-security task href %q: %w", task.Href, err) + } + // WithNoCache: see awaitClmFolderSearchTask's identical comment — repeated polls + // of the same task URL must observe its latest Status, not a memoized first one. + if _, err := c.doClmRequest(ctx, http.MethodGet, pollURL, nil, &task, uhttp.WithNoCache()); err != nil { + return task, fmt.Errorf("baton-docusign: failed to poll CLM change-security task %s: %w", task.Href, err) + } + } + return task, nil +} + // ListGroups lists CLM groups (a distinct object from eSignature groups). // // Pagination: offset/limit, see package doc. diff --git a/pkg/client/clm_client_test.go b/pkg/client/clm_client_test.go index ee137e46..e6c7dc89 100644 --- a/pkg/client/clm_client_test.go +++ b/pkg/client/clm_client_test.go @@ -61,6 +61,33 @@ func TestSearchFolders_PollsUntilSuccess(t *testing.T) { } } +// TestPatchFolderSecurity_PollsUntilSuccess is a regression test for +// PatchFolderSecurity's awaitClmChangeSecurityTask branch — unverified against a live +// tenant (see PatchFolderSecurity's doc in clm_client.go), so this mock-driven test is +// this branch's only coverage. +func TestPatchFolderSecurity_PollsUntilSuccess(t *testing.T) { + original := client.ClmFolderSearchTaskPollInterval + client.ClmFolderSearchTaskPollInterval = time.Millisecond + defer func() { client.ClmFolderSearchTaskPollInterval = original }() + + srv, c := clmtest.NewServer(t) + ctx := context.Background() + + groupHref := srv.GroupHref("group-ops") + srv.SetPendingChangeSecurityPolls(2) + + if _, err := c.PatchFolderSecurity(ctx, "folder-templates", client.ClmFolderSecurityWrite{ + Groups: []client.ClmGroupSecurityEntry{{AccessType: client.ClmAccessTypeView, Href: groupHref}}, + }); err != nil { + t.Fatalf("PatchFolderSecurity: %v", err) + } + + sec := srv.FolderSecurity("folder-templates") + if len(sec.Groups) != 1 || sec.Groups[0].AccessType != client.ClmAccessTypeView || sec.Groups[0].Href != groupHref { + t.Fatalf("expected one View entry for %s once the task resolves, got %+v", groupHref, sec.Groups) + } +} + func TestGetFolder_ExpandSecurity(t *testing.T) { _, c := clmtest.NewServer(t) ctx := context.Background() diff --git a/pkg/client/clm_models.go b/pkg/client/clm_models.go index 47b64885..ef2feca3 100644 --- a/pkg/client/clm_models.go +++ b/pkg/client/clm_models.go @@ -89,6 +89,17 @@ type ClmFolderSearchTaskResponse struct { Result *ClmFolderPage `json:"Result,omitempty"` } +// ClmChangeSecurityTaskResponse is CLM's ChangeSecurityTasks response envelope — per +// the CLM API Reference's documented schema for this resource's Post/Get methods. +// Deliberately leaner than ClmFolderSearchTaskResponse: this task mutates rather than +// returns data, so there's no Result field, just Href (poll URL) and Status. Status +// uses a distinct, lowercase vocabulary from FolderSearchTasks — see +// PatchFolderSecurity's doc in clm_client.go. Not independently confirmed live. +type ClmChangeSecurityTaskResponse struct { + Href string `json:"Href"` + Status string `json:"Status"` +} + // ClmFolderSecurity is a folder's explicit (non-inherited) security assignments. // Confirmed live against a real CLM tenant (GetFolder?expand=Security) to be three // SEPARATE, flat (non-paginated) arrays by principal type — no First/Href/Last/Limit/ @@ -240,9 +251,12 @@ func (e *ClmUserSecurityEntry) UnmarshalJSON(data []byte) error { return nil } -// ClmFolderSecurityPatch is the request body for PATCH .../folders/{id} when updating -// folder security. -type ClmFolderSecurityPatch struct { +// ClmChangeSecurityTaskRequest is the request body for POST .../changesecuritytasks — +// see PatchFolderSecurity's doc in clm_client.go. Href identifies the target folder +// (ChangeSecurityTasks' POST takes no {id} path parameter), Security carries the +// complete new state. +type ClmChangeSecurityTaskRequest struct { + Href string `json:"Href"` Security ClmFolderSecurityWrite `json:"Security"` } diff --git a/pkg/client/clmtest/handlers.go b/pkg/client/clmtest/handlers.go index 44770217..b7b646fd 100644 --- a/pkg/client/clmtest/handlers.go +++ b/pkg/client/clmtest/handlers.go @@ -111,17 +111,13 @@ func (s *Server) handleGetFolder(w http.ResponseWriter, r *http.Request) { // because it's safe under replace semantics too. Modeling this endpoint as a merge // would hide a regression to sending just the one changed entry — replace surfaces it // immediately as other principals' entries disappearing. -func (s *Server) handlePatchFolder(w http.ResponseWriter, r *http.Request) { +// Doc URL: https://developers.docusign.com/docs/clm-api/reference/tasks/changesecuritytasks/post/ +// Simulates PatchFolderSecurity's real endpoint (ChangeSecurityTasks), not the generic +// Folders Patch this replaced — see clm_client.go's PatchFolderSecurity doc for why. +func (s *Server) handleCreateChangeSecurityTask(w http.ResponseWriter, r *http.Request) { s.mu.Lock() defer s.mu.Unlock() - id := r.PathValue("id") - f, ok := s.folders[id] - if !ok { - writeNotFound(w) - return - } - bodyBytes, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) @@ -155,19 +151,49 @@ func (s *Server) handlePatchFolder(w http.ResponseWriter, r *http.Request) { } } - var body client.ClmFolderSecurityPatch + var body client.ClmChangeSecurityTaskRequest if err := json.Unmarshal(bodyBytes, &body); err != nil { w.WriteHeader(http.StatusBadRequest) return } + folderID := idFromHref(body.Href) + f, ok := s.folders[folderID] + if !ok { + writeNotFound(w) + return + } + f.Security = client.ClmFolderSecurity{ Groups: body.Security.Groups, Roles: body.Security.Roles, Users: body.Security.Users, } - writeJSON(w, *f) + s.nextChangeSecurityTaskID++ + taskHref := fmt.Sprintf("%s/v2/%s/changesecuritytasks/%d", s.baseURL, AccountID, s.nextChangeSecurityTaskID) + status := client.ClmChangeSecurityStatusSuccess + if s.pendingChangeSecurityPolls > 0 { + status = client.ClmChangeSecurityStatusWaiting + } + writeJSON(w, client.ClmChangeSecurityTaskResponse{Href: taskHref, Status: status}) +} + +// Doc URL: https://developers.docusign.com/docs/clm-api/reference/tasks/changesecuritytasks/get/ +func (s *Server) handlePollChangeSecurityTask(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + taskID := r.PathValue("id") + taskHref := fmt.Sprintf("%s/v2/%s/changesecuritytasks/%s", s.baseURL, AccountID, taskID) + + if s.pendingChangeSecurityPolls > 0 { + s.pendingChangeSecurityPolls-- + writeJSON(w, client.ClmChangeSecurityTaskResponse{Href: taskHref, Status: client.ClmChangeSecurityStatusWaiting}) + return + } + + writeJSON(w, client.ClmChangeSecurityTaskResponse{Href: taskHref, Status: client.ClmChangeSecurityStatusSuccess}) } // Doc URL: https://developers.docusign.com/docs/clm-api/reference/objects/groups/ diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index 7d9bbbbd..e83c6181 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -31,7 +31,8 @@ // GET /v2/{accountId}/foldersearchtasks/{id} — SearchFolders (poll a task — see PendingFolderSearchPolls) // GET /v2/{accountId}/foldersearchtasks/{id}/result — SearchFolders (continuation pages) // GET /v2/{accountId}/folders/{id} — GetFolder (supports ?expand=Security) -// PATCH /v2/{accountId}/folders/{id} — PatchFolderSecurity +// POST /v2/{accountId}/changesecuritytasks — PatchFolderSecurity (create task; resolves inline) +// GET /v2/{accountId}/changesecuritytasks/{id} — PatchFolderSecurity (poll a task — see PendingChangeSecurityPolls) // GET /v2/{accountId}/groups — ListGroups // GET /v2/{accountId}/groups/{id}/groupmembers — GetGroupMembers // GET /v2/{accountId}/members — ListMembers @@ -124,6 +125,17 @@ type Server struct { // POST /foldersearchtasks come back "Processing" this many times before resolving to // "Success" on poll — see SetPendingFolderSearchPolls. Decremented on each poll. pendingFolderSearchPolls int + + nextChangeSecurityTaskID int // incrementing counter for mock ChangeSecurityTasks task IDs + + // pendingChangeSecurityPolls, when > 0, makes the next PatchFolderSecurity task + // created via POST /changesecuritytasks come back "waiting" this many times before + // resolving to "success" on poll — see SetPendingChangeSecurityPolls. Decremented on + // each poll. + pendingChangeSecurityPolls int + + forcedDiscoveryStatus int // non-zero forces handleClmAccountDiscovery to fail with this HTTP status, for tests + forcedUserInfoStatus int // non-zero forces handleUserInfo to fail with this HTTP status, for tests } // SetPendingFolderSearchPolls makes the next folder search task created by this server @@ -137,6 +149,42 @@ func (s *Server) SetPendingFolderSearchPolls(n int) { s.pendingFolderSearchPolls = n } +// SetPendingChangeSecurityPolls makes the next change-security task created by this +// server require n polls of GET .../changesecuritytasks/{id} before resolving to +// "success" — exercises PatchFolderSecurity's awaitClmChangeSecurityTask polling loop, +// unverified against a live tenant (see PatchFolderSecurity's doc in clm_client.go). +func (s *Server) SetPendingChangeSecurityPolls(n int) { + s.mu.Lock() + defer s.mu.Unlock() + s.pendingChangeSecurityPolls = n +} + +// ForceClmDiscoveryStatus makes CLM account discovery fail with the given HTTP status +// for the fixed test bearer token — for tests that need a specific gRPC code out of +// ensureClmInitialized (e.g. a transient 5xx) rather than the normal 401/403/404 an +// auth/account failure produces. Call after NewServer returns. +// +// handleClmAccountDiscovery is registered behind requireAuth, so only a client +// presenting testBearerToken reaches this forced status; a client built via +// NewClientWithToken("wrong-token") (or any other mismatched token) still gets a plain +// 401 from requireAuth itself and never sees it. ForceUserInfoStatus has no such +// caveat — /oauth/userinfo isn't wrapped in requireAuth. +func (s *Server) ForceClmDiscoveryStatus(status int) { + s.mu.Lock() + defer s.mu.Unlock() + s.forcedDiscoveryStatus = status +} + +// ForceUserInfoStatus makes every subsequent eSignature /oauth/userinfo call fail with +// the given HTTP status — for tests that need ensureInitialized itself (not CLM account +// discovery) to fail with a specific gRPC code, since handleUserInfo otherwise has no +// auth check to fail on. Call after NewServer returns. +func (s *Server) ForceUserInfoStatus(status int) { + s.mu.Lock() + defer s.mu.Unlock() + s.forcedUserInfoStatus = status +} + // MemberGroupsRequestCount returns how many times GET .../members/{id}/groups has been // called, across all members — used to assert that GetMemberGroups actually issues // multiple requests when a member has more groups than fit on one page, rather than @@ -296,7 +344,8 @@ func newMux(s *Server) *http.ServeMux { mux.HandleFunc("GET /v2/{accountId}/foldersearchtasks/{id}", s.requireAuth(s.handlePollFolderSearchTask)) mux.HandleFunc("GET /v2/{accountId}/foldersearchtasks/{id}/result", s.requireAuth(s.handleFolderSearchTaskResult)) mux.HandleFunc("GET /v2/{accountId}/folders/{id}", s.requireAuth(s.handleGetFolder)) - mux.HandleFunc("PATCH /v2/{accountId}/folders/{id}", s.requireAuth(s.handlePatchFolder)) + mux.HandleFunc("POST /v2/{accountId}/changesecuritytasks", s.requireAuth(s.handleCreateChangeSecurityTask)) + mux.HandleFunc("GET /v2/{accountId}/changesecuritytasks/{id}", s.requireAuth(s.handlePollChangeSecurityTask)) mux.HandleFunc("GET /v2/{accountId}/groups", s.requireAuth(s.handleListGroups)) mux.HandleFunc("GET /v2/{accountId}/groups/{id}/groupmembers", s.requireAuth(s.handleGroupMembers)) mux.HandleFunc("GET /v2/{accountId}/members", s.requireAuth(s.handleListMembers)) From 67f0a138bfa692780203a0dc1b3134e7ff40628a Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 21 Aug 2026 12:55:45 -0300 Subject: [PATCH 37/41] fix: nest ChangeSecurityTasks' folder+security under Folder, not top-level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Task.ChangeSecurityTask request/response schema's default collapsed view on DocuSign's docs site looks like a flat {Href, Security} pair sibling to Status — the shape the previous commit implemented. Expanding "Folder" (now possible: Playwright browser automation was fixed in this session, unblocking full interaction with this JS-rendered, click-to-expand API reference) shows it's the *complete* Folder object schema, itself carrying its own nested Href and Security fields. The outer Href/Security/ Status are generic Task-wrapper fields this doc-generation tool reuses across every Task type (confirmed by cross-checking FolderSearchTasks' GET/POST pages, which show the identical pattern), not what ChangeSecurityTasks actually reads for a folder security change. Also removes a stale, duplicated doc comment on PatchFolderSecurity left over from an earlier rebase — an outdated "UNVERIFIED, may need ChangeSecurityTasks" paragraph was sitting directly above the newer, already-accurate version describing the same function. Still not independently verified live, per explicit instruction to stop live-testing against the customer's tenant — but now matches the fully expanded, confirmed schema rather than its collapsed default view. Co-Authored-By: Claude Sonnet 5 --- pkg/client/clm_client.go | 58 ++++++++++++++++------------------ pkg/client/clm_models.go | 33 ++++++++++++++----- pkg/client/clmtest/handlers.go | 22 +++++++------ 3 files changed, 64 insertions(+), 49 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 4f68c002..d038797f 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -487,42 +487,38 @@ func (c *Client) getFolder(ctx context.Context, folderID string, noCache bool, e // complete security state across all three principal-type collections (see the // connector-layer caller — clmFolderSecurityToWrite builds this from a fresh read, // with one entry changed/added in whichever of Groups/Roles/Users the grant/revoke -// targets), not just the one entry being changed: Folders.Patch's merge-vs-replace +// targets), not just the one entry being changed: ChangeSecurityTasks' merge-vs-replace // semantics for the Security field are undocumented, and sending the complete state // is correct under either interpretation, whereas sending only the one changed entry // would wipe every other principal's access to the folder if the real API replaces // rather than merges. // -// UNVERIFIED against a real CLM tenant, and higher-risk than the merge-semantics -// question above: DocuSign's docs also reference a separate `ChangeSecurityTasks` -// resource (POST /v2/{accountId}/changesecuritytasks), which may be the documented -// way to mutate folder security asynchronously rather than a direct PATCH on the -// folder itself. If that's the actual contract, this method - the entire mechanism -// Grant/Revoke on clm_folder relies on - could silently no-op or 404 against a real -// tenant. Verify which endpoint the real API expects before treating folder -// provisioning here as more than best-effort. -// PatchFolderSecurity sets a folder's security via CLM's ChangeSecurityTasks — a -// dedicated async Task API endpoint, NOT the generic Folders Patch this function used -// to call. Confirmed live that the generic PATCH /v2/{accountId}/folders/{id} silently -// ignores a Security payload entirely (200 OK, no error, but a fresh GET shows no -// change — a trivial PATCH of another field on the same folder DID apply and bump -// UpdatedDate, isolating the failure to Security specifically); CLM's own error code -// list separately names "136 - Missing Change Security Task", and the CLM API -// Reference confirms a distinct ChangeSecurityTasks resource ("Post: Set the security -// on a folder") exists for exactly this. See clm_client.go's package doc "Folders" -// section for the live evidence that led here. +// Sets a folder's security via CLM's ChangeSecurityTasks — a dedicated async Task API +// endpoint, NOT the generic Folders Patch this function used to call. Confirmed live +// that the generic PATCH /v2/{accountId}/folders/{id} silently ignores a Security +// payload entirely (200 OK, no error, but a fresh GET shows no change — a trivial PATCH +// of another field on the same folder DID apply and bump UpdatedDate, isolating the +// failure to Security specifically); CLM's own error code list separately names +// "136 - Missing Change Security Task", and the CLM API Reference confirms a distinct +// ChangeSecurityTasks resource ("Post: Set the security on a folder") exists for +// exactly this. See clm_client.go's package doc "Folders" section for the live +// evidence that led here. // -// This rewrite is implemented against ChangeSecurityTasks' documented request/response -// schema but is NOT independently verified live: per explicit instruction, this -// project is done live-testing against the customer's tenant. Two things are worth a -// second look if this doesn't work in practice: -// - The request body's top-level Href is assumed to identify the target folder -// (paralleling ParentFolder/other object references elsewhere in this API), since -// ChangeSecurityTasks' POST takes no {id} path parameter at all — the docs' own -// schema table doesn't say this in words, only implies it from the shape. -// - ChangeSecurityTasks' Status values are documented in lowercase (success/waiting/ -// failure/processing) — confirmed distinct from FolderSearchTasks' PascalCase -// (Success/Processing), not a documentation inconsistency to normalize away. +// The request body shape (ClmChangeSecurityTaskRequest) is confirmed via the CLM API +// Reference's interactive schema browser, expanded past its default collapsed view — +// the target folder's Href and its new Security both nest under a Folder field; a +// same-shaped top-level Href/Security/Status on the schema are generic Task-wrapper +// fields this doc-generation tool reuses across every Task type, not what +// ChangeSecurityTasks actually reads for a folder security change (an earlier version +// of this code got this wrong from the same page's default collapsed view, which looks +// identical to a flat {Href, Security} pair until "Folder" is expanded). +// +// NOT independently verified live: per explicit instruction, this project is done +// live-testing against the customer's tenant. ChangeSecurityTasks' Status values are +// documented in lowercase (success/waiting/failure/processing) — confirmed distinct +// from FolderSearchTasks' PascalCase (Success/Processing), not a documentation +// inconsistency to normalize away — is the other thing worth a second look if this +// doesn't work in practice. func (c *Client) PatchFolderSecurity(ctx context.Context, folderID string, write ClmFolderSecurityWrite) (annotations.Annotations, error) { if err := c.ensureClmReady(ctx); err != nil { return nil, err @@ -538,7 +534,7 @@ func (c *Client) PatchFolderSecurity(ctx context.Context, folderID string, write return nil, err } - body := ClmChangeSecurityTaskRequest{Href: folderURL.String(), Security: write} + body := ClmChangeSecurityTaskRequest{Folder: ClmChangeSecurityTaskFolder{Href: folderURL.String(), Security: write}} var task ClmChangeSecurityTaskResponse anno, err := c.doClmRequest(ctx, http.MethodPost, createURL, body, &task) diff --git a/pkg/client/clm_models.go b/pkg/client/clm_models.go index ef2feca3..9ddf60e0 100644 --- a/pkg/client/clm_models.go +++ b/pkg/client/clm_models.go @@ -90,11 +90,14 @@ type ClmFolderSearchTaskResponse struct { } // ClmChangeSecurityTaskResponse is CLM's ChangeSecurityTasks response envelope — per -// the CLM API Reference's documented schema for this resource's Post/Get methods. -// Deliberately leaner than ClmFolderSearchTaskResponse: this task mutates rather than -// returns data, so there's no Result field, just Href (poll URL) and Status. Status -// uses a distinct, lowercase vocabulary from FolderSearchTasks — see -// PatchFolderSecurity's doc in clm_client.go. Not independently confirmed live. +// the CLM API Reference's documented "Task.ChangeSecurityTask" schema (confirmed live +// via the rendered doc site, not just its collapsed default view — see +// ClmChangeSecurityTaskRequest's doc for why that distinction mattered here). The full +// schema also lists top-level Folder and Security fields alongside Href/Status, but +// PatchFolderSecurity only needs Status to decide success/failure; Href is this task's +// own poll URL, echoed back from the create call. Status uses a distinct, lowercase +// vocabulary from FolderSearchTasks — see PatchFolderSecurity's doc in clm_client.go. +// Not independently confirmed live (no more live-testing against the customer tenant). type ClmChangeSecurityTaskResponse struct { Href string `json:"Href"` Status string `json:"Status"` @@ -252,10 +255,24 @@ func (e *ClmUserSecurityEntry) UnmarshalJSON(data []byte) error { } // ClmChangeSecurityTaskRequest is the request body for POST .../changesecuritytasks — -// see PatchFolderSecurity's doc in clm_client.go. Href identifies the target folder -// (ChangeSecurityTasks' POST takes no {id} path parameter), Security carries the -// complete new state. +// see PatchFolderSecurity's doc in clm_client.go. Confirmed via the CLM API Reference's +// interactive schema browser (its default collapsed view initially looked like a flat +// {Href, Security} pair sibling to Status — an earlier version of this struct assumed +// exactly that — but expanding "Folder" shows it's the *complete* Folder object schema, +// itself carrying its own nested Href and Security fields; the outer Href/Security +// alongside Status are generic Task-wrapper fields this doc-generation tool reuses +// across every Task type, not what ChangeSecurityTasks actually reads for a folder +// security change). The target folder and its new security both nest under Folder. type ClmChangeSecurityTaskRequest struct { + Folder ClmChangeSecurityTaskFolder `json:"Folder"` +} + +// ClmChangeSecurityTaskFolder is the minimal Folder reference ChangeSecurityTasks' +// POST needs: which folder (Href) and what to set its security to (Security) — see +// ClmChangeSecurityTaskRequest's doc. The real Folder object has many more fields +// (Name, ParentFolder, Path, etc.); none are required here, mirroring how ParentFolder +// references elsewhere in this API only ever need Href. +type ClmChangeSecurityTaskFolder struct { Href string `json:"Href"` Security ClmFolderSecurityWrite `json:"Security"` } diff --git a/pkg/client/clmtest/handlers.go b/pkg/client/clmtest/handlers.go index b7b646fd..547481f8 100644 --- a/pkg/client/clmtest/handlers.go +++ b/pkg/client/clmtest/handlers.go @@ -131,14 +131,16 @@ func (s *Server) handleCreateChangeSecurityTask(w http.ResponseWriter, r *http.R // representation here is the only way this mock can actually catch a regression // to sending it, across all three of Groups/Roles/Users. var rawBody struct { - Security struct { - Groups []map[string]any `json:"Groups"` - Roles []map[string]any `json:"Roles"` - Users []map[string]any `json:"Users"` - } `json:"Security"` + Folder struct { + Security struct { + Groups []map[string]any `json:"Groups"` + Roles []map[string]any `json:"Roles"` + Users []map[string]any `json:"Users"` + } `json:"Security"` + } `json:"Folder"` } if json.Unmarshal(bodyBytes, &rawBody) == nil { - for _, entries := range [][]map[string]any{rawBody.Security.Groups, rawBody.Security.Roles, rawBody.Security.Users} { + for _, entries := range [][]map[string]any{rawBody.Folder.Security.Groups, rawBody.Folder.Security.Roles, rawBody.Folder.Security.Users} { for _, entry := range entries { if v, present := entry["AccessType"]; present { if str, ok := v.(string); ok && str == "" { @@ -157,7 +159,7 @@ func (s *Server) handleCreateChangeSecurityTask(w http.ResponseWriter, r *http.R return } - folderID := idFromHref(body.Href) + folderID := idFromHref(body.Folder.Href) f, ok := s.folders[folderID] if !ok { writeNotFound(w) @@ -165,9 +167,9 @@ func (s *Server) handleCreateChangeSecurityTask(w http.ResponseWriter, r *http.R } f.Security = client.ClmFolderSecurity{ - Groups: body.Security.Groups, - Roles: body.Security.Roles, - Users: body.Security.Users, + Groups: body.Folder.Security.Groups, + Roles: body.Folder.Security.Roles, + Users: body.Folder.Security.Users, } s.nextChangeSecurityTaskID++ From 07d0337b45a1d578c28765f1d45a20ad042fcf0d Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 21 Aug 2026 14:35:49 -0300 Subject: [PATCH 38/41] docs: align SearchFolders comments with CLM Folders:Search reference Official docs still list POST /folders/search; keep FolderSearchTasks as the live path (405 on sync search) and stop claiming the sync endpoint is absent. Co-authored-by: Cursor --- pkg/client/clm_client.go | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index d038797f..e3b5ec0e 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -15,7 +15,9 @@ // # API Endpoints Used // // Folders: -// - POST /v2/{accountId}/foldersearchtasks - Search for folders (async Task API — see SearchFolders' doc) +// - POST /v2/{accountId}/foldersearchtasks - Search for folders (async Task API — see +// SearchFolders' doc). Prefer this over the also-documented Folders:Search +// (POST /v2/{accountId}/folders/search), which returns 405 live. // - GET /v2/{accountId}/folders/{id}?expand=Security - Get a folder with its explicit security entries. // Security is three separate collections by principal type (Groups/Roles/Users), confirmed via // DocuSign's own Folders.Patch reference page - see ClmFolderSecurity's doc in clm_models.go. @@ -117,8 +119,10 @@ var clmBaseURLCandidateFields = []string{ // CLM API endpoint constants. const ( // clmCreateFolderSearchTask creates a CLM FolderSearchTasks task — see - // SearchFolders' doc. The previously-assumed synchronous "/v2/%s/folders/search" - // endpoint does not exist for folders (confirmed live: 405 Method Not Allowed). + // SearchFolders' doc. The CLM API Reference also documents a synchronous + // Folders:Search at POST /v2/{accountId}/folders/search, but against a real + // tenant that path returns 405 Method Not Allowed, so this connector uses the + // documented async FolderSearchTasks resource instead. clmCreateFolderSearchTask = "/v2/%s/foldersearchtasks" // clmCreateChangeSecurityTask creates a CLM ChangeSecurityTasks task — see // PatchFolderSecurity's doc. The generic Folders Patch this replaced silently @@ -316,24 +320,28 @@ const clmMaxTaskPolls = 30 // needlessly slow. var ClmFolderSearchTaskPollInterval = 2 * time.Second -// SearchFolders discovers folders via CLM's FolderSearchTasks — there is no flat -// list-all or synchronous search endpoint for folders (unlike Groups/Members/ -// PermissionSets). Folder search is part of CLM's async Task API (CLM Task API 101 / -// FolderSearchTasks reference, pasted live since the site is JS-rendered): a POST -// creates a search task, which either resolves inline or must be polled via its own -// Href until Status leaves "Processing", after which the paginated folder list is read -// from the task's Result. +// SearchFolders discovers folders via CLM's FolderSearchTasks (CLM API Reference → +// Tasks → FolderSearchTasks). Unlike Groups/Members/PermissionSets there is no +// flat list-all for folders. The Reference also documents Folders:Search +// (POST /v2/{accountId}/folders/search); this connector follows FolderSearchTasks +// because that sync path returns 405 live (see bullets below). A POST creates a +// search task, which either resolves inline or must be polled via its own Href +// until Status leaves "Processing", after which the paginated folder list is read +// from the task's Result. (FolderSearchTasks' Status field is an unenumerated +// string in the schema — live returns Title-Case "Success"/"Processing", distinct +// from ChangeSecurityTasks' documented lowercase success/waiting/failure/processing.) // // Confirmed live against a real CLM tenant: -// - POST /v2/{accountId}/folders/search (a plain synchronous search — this function's -// original implementation) returns 405 Method Not Allowed: that endpoint doesn't -// exist for folders. +// - POST /v2/{accountId}/folders/search (Folders:Search in the Reference — this +// function's original implementation) returns 405 Method Not Allowed, so the +// documented sync search is not usable on the tenants we hit. // - POST /v2/{accountId}/foldersearchtasks requires a recognized search parameter in // the body — an empty body, or {"Name": ...} (the field ClmFolder's own JSON tag // uses), is rejected with CLM ErrorCode 1024 "no valid search parameter" against // every property name tried except "Title". {"Title": ""} is accepted and matches // every folder (Title is a substring match, so empty matches everything) — -// confirmed against a real account with 100 folders. +// confirmed against a real account with 100 folders. Title is a documented +// FolderSearchTask request field in the Reference. // - The task resolved inline (Status "Success" already in the POST response, Result // already populated) on every live test; the "Processing" polling branch below is // unverified live. From c3e7d3af9f0957f9c3acad10daab7e7ebfd743fa Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 21 Aug 2026 15:17:18 -0300 Subject: [PATCH 39/41] chore: sync baton-sdk to v0.24.6 to match main Unblocks stacked PR #64 verify-versions-match: CI compares this branch's .versions.yaml to the PR merge go.mod, which already resolves to main's v0.24.6. Co-authored-by: Cursor --- .versions.yaml | 2 +- go.mod | 2 +- go.sum | 4 +- .../pkg/connectorstore/connectorstore.go | 28 ++++--- .../dotc1z/engine/pebble/adapter_reader.go | 43 +++++++--- .../pkg/dotc1z/engine/pebble/digest.go | 13 +++ .../pkg/dotc1z/engine/pebble/grant_digest.go | 84 +++++++++++++++++-- .../pkg/dotc1z/format/v3/manifest.go | 24 +++++- .../baton-sdk/pkg/provisioner/provisioner.go | 52 +++++++----- .../conductorone/baton-sdk/pkg/sdk/version.go | 2 +- .../baton-sdk/pkg/uhttp/client.go | 64 +++++++++++++- .../baton-sdk/pkg/uhttp/dbcache.go | 8 +- .../baton-sdk/pkg/uhttp/gocache.go | 12 +-- .../baton-sdk/pkg/uhttp/wrapper.go | 30 ++++++- vendor/modules.txt | 2 +- 15 files changed, 296 insertions(+), 74 deletions(-) diff --git a/.versions.yaml b/.versions.yaml index 9024b678..c37f7e1d 100644 --- a/.versions.yaml +++ b/.versions.yaml @@ -1,4 +1,4 @@ # This file is managed by baton-admin. DO NOT EDIT!!! go-version: 1.25.2 dependencies: - baton-sdk: v0.24.4 \ No newline at end of file + baton-sdk: v0.24.6 \ No newline at end of file diff --git a/go.mod b/go.mod index abb4e189..6dd19f37 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/conductorone/baton-docusign go 1.25.2 require ( - github.com/conductorone/baton-sdk v0.24.4 + github.com/conductorone/baton-sdk v0.24.6 github.com/ennyjfrick/ruleguard-logfatal v0.0.2 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/quasilyte/go-ruleguard/dsl v0.3.23 diff --git a/go.sum b/go.sum index 28097db8..1f7440b7 100644 --- a/go.sum +++ b/go.sum @@ -84,8 +84,8 @@ github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b h1:VXvSNzmr8hMj8 github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= -github.com/conductorone/baton-sdk v0.24.4 h1:W2YogjlYQDp1Mwt/RBiPKLWalXfANEpzg8FLc4c3bvI= -github.com/conductorone/baton-sdk v0.24.4/go.mod h1:SKm95z4KkQ23Tufo2ys88lVzbwKb0AQEbKee5GE0Lig= +github.com/conductorone/baton-sdk v0.24.6 h1:mORfZrBdsxXSYqZxlGMEQTFf6I2fu2/PBF+0c7a73KU= +github.com/conductorone/baton-sdk v0.24.6/go.mod h1:SKm95z4KkQ23Tufo2ys88lVzbwKb0AQEbKee5GE0Lig= github.com/conductorone/dpop v0.2.6 h1:fakwai/Xm2b/fcDUwJN41WtcSI/2UhQOyRIVvnnrrNA= github.com/conductorone/dpop v0.2.6/go.mod h1:gyo8TtzB9SCFCsjsICH4IaLZ7y64CcrDXMOPBwfq/3s= github.com/conductorone/dpop/integrations/dpop_grpc v0.2.4 h1:lYxYi9/WTSL9sE96CO0QF2BY3kehs8dTTApI134TGCA= diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/connectorstore/connectorstore.go b/vendor/github.com/conductorone/baton-sdk/pkg/connectorstore/connectorstore.go index 3708bf5d..50fc2e7d 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/connectorstore/connectorstore.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/connectorstore/connectorstore.go @@ -192,21 +192,29 @@ type EntitlementGrantDigestReader interface { // // For 0 <= level <= the native Level (GrantDigest.Level) this folds // the stored leaves — one contiguous scan of the digest keyspace, no - // grant-index scan. For a finer level it falls back to scanning the - // grant index directly (O(grants)) — slower, but it never errors on a - // "too deep" level. The principal-hash carries a bounded number of - // bits, so a level beyond that resolution is served at the maximum - // (you may get fewer than 2^level distinct buckets). found is false - // when no digest exists. + // grant-index scan. For a finer level, up to the principal-hash's + // resolution, it falls back to scanning the grant index directly + // (O(grants)) — slower, but exact. A level outside that resolution + // (a negative level, or one past the implementation's bucket-hash + // width — any level <= the digest's native Level is always in range; + // the Pebble engine exports its full width as DigestBucketHashBits) + // errors rather than silently serving the maximum resolution: + // a caller that placed its own records by hash (e.g. the Pebble + // engine's PrincipalDigestBucket) must get the same bucket set the + // engine reports, not a quietly coarser one. found is false when no + // digest exists. GetEntitlementGrantDigestNodes(ctx context.Context, entitlement *v2.Entitlement, level int) (nodes []GrantDigestNode, found bool, err error) // ScanEntitlementGrantBucket yields every grant in one digest bucket // of the entitlement (see GrantDigestBucket) as a v2.Grant, stopping // early if yield returns false. Bucket Level 0 scans the whole - // entitlement; a Level finer than the bucket-hash resolution is - // clamped (matching GetEntitlementGrantDigestNodes). It reads the - // grant hash index, which exists only on files whose digest was - // built (they are derived together at seal): callers must check + // entitlement; a Level outside the bucket-hash resolution errors + // (matching GetEntitlementGrantDigestNodes) rather than clamping, + // and an Index outside [0, 2^Level) errors rather than wrapping — + // silently folding either coordinate would scan a bucket other than + // the one addressed. It reads the grant hash index, + // which exists only on files whose digest was built (they are + // derived together at seal): callers must check // GetEntitlementGrantDigest first and treat found=false as "scan // unavailable — read the grants directly", not as "no grants". It // yields nothing when there is no active sync or no matching grants. diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/adapter_reader.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/adapter_reader.go index d0782dd5..882c698f 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/adapter_reader.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/adapter_reader.go @@ -719,13 +719,18 @@ func (e *Engine) GetEntitlementGrantDigest(ctx context.Context, ent *v2.Entitlem // grant-digest rollup nodes at the requested level (2^level buckets; // level 0 = the root). For 0 <= level <= the digest's native level it // folds the stored leaves — one scan of the digest keyspace. For a finer -// level it scans the grant index directly (O(grants)) instead of -// erroring; the level is clamped to the bucket-hash resolution -// (digestMaxWidthBits). +// level, up to digestMaxWidthBits, it scans the grant index directly +// (O(grants)) instead. A level outside [0, digestMaxWidthBits] errors: +// the bucket hash carries no more resolution than digestMaxWidthBits, so +// silently clamping would report buckets a caller's own precomputed +// index (see PrincipalDigestBucket) does not agree with. func (e *Engine) GetEntitlementGrantDigestNodes(ctx context.Context, ent *v2.Entitlement, level int) ([]connectorstore.GrantDigestNode, bool, error) { if level < 0 { return nil, false, fmt.Errorf("pebble: negative grant-digest level %d", level) } + if level > digestMaxWidthBits { + return nil, false, fmt.Errorf("pebble: grant-digest level %d exceeds bucket-hash resolution %d", level, digestMaxWidthBits) + } syncID, err := e.resolveActiveSyncForReader(ctx, nil) if err != nil { return nil, false, err @@ -746,11 +751,10 @@ func (e *Engine) GetEntitlementGrantDigestNodes(ctx context.Context, ent *v2.Ent if level == 0 { return []connectorstore.GrantDigestNode{{Index: 0, Hash: root.Hash, Count: root.Count}}, true, nil } - // The bucket hash carries at most digestMaxWidthBits of resolution; - // a finer level can't address more buckets, so clamp. - bits := min(level, digestMaxWidthBits) - // At or below the stored width, fold the digest leaves (cheap). Finer - // than what we stored, scan the grant index to compute the rollup. + // level is already bounded to [0, digestMaxWidthBits] above. At or + // below the stored width, fold the digest leaves (cheap); finer than + // what we stored, scan the grant index to compute the rollup. + bits := level partition := digestPartitionForEntitlement(id) var folded []foldedBucket if bits <= root.Bits { @@ -775,13 +779,27 @@ func (e *Engine) GetEntitlementGrantDigestNodes(ctx context.Context, ent *v2.Ent // ScanEntitlementGrantBucket implements // connectorstore.EntitlementGrantDigestReader. It yields every grant in // the given digest bucket of the entitlement, translated to v2.Grant. -// Bucket Level 0 scans the whole entitlement; a finer Level is clamped -// to the bucket-hash resolution. Yields nothing when there is no active -// sync or a bare entitlement id resolves to nothing. +// Bucket Level 0 scans the whole entitlement. A Level outside +// [0, digestMaxWidthBits] errors rather than clamping to the bucket-hash +// resolution, and an Index outside [0, 2^Level) errors rather than +// wrapping to its low Level bits: either kind of silent folding would +// scan a bucket other than the one the caller addressed (see +// PrincipalDigestBucket, which only builds in-range buckets). Yields +// nothing when there is no active sync or a bare entitlement id +// resolves to nothing. func (e *Engine) ScanEntitlementGrantBucket(ctx context.Context, ent *v2.Entitlement, bucket connectorstore.GrantDigestBucket, yield func(*v2.Grant) bool) error { if bucket.Level < 0 { return fmt.Errorf("pebble: negative grant-digest level %d", bucket.Level) } + if bucket.Level > digestMaxWidthBits { + return fmt.Errorf("pebble: grant-digest level %d exceeds bucket-hash resolution %d", bucket.Level, digestMaxWidthBits) + } + // Level 0 ignores Index (whole-entitlement scan) per the + // GrantDigestBucket contract; past that, bucketBounds would shift an + // oversized index's high bits away and scan Index mod 2^Level. + if bucket.Level > 0 && uint64(bucket.Index) >= 1<> (16 - bits)" shifts in +// bucketOfHash / foldedLeafBuckets / computeBucketsAtWidth safe: +// growing digestMaxWidthBits without digestLeafPrefixLen would leave +// bits able to exceed 16 and panic on a negative shift at read time. +const _ uint = digestMaxWidthBits - digestBucketHashLen*8 +const _ uint = digestBucketHashLen*8 - digestMaxWidthBits +const _ uint = digestMaxWidthBits - digestLeafPrefixLen*8 +const _ uint = digestLeafPrefixLen*8 - digestMaxWidthBits + // Node-key levels: the root is level 0 (empty prefix); the single leaf // level is 1 (digestLeafPrefixLen-byte prefix). See encodeDigestNodeKey. const ( diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/grant_digest.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/grant_digest.go index b173e5de..c13e4eb5 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/grant_digest.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/grant_digest.go @@ -12,6 +12,7 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/connectorstore" "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/codec" "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb" ) @@ -102,16 +103,89 @@ func grantPrincipalBucketHash64(encodedPrincipalSegments []byte) uint64 { return xxhash.Sum64(encodedPrincipalSegments) } +// DigestBucketHashBits is how many of PrincipalBucketHash's leading bits +// actually select a digest bucket: the stored bucket hash is truncated to +// this width, so bucket levels beyond it cannot subdivide further and are +// rejected (see PrincipalDigestBucket, GetEntitlementGrantDigestNodes, +// ScanEntitlementGrantBucket). +// +// ABI: the stored truncation width, pinned to GrantDigestABIVersion. It may +// only grow, and only under an index-migration bump — which is why it is a +// named constant rather than a literal in PrincipalBucketHash's signature: +// widening the addressable bucket space must not change that signature. +const DigestBucketHashBits = digestBucketHashLen * 8 + +// PrincipalBucketHash is the public form of the grant digest's bucket +// address for a principal: the full 64-bit xxHash64 of the principal's +// ENCODED identity segments (see grantPrincipalBucketHash64). Identity +// only — never the principal's attributes — so a principal keeps its +// bucket across syncs. +// +// Use PrincipalDigestBucket to turn this into a bucket at a given level; +// it owns the index math below so callers never hand-derive it: +// +// bucket, _ := PrincipalDigestBucket(rt, id, level) +// nodes, _, _ := r.GetEntitlementGrantDigestNodes(ctx, ent, level) +// _ = r.ScanEntitlementGrantBucket(ctx, ent, bucket, yield) +// +// Cost: levels at or below the digest's native level +// (GetEntitlementGrantDigest().Level) fold the stored leaves — one cheap +// contiguous scan. A finer level is exact but costs a full scan of the +// entitlement's grant index on every call, so prefer the native level +// unless narrowing a bucket is worth that. +// +// Contract: the bucket at level L holds exactly the principals whose top +// L bits of this hash equal the bucket index — the same index +// GetEntitlementGrantDigestNodes(L) reports and ScanEntitlementGrantBucket +// takes. Only the leading DigestBucketHashBits bits are stored, so a +// level past that has no addressable bucket: PrincipalDigestBucket and +// the read APIs all ERROR on such a level rather than silently folding it +// to DigestBucketHashBits, so a caller's precomputed placement and what +// the engine actually scans never quietly diverge. L == 0 is the whole +// entitlement (index 0). +// +// ABI: pinned to GrantDigestABIVersion alongside GrantContentHash. Two +// SDK builds must place the same principal in the same bucket, so the +// input framing changes only under an index-migration bump. +func PrincipalBucketHash(principalRT, principalID string) uint64 { + enc := codec.AppendTupleStrings(make([]byte, 0, 64), principalRT, principalID) + return grantPrincipalBucketHash64(enc) +} + +// PrincipalDigestBucket places a principal into its grant-digest bucket +// at level: the connectorstore.GrantDigestBucket a caller outside this +// package would otherwise have to hand-derive from PrincipalBucketHash's +// raw shift formula. Index is the top level bits of PrincipalBucketHash, +// matching exactly what GetEntitlementGrantDigestNodes(level) reports and +// ScanEntitlementGrantBucket(level, Index) scans. +// +// level must be in [0, DigestBucketHashBits] — 0 is the whole entitlement +// (Index always 0); past DigestBucketHashBits there is no finer +// addressable bucket, and this errors rather than silently returning an +// Index computed at a resolution the stored hash doesn't have. The read +// APIs enforce the same bound, so a bucket built here is always valid to +// pass to them. +func PrincipalDigestBucket(principalRT, principalID string, level int) (connectorstore.GrantDigestBucket, error) { + if level < 0 || level > DigestBucketHashBits { + return connectorstore.GrantDigestBucket{}, fmt.Errorf("pebble: grant-digest level %d out of range [0, %d]", level, DigestBucketHashBits) + } + if level == 0 { + return connectorstore.GrantDigestBucket{Level: 0, Index: 0}, nil + } + idx := uint32(PrincipalBucketHash(principalRT, principalID) >> (64 - level)) //nolint:gosec // level <= DigestBucketHashBits (16), so the shift leaves at most 16 bits + return connectorstore.GrantDigestBucket{Level: level, Index: idx}, nil +} + // principalBucketHash is the from-identity form of the bucket hash: // the stored digestBucketHashLen key bytes for a principal given its -// decoded identity. Encodes the segments exactly as the primary grant -// key does, then hashes — so it MUST agree with hashing the spliced -// key region (pinned by TestGrantDigestSpliceMatchesEncode). Returns a +// decoded identity — the truncation of PrincipalBucketHash that index +// keys carry. Encodes the segments exactly as the primary grant key +// does, then hashes — so it MUST agree with hashing the spliced key +// region (pinned by TestGrantDigestSpliceMatchesEncode). Returns a // fresh slice. func principalBucketHash(principalRT, principalID string) []byte { - enc := codec.AppendTupleStrings(make([]byte, 0, 64), principalRT, principalID) var full [8]byte - binary.BigEndian.PutUint64(full[:], grantPrincipalBucketHash64(enc)) + binary.BigEndian.PutUint64(full[:], PrincipalBucketHash(principalRT, principalID)) out := make([]byte, digestBucketHashLen) copy(out, full[:]) return out diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/format/v3/manifest.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/format/v3/manifest.go index 625249ae..71fbe4ec 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/format/v3/manifest.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/format/v3/manifest.go @@ -3,6 +3,7 @@ package v3 import ( "errors" "fmt" + "sort" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protodesc" @@ -33,6 +34,16 @@ var ( // The closure invariant: for every file F in the result, every file F // imports is also in the result. Reader-side verification can detect // any missing import and return ErrManifestIncompleteDescriptors. +// +// The result is sorted by file path so the set — and therefore the +// marshaled manifest that embeds it — is byte-deterministic across +// calls. proto's Deterministic marshal option only sorts map entries; +// repeated-field order is part of the message value, so emitting the +// closure in Go map-iteration order would make every save produce +// different manifest bytes (and a different manifest_xxh64) for +// identical content, defeating any use of the manifest as a content +// identity. Readers do not depend on any particular order +// (VerifyDescriptorClosure is set-based). func BuildDescriptorClosure() (*descriptorpb.FileDescriptorSet, error) { // Collect all files whose package is c1.storage.v3 OR which any // such file transitively imports. @@ -56,11 +67,20 @@ func BuildDescriptorClosure() (*descriptorpb.FileDescriptorSet, error) { return true }) + // Sort by path (the map key, so unique — no ties) rather than + // ranging the map: Go randomizes map iteration per range statement, + // which would permute the repeated field on every call. + paths := make([]string, 0, len(seen)) + for p := range seen { + paths = append(paths, p) + } + sort.Strings(paths) + set := &descriptorpb.FileDescriptorSet{ File: make([]*descriptorpb.FileDescriptorProto, 0, len(seen)), } - for _, fd := range seen { - set.File = append(set.File, protodesc.ToFileDescriptorProto(fd)) + for _, p := range paths { + set.File = append(set.File, protodesc.ToFileDescriptorProto(seen[p])) } return set, nil } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/provisioner/provisioner.go b/vendor/github.com/conductorone/baton-sdk/pkg/provisioner/provisioner.go index ca63af0d..5693e268 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/provisioner/provisioner.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/provisioner/provisioner.go @@ -9,6 +9,7 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.opentelemetry.io/otel" "go.uber.org/zap" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" @@ -126,6 +127,31 @@ func (p *Provisioner) Close(ctx context.Context) error { return nil } +// hydrateEntitlementResource returns a copy of e with Resource replaced by +// resource. The store's entitlement read (GetEntitlement, via +// V3EntitlementToV2 on the Pebble engine) returns Resource as an +// identity-only stub; callers that already fetched the full Resource +// separately (e.g. for the external-resource annotation check) must +// splice it back in before handing the entitlement to a connector's +// Grant/Revoke, or the connector never sees the resource's Profile, +// DisplayName, etc. +// +// GrantableTo is untouched: V3EntitlementToV2 also stubs it down to +// ResourceType id-only entries, and this helper does not re-hydrate those — +// a connector reading entitlement.GrantableTo display names/traits in +// Grant/Revoke still sees stubs on the Pebble engine. +func hydrateEntitlementResource(e *v2.Entitlement, resource *v2.Resource) *v2.Entitlement { + if e == nil { + return nil + } + hydrated, ok := proto.Clone(e).(*v2.Entitlement) + if !ok { + return e + } + hydrated.SetResource(resource) + return hydrated +} + func (p *Provisioner) grant(ctx context.Context) error { ctx, span := tracer.Start(ctx, "Provisioner.grant") var err error @@ -165,18 +191,9 @@ func (p *Provisioner) grant(ctx context.Context) error { return err } - resource := v2.Resource_builder{ - Id: principal.GetResource().GetId(), - DisplayName: principal.GetResource().GetDisplayName(), - Annotations: principal.GetResource().GetAnnotations(), - Description: principal.GetResource().GetDescription(), - ExternalId: principal.GetResource().GetExternalId(), //nolint:staticcheck // Deprecated. - ParentResourceId: principal.GetResource().GetParentResourceId(), - }.Build() - _, err = p.connector.Grant(ctx, v2.GrantManagerServiceGrantRequest_builder{ - Entitlement: entitlement.GetEntitlement(), - Principal: resource, + Entitlement: hydrateEntitlementResource(entitlement.GetEntitlement(), entitlementResource.GetResource()), + Principal: principal.GetResource(), }.Build()) if err != nil { return err @@ -228,20 +245,11 @@ func (p *Provisioner) revoke(ctx context.Context) error { return errors.New("cannot revoke grant on external resource") } - resource := v2.Resource_builder{ - Id: principal.GetResource().GetId(), - DisplayName: principal.GetResource().GetDisplayName(), - Annotations: principal.GetResource().GetAnnotations(), - Description: principal.GetResource().GetDescription(), - ExternalId: principal.GetResource().GetExternalId(), //nolint:staticcheck // Deprecated. - ParentResourceId: principal.GetResource().GetParentResourceId(), - }.Build() - _, err = p.connector.Revoke(ctx, v2.GrantManagerServiceRevokeRequest_builder{ Grant: v2.Grant_builder{ Id: grant.GetGrant().GetId(), - Entitlement: entitlement.GetEntitlement(), - Principal: resource, + Entitlement: hydrateEntitlementResource(entitlement.GetEntitlement(), entitlementResource.GetResource()), + Principal: principal.GetResource(), Annotations: grant.GetGrant().GetAnnotations(), }.Build(), }.Build()) diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go index a03cb764..658ecef7 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go @@ -1,3 +1,3 @@ package sdk -const Version = "v0.24.3" +const Version = "v0.24.5" diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/client.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/client.go index df9474ef..3237ebcf 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/client.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/client.go @@ -104,17 +104,53 @@ func NewClient(ctx context.Context, options ...Option) (*http.Client, error) { } type icache interface { - Get(req *http.Request) (*http.Response, error) - Set(req *http.Request, value *http.Response) error + Get(req *http.Request, opts ...CacheOption) (*http.Response, error) + Set(req *http.Request, value *http.Response, opts ...CacheOption) error Clear(ctx context.Context) error Stats(ctx context.Context) CacheStats } +type cacheKeyConfig struct { + headers []string +} + +// CacheOption configures how CreateCacheKey computes its key, beyond the +// default set of headers (Accept, Content-Type, Cookie, Range). Kept as an +// interface so future dimensions (TTL, query-param keying, etc.) can be +// added without changing CreateCacheKey's or icache's signatures again. +type CacheOption interface { + applyCache(*cacheKeyConfig) +} + +type cacheKeyHeadersOption []string + +func (o cacheKeyHeadersOption) applyCache(c *cacheKeyConfig) { + c.headers = append(c.headers, o...) +} + +// CacheKeyHeaders returns a CacheOption that folds the named headers into +// the cache key computed by CreateCacheKey (and by GoCache/DBCache's +// Get/Set), beyond the default set (Accept, Content-Type, Cookie, Range). +// The value folded in is always read from req.Header at key-computation +// time, so the key can never describe a value other than the one actually +// present on the request. Named headers must therefore be set on the +// request before it reaches the cache lookup; a header only added by a +// transport-level RoundTripper or a cookie jar after that point is not +// seen. +func CacheKeyHeaders(headers ...string) CacheOption { + return cacheKeyHeadersOption(headers) +} + // CreateCacheKey generates a cache key based on the request URL, query parameters, and headers. -func CreateCacheKey(req *http.Request) (string, error) { +func CreateCacheKey(req *http.Request, opts ...CacheOption) (string, error) { if req == nil { return "", fmt.Errorf("request is nil") } + var cfg cacheKeyConfig + for _, o := range opts { + o.applyCache(&cfg) + } + var sortedParams []string // Normalize the URL path path := strings.ToLower(req.URL.Path) @@ -130,13 +166,33 @@ func CreateCacheKey(req *http.Request) (string, error) { queryString := strings.Join(sortedParams, "&") // Include relevant headers in the cache key var headerParts []string + seenHeaders := map[string]bool{ + "Accept": true, + "Content-Type": true, + "Cookie": true, + "Range": true, + } for key, values := range req.Header { for _, value := range values { - if key == "Accept" || key == "Content-Type" || key == "Cookie" || key == "Range" { + if seenHeaders[key] { headerParts = append(headerParts, fmt.Sprintf("%s=%s", key, value)) } } } + // Opted-in headers are folded in on top of the default set above. + // seenHeaders already marks the default set, and gets marked as each + // opted-in header is processed, so a header named in cfg.headers -- by + // one CacheOption or by several -- is never folded in more than once. + for _, h := range cfg.headers { + key := http.CanonicalHeaderKey(h) + if seenHeaders[key] { + continue + } + seenHeaders[key] = true + for _, value := range req.Header[key] { + headerParts = append(headerParts, fmt.Sprintf("%s=%s", key, value)) + } + } sort.Strings(headerParts) headersString := strings.Join(headerParts, "&") diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/dbcache.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/dbcache.go index 1eeba061..860af5cf 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/dbcache.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/dbcache.go @@ -190,12 +190,12 @@ func (d *DBCache) removeDB(ctx context.Context) error { } // Get returns cached response (if exists). -func (d *DBCache) Get(req *http.Request) (*http.Response, error) { +func (d *DBCache) Get(req *http.Request, opts ...CacheOption) (*http.Response, error) { var ( isFound = false resp *http.Response ) - key, err := CreateCacheKey(req) + key, err := CreateCacheKey(req, opts...) if err != nil { return nil, err } @@ -250,8 +250,8 @@ func (d *DBCache) pick(ctx context.Context, key string) ([]byte, error) { } // Set stores and save response in the db. -func (d *DBCache) Set(req *http.Request, value *http.Response) error { - key, err := CreateCacheKey(req) +func (d *DBCache) Set(req *http.Request, value *http.Response, opts ...CacheOption) error { + key, err := CreateCacheKey(req, opts...) if err != nil { return err } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/gocache.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/gocache.go index 81b8a8e0..718e63c7 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/gocache.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/gocache.go @@ -58,13 +58,13 @@ func NewNoopCache(ctx context.Context) *NoopCache { return &NoopCache{} } -func (g *NoopCache) Get(req *http.Request) (*http.Response, error) { +func (g *NoopCache) Get(req *http.Request, opts ...CacheOption) (*http.Response, error) { // This isn't threadsafe but who cares? It's the noop cache. g.counter++ return nil, nil } -func (n *NoopCache) Set(req *http.Request, value *http.Response) error { +func (n *NoopCache) Set(req *http.Request, value *http.Response, opts ...CacheOption) error { return nil } @@ -219,12 +219,12 @@ func (g *GoCache) Stats(ctx context.Context) CacheStats { } } -func (g *GoCache) Get(req *http.Request) (*http.Response, error) { +func (g *GoCache) Get(req *http.Request, opts ...CacheOption) (*http.Response, error) { if g.rootLibrary == nil { return nil, nil } - key, err := CreateCacheKey(req) + key, err := CreateCacheKey(req, opts...) if err != nil { return nil, err } @@ -247,12 +247,12 @@ func (g *GoCache) Get(req *http.Request) (*http.Response, error) { return resp, nil } -func (g *GoCache) Set(req *http.Request, value *http.Response) error { +func (g *GoCache) Set(req *http.Request, value *http.Response, opts ...CacheOption) error { if g.rootLibrary == nil { return nil } - key, err := CreateCacheKey(req) + key, err := CreateCacheKey(req, opts...) if err != nil { return err } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/wrapper.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/wrapper.go index c9a0abe9..22806159 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/wrapper.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/wrapper.go @@ -83,6 +83,31 @@ func WithMetricsHandler(handler metrics.Handler) WrapperOption { return metricsHandlerOption{handler: handler} } +type cacheKeyHeadersWrapperOption struct { + opt CacheOption +} + +func (o cacheKeyHeadersWrapperOption) Apply(c *BaseHttpClient) { + c.cacheOptions = append(c.cacheOptions, o.opt) +} + +// WithCacheKeyHeaders returns a WrapperOption that additionally folds the +// named headers into the HTTP response cache key for every request this +// client makes, on top of the default set (Accept, Content-Type, Cookie, +// Range). Use this when requests through this client vary by a header the +// cache wouldn't otherwise key on -- e.g. a per-call Authorization token or +// a tenant/version header -- so requests that only differ in that header +// don't collide in the cache. The value folded in is always read from +// req.Header at request time, so the key can never describe a value other +// than the one actually sent. +// +// Named headers must be set on the request before it reaches Do; a header +// only added later by a transport-level RoundTripper or a cookie jar is not +// seen by the cache lookup and will not be reflected in the key. +func WithCacheKeyHeaders(headers ...string) WrapperOption { + return cacheKeyHeadersWrapperOption{opt: CacheKeyHeaders(headers...)} +} + type WrapperOption interface { Apply(*BaseHttpClient) } @@ -120,6 +145,7 @@ type ( rateLimiter uRateLimit.Limiter baseHttpCache icache metricsHandler metrics.Handler + cacheOptions []CacheOption } DoOption func(resp *WrapperResponse) error @@ -495,7 +521,7 @@ func (c *BaseHttpClient) Do(req *http.Request, options ...DoOption) (*http.Respo } if req.Method == http.MethodGet && req.Header.Get("Cache-Control") != "no-cache" { - resp, err = c.baseHttpCache.Get(req) + resp, err = c.baseHttpCache.Get(req, c.cacheOptions...) if err != nil { return nil, err } @@ -567,7 +593,7 @@ func (c *BaseHttpClient) Do(req *http.Request, options ...DoOption) (*http.Respo } if req.Method == http.MethodGet && resp.StatusCode == http.StatusOK { - cacheErr := c.baseHttpCache.Set(req, resp) + cacheErr := c.baseHttpCache.Set(req, resp, c.cacheOptions...) if cacheErr != nil { l.Warn("error setting cache", zap.String("url", req.URL.String()), zap.Error(cacheErr)) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 1dc3ac8c..67b84f95 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -272,7 +272,7 @@ github.com/cockroachdb/swiss # github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 ## explicit; go 1.19 github.com/cockroachdb/tokenbucket -# github.com/conductorone/baton-sdk v0.24.4 +# github.com/conductorone/baton-sdk v0.24.6 ## explicit; go 1.25.2 github.com/conductorone/baton-sdk/internal/connector github.com/conductorone/baton-sdk/pb/c1/c1z/v1 From 749f98fa88052bf7c1290b69e7bc701289656c28 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 21 Aug 2026 15:18:21 -0300 Subject: [PATCH 40/41] chore: leave .versions.yaml on baton-admin pin for this branch go.mod/vendor stay on v0.24.6 to match main; .versions.yaml remains untouched so check-versions block-versions-yaml-edits stays green. Co-authored-by: Cursor --- .versions.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.versions.yaml b/.versions.yaml index c37f7e1d..9024b678 100644 --- a/.versions.yaml +++ b/.versions.yaml @@ -1,4 +1,4 @@ # This file is managed by baton-admin. DO NOT EDIT!!! go-version: 1.25.2 dependencies: - baton-sdk: v0.24.6 \ No newline at end of file + baton-sdk: v0.24.4 \ No newline at end of file From 28a362fc4656a0378244daa307c9665e0fe9d074 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 21 Aug 2026 15:45:32 -0300 Subject: [PATCH 41/41] fix: fail loud when folder-search Result.Href is empty mid-pagination An empty Result.Href previously minted a continuation token that made SearchFolders re-POST a new search (page 1) forever. Refuse that token and reject resume tokens missing ResultHref. Co-authored-by: Cursor --- pkg/client/clm_client.go | 14 ++++++++++++-- pkg/client/clm_client_test.go | 22 ++++++++++++++++++++++ pkg/client/clmtest/handlers.go | 19 +++++++++++++++++-- pkg/client/clmtest/server.go | 13 +++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index e3b5ec0e..3a3426cb 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -358,9 +358,13 @@ func (c *Client) SearchFolders(ctx context.Context, options PageOptions) ([]ClmF if err != nil { return nil, "", nil, fmt.Errorf("baton-docusign: invalid CLM page token: %w", err) } - if decoded.ResultHref != "" { - return c.getClmFolderSearchResultPage(ctx, decoded.ResultHref, options) + if decoded.ResultHref == "" { + // A SearchFolders continuation token without ResultHref would fall through + // to POST a brand-new search task (page 1 again) — an unbounded loop when + // more pages remain. Fail loud instead. + return nil, "", nil, fmt.Errorf("baton-docusign: CLM folder search page token missing ResultHref") } + return c.getClmFolderSearchResultPage(ctx, decoded.ResultHref, options) } createURL, err := c.buildClmClientURL(clmCreateFolderSearchTask) @@ -387,6 +391,12 @@ func (c *Client) SearchFolders(ctx context.Context, options PageOptions) ([]ClmF if err != nil { return nil, "", anno, err } + // getClmNextToken will happily mint a token with ResultHref:"" when Href is empty. + // The next SearchFolders call would then re-POST (Requests reset to 0) and loop on + // page 1 forever — maxClmListPages never fires. Refuse to emit that token. + if nextToken != "" && task.Result.Href == "" { + return nil, "", anno, fmt.Errorf("baton-docusign: CLM folder search task %s Result has no Href; cannot continue pagination", task.Href) + } return task.Result.Items, nextToken, anno, nil } diff --git a/pkg/client/clm_client_test.go b/pkg/client/clm_client_test.go index e6c7dc89..fe866a53 100644 --- a/pkg/client/clm_client_test.go +++ b/pkg/client/clm_client_test.go @@ -2,6 +2,7 @@ package client_test import ( "context" + "strings" "testing" "time" @@ -38,6 +39,27 @@ func TestSearchFolders_Pagination(t *testing.T) { } } +// TestSearchFolders_EmptyResultHrefFailsLoud is a regression for the page-1 loop that +// happens when Result.Href is empty but more pages remain: getClmNextToken would mint a +// token with ResultHref:"", and the next SearchFolders call would re-POST a new search +// (resetting Requests) forever. SearchFolders must error instead of emitting that token. +func TestSearchFolders_EmptyResultHrefFailsLoud(t *testing.T) { + srv, c := clmtest.NewServer(t) + ctx := context.Background() + srv.SetOmitFolderSearchResultHref(true) + + folders, next, _, err := c.SearchFolders(ctx, client.PageOptions{PageSize: 2}) + if err == nil { + t.Fatalf("expected error when Result.Href is empty and more pages remain, got folders=%d next=%q", len(folders), next) + } + if next != "" { + t.Errorf("expected empty next token on failure, got %q", next) + } + if !strings.Contains(err.Error(), "Result has no Href") { + t.Errorf("expected Result-Href error, got: %v", err) + } +} + // TestSearchFolders_PollsUntilSuccess is a regression test for SearchFolders' // awaitClmFolderSearchTask branch: every live test against a real CLM tenant resolved // the task inline (Status "Success" already in the POST response), leaving the polling diff --git a/pkg/client/clmtest/handlers.go b/pkg/client/clmtest/handlers.go index 547481f8..267051fc 100644 --- a/pkg/client/clmtest/handlers.go +++ b/pkg/client/clmtest/handlers.go @@ -49,7 +49,20 @@ func (s *Server) handleCreateFolderSearchTask(w http.ResponseWriter, r *http.Req resp := client.ClmFolderSearchTaskResponse{Status: status, Href: taskHref} if status == "Success" { result := s.folderSearchResults(r) - result.Href = taskHref + "/result" + if s.omitFolderSearchResultHref { + // Leave Href empty and force a "more pages remain" shape so SearchFolders' + // empty-Href continuation guard is reachable (the create POST has no + // offset/limit query, so folderSearchResults alone would return everything). + if len(result.Items) > 1 { + result.Items = result.Items[:1] + } + result.Limit = 1 + result.Total = len(s.folderOrder) + result.Next = "more" + result.Offset = 0 + } else { + result.Href = taskHref + "/result" + } resp.Result = &result } writeJSON(w, resp) @@ -70,7 +83,9 @@ func (s *Server) handlePollFolderSearchTask(w http.ResponseWriter, r *http.Reque } result := s.folderSearchResults(r) - result.Href = taskHref + "/result" + if !s.omitFolderSearchResultHref { + result.Href = taskHref + "/result" + } writeJSON(w, client.ClmFolderSearchTaskResponse{Status: "Success", Href: taskHref, Result: &result}) } diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index e83c6181..594f1512 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -126,6 +126,10 @@ type Server struct { // "Success" on poll — see SetPendingFolderSearchPolls. Decremented on each poll. pendingFolderSearchPolls int + // omitFolderSearchResultHref, when true, leaves Result.Href empty on the next + // successful folder-search task response — see SetOmitFolderSearchResultHref. + omitFolderSearchResultHref bool + nextChangeSecurityTaskID int // incrementing counter for mock ChangeSecurityTasks task IDs // pendingChangeSecurityPolls, when > 0, makes the next PatchFolderSecurity task @@ -149,6 +153,15 @@ func (s *Server) SetPendingFolderSearchPolls(n int) { s.pendingFolderSearchPolls = n } +// SetOmitFolderSearchResultHref makes the next successful FolderSearchTasks response +// leave Result.Href empty. Used to pin SearchFolders' guard against minting a +// continuation token that would re-POST a new search (page-1 loop). +func (s *Server) SetOmitFolderSearchResultHref(omit bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.omitFolderSearchResultHref = omit +} + // SetPendingChangeSecurityPolls makes the next change-security task created by this // server require n polls of GET .../changesecuritytasks/{id} before resolving to // "success" — exercises PatchFolderSecurity's awaitClmChangeSecurityTask polling loop,