From b339b0d7a32fb68fa63e3fe79fb0ce9c2850c4e1 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 7 Aug 2026 14:01:16 -0300 Subject: [PATCH 01/54] fix: gate clm_role on CLM availability, narrow opt-in error tolerance, log skipped folder-security entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clm_role's List() never made an API call, so it had no way to detect a missing CLM subscription and synced its 5 fixed roles unconditionally — unlike every other CLM resource type. It now calls the same account- discovery check (EnsureClmReady) the other CLM builders already run internally before their real request. - isOptInFeatureUnavailableError tolerated 401/403/404/412 from any CLM call, not just the discovery call it was reasoned about — so a real per-resource CLM data call failing with the same code for an unrelated reason (token expired mid-sync, a narrower scope issue) was silently swallowed as "no CLM subscription" too. isClmUnavailableError now also requires the error to have actually originated from CLM account discovery (client.IsClmDiscoveryError), tightening the tolerance to what it was already documented to mean. - clm_folder's Grants() silently skipped folder-security entries with an unmapped AccessType (Custom, InheritFromParentFolder, an unrecognized role) with no log output at any level. Added debug-level logging at each skip point so this is traceable without being treated as an error. Co-Authored-By: Claude Sonnet 5 --- pkg/client/clm_client.go | 38 +++++++++++++++++++++++++--- pkg/client/clm_client_test.go | 21 +++++++++++++++ pkg/connector/clm_folders.go | 10 +++++++- pkg/connector/clm_groups.go | 2 +- pkg/connector/clm_members.go | 2 +- pkg/connector/clm_permission_sets.go | 2 +- pkg/connector/clm_roles.go | 26 +++++++++++++++---- pkg/connector/clm_roles_test.go | 30 +++++++++++++++++++--- pkg/connector/helper.go | 11 ++++++++ 9 files changed, 127 insertions(+), 15 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index d8ec5e9f..2aeab2b0 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -81,6 +81,7 @@ package client import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -199,7 +200,7 @@ func (c *Client) ensureClmInitialized(ctx context.Context) error { var raw map[string]json.RawMessage if _, _, err := doRequestCommon(c.wrapper, request, &raw, &ClmErrorResponse{}); err != nil { - return fmt.Errorf("baton-docusign: failed to discover the CLM API base URL: %w", err) + return &clmDiscoveryError{err: fmt.Errorf("baton-docusign: failed to discover the CLM API base URL: %w", err)} } baseURL, ok := clmExtractBaseURLField(raw) @@ -214,8 +215,8 @@ func (c *Client) ensureClmInitialized(ctx context.Context) error { // candidate fields), so isOptInFeatureUnavailableError needs a recognizable // code to tolerate this specific failure the same way it tolerates 401/403 — // see that function's doc in helper.go. - return status.Errorf(codes.FailedPrecondition, "baton-docusign: CLM account discovery response at %s did not contain a recognized "+ - "base-URL field (checked %v); response contained these fields instead: %v", discoveryURL, clmBaseURLCandidateFields, keys) + return &clmDiscoveryError{err: status.Errorf(codes.FailedPrecondition, "baton-docusign: CLM account discovery response at %s did not contain a recognized "+ + "base-URL field (checked %v); response contained these fields instead: %v", discoveryURL, clmBaseURLCandidateFields, keys)} } c.clmBaseURI = baseURL @@ -223,6 +224,37 @@ func (c *Client) ensureClmInitialized(ctx context.Context) error { return nil } +// clmDiscoveryError marks an error as originating specifically from CLM account +// discovery (ensureClmInitialized above), not from a later per-resource CLM data call +// (SearchFolders, ListGroups, ...). isOptInFeatureUnavailableError's tolerated codes +// (401/403/404/412-equivalent) aren't unique to "no CLM subscription" — a real +// per-resource call can fail with the same code once discovery has already succeeded +// and been cached, for an unrelated reason (a token that expired mid-sync, a narrower +// scope problem on just that endpoint). Without this marker, that later failure would +// be silently treated as "no CLM" too. See IsClmDiscoveryError. +type clmDiscoveryError struct { + err error +} + +func (e *clmDiscoveryError) Error() string { return e.err.Error() } +func (e *clmDiscoveryError) Unwrap() error { return e.err } + +// IsClmDiscoveryError reports whether err (or a wrapped error within it) originated +// from ensureClmInitialized's CLM account discovery call — see clmDiscoveryError. +func IsClmDiscoveryError(err error) bool { + var discoveryErr *clmDiscoveryError + return errors.As(err, &discoveryErr) +} + +// EnsureClmReady exposes the CLM-readiness check every other CLM client method runs +// internally before its real request, for callers with no CLM endpoint of their own +// (clm_role — see pkg/connector/clm_roles.go) that still need to detect CLM +// availability. Memoized after the first successful call, same as every other CLM +// method — see ensureClmInitialized. +func (c *Client) EnsureClmReady(ctx context.Context) error { + return c.ensureClmReady(ctx) +} + // clmExtractBaseURLField scans a CLM account discovery response for the first // recognized base-URL field, in clmBaseURLCandidateFields priority order. Split out // from ensureClmInitialized so this defensive-fallback logic can be unit tested diff --git a/pkg/client/clm_client_test.go b/pkg/client/clm_client_test.go index 5d773de0..0512a966 100644 --- a/pkg/client/clm_client_test.go +++ b/pkg/client/clm_client_test.go @@ -8,6 +8,8 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" "github.com/conductorone/baton-docusign/pkg/client/clmtest" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func TestSearchFolders_Pagination(t *testing.T) { @@ -385,3 +387,22 @@ func TestListPermissionSets_Pagination(t *testing.T) { t.Fatalf("expected 5 permission sets across all pages, got %d", len(all)) } } + +func TestIsClmDiscoveryError(t *testing.T) { + s, _ := clmtest.NewServer(t) + ctx := context.Background() + + badClient := s.NewClientWithToken("wrong-token") + err := badClient.EnsureClmReady(ctx) + if err == nil { + t.Fatal("expected EnsureClmReady to fail for a bad token") + } + if !client.IsClmDiscoveryError(err) { + t.Errorf("expected a real CLM discovery failure to be detected as a discovery error, got: %v", err) + } + + plain := status.Error(codes.Unauthenticated, "not from discovery — a real per-resource CLM data call failure instead") + if client.IsClmDiscoveryError(plain) { + t.Error("expected a plain gRPC-coded error not produced by discovery to not be treated as a CLM discovery error") + } +} diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 1e39e10a..e29e2917 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -74,7 +74,7 @@ func (f *clmFolderBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + if attr.PageToken.Token == "" && isClmUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } @@ -141,6 +141,8 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Groups { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder group-security entry with an unmapped AccessType", + zap.String("folder_id", folderResource.Id.Resource), zap.String("group_href", entry.Href), zap.String("access_type", entry.AccessType)) continue } principalID := &v2.ResourceId{ResourceType: clmGroupResourceType.Id, Resource: clmIDFromHref(entry.Href)} @@ -156,12 +158,16 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Roles { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry with an unmapped AccessType", + zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item), zap.String("access_type", entry.AccessType)) continue } if !clmIsKnownRole(entry.Item) { // clm_role is a fixed, hardcoded 5-role list (clmRoleBuilder.List) — a role // name outside that set has no synced principal to grant against. Skip // rather than emit a grant to a dangling/unsynced resource. + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry for an unrecognized role", + zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item)) continue } principalID := &v2.ResourceId{ResourceType: clmRoleResourceType.Id, Resource: entry.Item} @@ -171,6 +177,8 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Users { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder user-security entry with an unmapped AccessType", + zap.String("folder_id", folderResource.Id.Resource), zap.String("member_href", entry.Href), zap.String("access_type", entry.AccessType)) continue } principalID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: clmIDFromHref(entry.Href)} diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 318e0302..13e2f229 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -53,7 +53,7 @@ func (g *clmGroupBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.Sy PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + if attr.PageToken.Token == "" && isClmUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 74352724..197f59f3 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -35,7 +35,7 @@ func (b *clmMemberBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + if attr.PageToken.Token == "" && isClmUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_permission_sets.go b/pkg/connector/clm_permission_sets.go index 04b81b66..f6fabfe9 100644 --- a/pkg/connector/clm_permission_sets.go +++ b/pkg/connector/clm_permission_sets.go @@ -42,7 +42,7 @@ func (b *clmPermissionSetBuilder) List(ctx context.Context, _ *v2.ResourceId, at PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + if attr.PageToken.Token == "" && isClmUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index 83d7e560..8e1fc489 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -6,10 +6,16 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" ) -// clmRoleBuilder syncs the 5 fixed CLM account-level roles (client.ClmRoles). Not -// backed by an API call — see resource_types.go for why this resource type exists. +// clmRoleBuilder syncs the 5 fixed CLM account-level roles (client.ClmRoles). The role +// set itself isn't backed by an API call — see resource_types.go for why this resource +// type exists — but List() still checks CLM availability via EnsureClmReady before +// emitting it, the same discovery check every other CLM builder's real API call runs +// internally; otherwise these 5 roles would sync unconditionally even on an account +// with no CLM subscription, unlike every other CLM resource type. type clmRoleBuilder struct { resourceType *v2.ResourceType client *client.Client @@ -19,9 +25,19 @@ func (b *clmRoleBuilder) ResourceType(_ context.Context) *v2.ResourceType { return clmRoleResourceType } -// List returns the fixed set of CLM roles. No pagination needed — the set is small -// and hardcoded, not fetched from the API. -func (b *clmRoleBuilder) List(_ context.Context, _ *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { +// List returns the fixed set of CLM roles, gated on CLM being available for this +// account. No pagination needed — the set is small and hardcoded, not fetched from the +// API — so the availability check always runs (there's no first-page-only gate to +// apply, unlike the paginated CLM builders). +func (b *clmRoleBuilder) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { + if err := b.client.EnsureClmReady(ctx); err != nil { + if isClmUnavailableError(err) { + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err)) + return nil, &rs.SyncOpResults{}, nil + } + return nil, nil, err + } + var resources []*v2.Resource for _, role := range client.ClmRoles { roleResource, err := rs.NewRoleResource( diff --git a/pkg/connector/clm_roles_test.go b/pkg/connector/clm_roles_test.go index 5a9fce24..05753920 100644 --- a/pkg/connector/clm_roles_test.go +++ b/pkg/connector/clm_roles_test.go @@ -5,13 +5,16 @@ import ( "testing" "github.com/conductorone/baton-docusign/pkg/client" + "github.com/conductorone/baton-docusign/pkg/client/clmtest" rs "github.com/conductorone/baton-sdk/pkg/types/resource" ) func TestClmRoleBuilder_List(t *testing.T) { - // Not backed by an API call — the mock server isn't even needed here, unlike every - // other CLM builder's List test. - b := newClmRoleBuilder(nil) + // The role set itself isn't backed by an API call, but List() now checks CLM + // availability via EnsureClmReady first (see clm_roles.go), so it needs a working + // mock client to reach the "CLM is available" branch. + _, c := clmtest.NewServer(t) + b := newClmRoleBuilder(c) ctx := context.Background() resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{}) @@ -31,6 +34,27 @@ func TestClmRoleBuilder_List(t *testing.T) { } } +func TestClmRoleBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { + // See clm_members_test.go's identical test for the full rationale. Before the 2a + // fix, clm_roles.go's List() made no API call at all, so this case couldn't happen + // — the 5 fixed roles synced unconditionally even without CLM access. + s, _ := clmtest.NewServer(t) + badClient := s.NewClientWithToken("wrong-token") + b := newClmRoleBuilder(badClient) + ctx := context.Background() + + resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("expected List to tolerate an unavailable CLM account and skip gracefully, got error: %v", err) + } + if len(resources) != 0 { + t.Errorf("expected zero resources when CLM is unavailable, got %d", len(resources)) + } + if res == nil { + t.Errorf("expected a non-nil SyncOpResults, got %+v", res) + } +} + func TestClmRoleBuilder_EntitlementsAndGrants_AreNoop(t *testing.T) { b := newClmRoleBuilder(nil) ctx := context.Background() diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 1795e533..8518f8a8 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -84,6 +84,17 @@ func isOptInFeatureUnavailableError(err error) bool { } } +// isClmUnavailableError is isOptInFeatureUnavailableError's CLM-specific counterpart, +// used by all 5 CLM builders' List() methods (not signing_group, which has no separate +// discovery step to distinguish from its one real call). isOptInFeatureUnavailableError +// alone isn't precise enough here: its codes can also come from a real per-resource CLM +// data call (SearchFolders, ListGroups, ...) failing for an unrelated reason once +// discovery has already succeeded — see client.IsClmDiscoveryError's doc. Requiring +// both narrows the tolerance to what the account-discovery call itself can produce. +func isClmUnavailableError(err error) bool { + return client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) +} + // clmIDFromHref extracts the trailing path segment from a CLM object's Href — see // 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 From 1863069be6b59e2781aa85b6f8bb789fa4edc7e8 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 7 Aug 2026 15:13:52 -0300 Subject: [PATCH 02/54] fix: revert opt-in-error narrowing, log instead of gate on error source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review feedback: requiring isClmUnavailableError (i.e. the tolerated error must have come from CLM account discovery, not any later per-resource CLM data call) was a real regression risk, not a safe tightening. A genuine "no CLM subscription" signal can legitimately surface on the data call itself, not just discovery, depending on where DocuSign enforces the check for a given account shape — e.g. an account whose legacy SpringCM discovery succeeds but whose token lacks CLM scopes would get PermissionDenied only on the real API call. Narrowing the gate would turn that previously-graceful skip into a hard sync failure, which is a worse regression than the observability gap it was meant to fix, and this project has no live CLM tenant to confirm which shape is actually correct. Reverted to the original tolerance (isOptInFeatureUnavailableError, unchanged) at all 5 call sites. What's new: clmSkipLogLevel logs at Warn instead of Info when the tolerated error did NOT come from CLM discovery (client.IsClmDiscoveryError) — same behavior, louder signal for the one case that doesn't have discovery's one-directional "this really is a missing subscription" guarantee. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_folders.go | 4 +-- pkg/connector/clm_groups.go | 5 ++-- pkg/connector/clm_members.go | 5 ++-- pkg/connector/clm_permission_sets.go | 5 ++-- pkg/connector/clm_roles.go | 5 ++-- pkg/connector/helper.go | 31 ++++++++++++++++------- pkg/connector/helper_test.go | 37 ++++++++++++++++++++++++++++ 7 files changed, 69 insertions(+), 23 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index e29e2917..bcc7b17e 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -74,8 +74,8 @@ func (f *clmFolderBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isClmUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err)) + if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 13e2f229..b13bce05 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -10,7 +10,6 @@ import ( "github.com/conductorone/baton-sdk/pkg/connectorbuilder" "github.com/conductorone/baton-sdk/pkg/types/grant" 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" @@ -53,8 +52,8 @@ func (g *clmGroupBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.Sy PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isClmUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err)) + if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 197f59f3..0a867b43 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -6,7 +6,6 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" ) @@ -35,8 +34,8 @@ func (b *clmMemberBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isClmUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err)) + if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_permission_sets.go b/pkg/connector/clm_permission_sets.go index f6fabfe9..477fc124 100644 --- a/pkg/connector/clm_permission_sets.go +++ b/pkg/connector/clm_permission_sets.go @@ -7,7 +7,6 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/types/entitlement" rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" ) @@ -42,8 +41,8 @@ func (b *clmPermissionSetBuilder) List(ctx context.Context, _ *v2.ResourceId, at PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isClmUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err)) + if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index 8e1fc489..ce580824 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -6,7 +6,6 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" ) @@ -31,8 +30,8 @@ func (b *clmRoleBuilder) ResourceType(_ context.Context) *v2.ResourceType { // apply, unlike the paginated CLM builders). func (b *clmRoleBuilder) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { if err := b.client.EnsureClmReady(ctx); err != nil { - if isClmUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err)) + if isOptInFeatureUnavailableError(err) { + clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 8518f8a8..a2a87ac3 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -84,15 +84,28 @@ func isOptInFeatureUnavailableError(err error) bool { } } -// isClmUnavailableError is isOptInFeatureUnavailableError's CLM-specific counterpart, -// used by all 5 CLM builders' List() methods (not signing_group, which has no separate -// discovery step to distinguish from its one real call). isOptInFeatureUnavailableError -// alone isn't precise enough here: its codes can also come from a real per-resource CLM -// data call (SearchFolders, ListGroups, ...) failing for an unrelated reason once -// discovery has already succeeded — see client.IsClmDiscoveryError's doc. Requiring -// both narrows the tolerance to what the account-discovery call itself can produce. -func isClmUnavailableError(err error) bool { - return client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) +// clmSkipLogLevel picks Info or Warn for the "CLM is not available, skipping sync" log +// line every CLM builder's List() emits when isOptInFeatureUnavailableError tolerates +// an error. Deliberately does NOT gate whether the sync skips gracefully — only two +// tries at that were made and both were wrong: the original behavior tolerates any of +// isOptInFeatureUnavailableError's codes regardless of source, and an earlier version +// of this function required client.IsClmDiscoveryError (i.e. only a failure from +// ensureClmInitialized's account-discovery call, never a later per-resource CLM data +// call) — but a genuine "no CLM subscription" signal can legitimately come from either +// place depending on where DocuSign enforces the check for a given account, and this +// project has no live CLM tenant to confirm which. Narrowing the gate risked turning a +// previously-graceful skip into a hard sync failure for a real account shape, which is +// a worse regression than the observability gap it would have fixed. So: same +// tolerance as before, but logged louder when the source isn't discovery, since that +// case doesn't have the same one-directional guarantee a discovery failure does (it +// could also be a narrower problem — a token that expired mid-sync, a scope issue on +// just this endpoint — being silently treated as "nothing to sync" rather than a real +// failure) and is worth a human noticing. +func clmSkipLogLevel(ctx context.Context, err error) func(string, ...zap.Field) { + if client.IsClmDiscoveryError(err) { + return ctxzap.Extract(ctx).Info + } + return ctxzap.Extract(ctx).Warn } // clmIDFromHref extracts the trailing path segment from a CLM object's Href — see diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index b6ac2361..ce4ecfdf 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -3,10 +3,13 @@ package connector import ( "context" "errors" + "reflect" "testing" + "github.com/conductorone/baton-docusign/pkg/client/clmtest" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -88,6 +91,40 @@ func TestClmHrefWithID(t *testing.T) { } } +// TestClmSkipLogLevel confirms clmSkipLogLevel's only real decision: log louder (Warn, +// not Info) when the tolerated error didn't actually come from CLM account discovery. +// It does NOT gate whether the sync skips gracefully — both a discovery error and a +// plain one are equally tolerated by isOptInFeatureUnavailableError, unchanged. See +// clmSkipLogLevel's doc for why gating on the source, not just the log level, was tried +// and reverted. +func TestClmSkipLogLevel(t *testing.T) { + ctx := context.Background() + + s, _ := clmtest.NewServer(t) + discoveryErr := s.NewClientWithToken("wrong-token").EnsureClmReady(ctx) + if discoveryErr == nil { + t.Fatal("test setup: expected EnsureClmReady to fail for a bad token") + } + // Stands in for a real per-resource CLM data call (SearchFolders, ListGroups, ...) + // failing for an unrelated reason (an expired token mid-sync, a narrower scope + // problem) after discovery already succeeded — isOptInFeatureUnavailableError + // tolerates this identically to a discovery error, but it doesn't have the same + // one-directional "this means no CLM subscription" guarantee. + plainErr := status.Error(codes.Unauthenticated, "token expired mid-sync") + + discoveryLevel := reflect.ValueOf(clmSkipLogLevel(ctx, discoveryErr)).Pointer() + plainLevel := reflect.ValueOf(clmSkipLogLevel(ctx, plainErr)).Pointer() + infoLevel := reflect.ValueOf(ctxzap.Extract(ctx).Info).Pointer() + warnLevel := reflect.ValueOf(ctxzap.Extract(ctx).Warn).Pointer() + + if discoveryLevel != infoLevel { + t.Error("expected a discovery-sourced error to log at Info") + } + if plainLevel != warnLevel { + t.Error("expected a non-discovery error to log at Warn") + } +} + func TestClmPreferredHref(t *testing.T) { ctx := context.Background() fallbackCalled := false From b002b0b1bf8b42edb5eb168f5b6a55c65e97720e Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 12:46:21 -0300 Subject: [PATCH 03/54] fix: address current bot review findings on PR #64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestClmSkipLogLevel now asserts the actually-emitted log level via zaptest/observer instead of comparing method-value pointers with reflect — Pointer() on a func is documented as not guaranteed to uniquely identify a function, so the old assertion tested identity that happened to work, not the observable behavior the test cares about. - Trimmed clmSkipLogLevel's doc comment: it was narrating this PR's own review history ("only two tries at that were made and both were wrong", referencing a since-removed IsClmDiscoveryError gate) instead of standing on its own — a future reader can't check a claim about code that no longer exists, and the paragraph would only rot further. Other bot comments on this PR (helper.go's "requiring IsClmDiscoveryError removes graceful degradation" x2, a double-blank-line formatting note) are stale: they describe the isClmUnavailableError gating mechanism this PR already reverted away from in a prior commit, and the blank line was already fixed there too. GitHub re-anchored their commit references to HEAD after the stack rebase, but their content predates the revert. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/helper.go | 28 ++- pkg/connector/helper_test.go | 26 ++- .../zap/zaptest/observer/logged_entry.go | 39 ++++ .../zap/zaptest/observer/observer.go | 203 ++++++++++++++++++ vendor/modules.txt | 1 + 5 files changed, 271 insertions(+), 26 deletions(-) create mode 100644 vendor/go.uber.org/zap/zaptest/observer/logged_entry.go create mode 100644 vendor/go.uber.org/zap/zaptest/observer/observer.go diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index a2a87ac3..cdd6e9d4 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -85,22 +85,18 @@ func isOptInFeatureUnavailableError(err error) bool { } // clmSkipLogLevel picks Info or Warn for the "CLM is not available, skipping sync" log -// line every CLM builder's List() emits when isOptInFeatureUnavailableError tolerates -// an error. Deliberately does NOT gate whether the sync skips gracefully — only two -// tries at that were made and both were wrong: the original behavior tolerates any of -// isOptInFeatureUnavailableError's codes regardless of source, and an earlier version -// of this function required client.IsClmDiscoveryError (i.e. only a failure from -// ensureClmInitialized's account-discovery call, never a later per-resource CLM data -// call) — but a genuine "no CLM subscription" signal can legitimately come from either -// place depending on where DocuSign enforces the check for a given account, and this -// project has no live CLM tenant to confirm which. Narrowing the gate risked turning a -// previously-graceful skip into a hard sync failure for a real account shape, which is -// a worse regression than the observability gap it would have fixed. So: same -// tolerance as before, but logged louder when the source isn't discovery, since that -// case doesn't have the same one-directional guarantee a discovery failure does (it -// could also be a narrower problem — a token that expired mid-sync, a scope issue on -// just this endpoint — being silently treated as "nothing to sync" rather than a real -// failure) and is worth a human noticing. +// line every CLM builder's List() emits when isOptInFeatureUnavailableError tolerates an +// error. Deliberately does NOT gate whether the sync skips gracefully — a genuine "no +// CLM subscription" signal can legitimately come from either CLM account discovery or a +// later per-resource CLM data call, depending on where DocuSign enforces the check for a +// given account, and this project has no live CLM tenant to confirm which; gating on the +// source would risk turning a real account's previously-graceful skip into a hard sync +// failure. So: the same tolerance either way, but logged louder when the source isn't +// discovery, since that case doesn't have the same one-directional "this really is a +// missing subscription" guarantee a discovery failure does — it could also be a +// narrower problem (a token that expired mid-sync, a scope issue on just this endpoint) +// being silently treated as "nothing to sync" rather than a real failure, which is worth +// a human noticing. func clmSkipLogLevel(ctx context.Context, err error) func(string, ...zap.Field) { if client.IsClmDiscoveryError(err) { return ctxzap.Extract(ctx).Info diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index ce4ecfdf..eba679dd 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -3,13 +3,15 @@ package connector import ( "context" "errors" - "reflect" "testing" "github.com/conductorone/baton-docusign/pkg/client/clmtest" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -98,13 +100,15 @@ func TestClmHrefWithID(t *testing.T) { // clmSkipLogLevel's doc for why gating on the source, not just the log level, was tried // and reverted. func TestClmSkipLogLevel(t *testing.T) { - ctx := context.Background() + core, logs := observer.New(zapcore.DebugLevel) + ctx := ctxzap.ToContext(context.Background(), zap.New(core)) s, _ := clmtest.NewServer(t) discoveryErr := s.NewClientWithToken("wrong-token").EnsureClmReady(ctx) if discoveryErr == nil { t.Fatal("test setup: expected EnsureClmReady to fail for a bad token") } + logs.TakeAll() // discard the underlying HTTP client's own log line from that call above // Stands in for a real per-resource CLM data call (SearchFolders, ListGroups, ...) // failing for an unrelated reason (an expired token mid-sync, a narrower scope // problem) after discovery already succeeded — isOptInFeatureUnavailableError @@ -112,16 +116,18 @@ func TestClmSkipLogLevel(t *testing.T) { // one-directional "this means no CLM subscription" guarantee. plainErr := status.Error(codes.Unauthenticated, "token expired mid-sync") - discoveryLevel := reflect.ValueOf(clmSkipLogLevel(ctx, discoveryErr)).Pointer() - plainLevel := reflect.ValueOf(clmSkipLogLevel(ctx, plainErr)).Pointer() - infoLevel := reflect.ValueOf(ctxzap.Extract(ctx).Info).Pointer() - warnLevel := reflect.ValueOf(ctxzap.Extract(ctx).Warn).Pointer() + clmSkipLogLevel(ctx, discoveryErr)("discovery skip") + clmSkipLogLevel(ctx, plainErr)("plain skip") - if discoveryLevel != infoLevel { - t.Error("expected a discovery-sourced error to log at Info") + entries := logs.All() + if len(entries) != 2 { + t.Fatalf("expected 2 log entries, got %d: %+v", len(entries), entries) + } + if entries[0].Level != zapcore.InfoLevel { + t.Errorf("expected a discovery-sourced error to log at Info, got %v", entries[0].Level) } - if plainLevel != warnLevel { - t.Error("expected a non-discovery error to log at Warn") + if entries[1].Level != zapcore.WarnLevel { + t.Errorf("expected a non-discovery error to log at Warn, got %v", entries[1].Level) } } diff --git a/vendor/go.uber.org/zap/zaptest/observer/logged_entry.go b/vendor/go.uber.org/zap/zaptest/observer/logged_entry.go new file mode 100644 index 00000000..ef89e25c --- /dev/null +++ b/vendor/go.uber.org/zap/zaptest/observer/logged_entry.go @@ -0,0 +1,39 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package observer + +import "go.uber.org/zap/zapcore" + +// A LoggedEntry is an encoding-agnostic representation of a log message. +// Field availability is context dependent. +type LoggedEntry struct { + zapcore.Entry + Context []zapcore.Field +} + +// ContextMap returns a map for all fields in Context. +func (e LoggedEntry) ContextMap() map[string]interface{} { + encoder := zapcore.NewMapObjectEncoder() + for _, f := range e.Context { + f.AddTo(encoder) + } + return encoder.Fields +} diff --git a/vendor/go.uber.org/zap/zaptest/observer/observer.go b/vendor/go.uber.org/zap/zaptest/observer/observer.go new file mode 100644 index 00000000..4f7ce0ec --- /dev/null +++ b/vendor/go.uber.org/zap/zaptest/observer/observer.go @@ -0,0 +1,203 @@ +// Copyright (c) 2016-2022 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Package observer provides a zapcore.Core that keeps an in-memory, +// encoding-agnostic representation of log entries. It's useful for +// applications that want to unit test their log output without tying their +// tests to a particular output encoding. +package observer // import "go.uber.org/zap/zaptest/observer" + +import ( + "strings" + "sync" + "time" + + "go.uber.org/zap/internal" + "go.uber.org/zap/zapcore" +) + +// ObservedLogs is a concurrency-safe, ordered collection of observed logs. +type ObservedLogs struct { + mu sync.RWMutex + logs []LoggedEntry +} + +// Len returns the number of items in the collection. +func (o *ObservedLogs) Len() int { + o.mu.RLock() + n := len(o.logs) + o.mu.RUnlock() + return n +} + +// All returns a copy of all the observed logs. +func (o *ObservedLogs) All() []LoggedEntry { + o.mu.RLock() + ret := make([]LoggedEntry, len(o.logs)) + copy(ret, o.logs) + o.mu.RUnlock() + return ret +} + +// TakeAll returns a copy of all the observed logs, and truncates the observed +// slice. +func (o *ObservedLogs) TakeAll() []LoggedEntry { + o.mu.Lock() + ret := o.logs + o.logs = nil + o.mu.Unlock() + return ret +} + +// AllUntimed returns a copy of all the observed logs, but overwrites the +// observed timestamps with time.Time's zero value. This is useful when making +// assertions in tests. +func (o *ObservedLogs) AllUntimed() []LoggedEntry { + ret := o.All() + for i := range ret { + ret[i].Time = time.Time{} + } + return ret +} + +// FilterLevelExact filters entries to those logged at exactly the given level. +func (o *ObservedLogs) FilterLevelExact(level zapcore.Level) *ObservedLogs { + return o.Filter(func(e LoggedEntry) bool { + return e.Level == level + }) +} + +// FilterMessage filters entries to those that have the specified message. +func (o *ObservedLogs) FilterMessage(msg string) *ObservedLogs { + return o.Filter(func(e LoggedEntry) bool { + return e.Message == msg + }) +} + +// FilterLoggerName filters entries to those logged through logger with the specified logger name. +func (o *ObservedLogs) FilterLoggerName(name string) *ObservedLogs { + return o.Filter(func(e LoggedEntry) bool { + return e.LoggerName == name + }) +} + +// FilterMessageSnippet filters entries to those that have a message containing the specified snippet. +func (o *ObservedLogs) FilterMessageSnippet(snippet string) *ObservedLogs { + return o.Filter(func(e LoggedEntry) bool { + return strings.Contains(e.Message, snippet) + }) +} + +// FilterField filters entries to those that have the specified field. +func (o *ObservedLogs) FilterField(field zapcore.Field) *ObservedLogs { + return o.Filter(func(e LoggedEntry) bool { + for _, ctxField := range e.Context { + if ctxField.Equals(field) { + return true + } + } + return false + }) +} + +// FilterFieldKey filters entries to those that have the specified key. +func (o *ObservedLogs) FilterFieldKey(key string) *ObservedLogs { + return o.Filter(func(e LoggedEntry) bool { + for _, ctxField := range e.Context { + if ctxField.Key == key { + return true + } + } + return false + }) +} + +// Filter returns a copy of this ObservedLogs containing only those entries +// for which the provided function returns true. +func (o *ObservedLogs) Filter(keep func(LoggedEntry) bool) *ObservedLogs { + o.mu.RLock() + defer o.mu.RUnlock() + + var filtered []LoggedEntry + for _, entry := range o.logs { + if keep(entry) { + filtered = append(filtered, entry) + } + } + return &ObservedLogs{logs: filtered} +} + +func (o *ObservedLogs) add(log LoggedEntry) { + o.mu.Lock() + o.logs = append(o.logs, log) + o.mu.Unlock() +} + +// New creates a new Core that buffers logs in memory (without any encoding). +// It's particularly useful in tests. +func New(enab zapcore.LevelEnabler) (zapcore.Core, *ObservedLogs) { + ol := &ObservedLogs{} + return &contextObserver{ + LevelEnabler: enab, + logs: ol, + }, ol +} + +type contextObserver struct { + zapcore.LevelEnabler + logs *ObservedLogs + context []zapcore.Field +} + +var ( + _ zapcore.Core = (*contextObserver)(nil) + _ internal.LeveledEnabler = (*contextObserver)(nil) +) + +func (co *contextObserver) Level() zapcore.Level { + return zapcore.LevelOf(co.LevelEnabler) +} + +func (co *contextObserver) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if co.Enabled(ent.Level) { + return ce.AddCore(ent, co) + } + return ce +} + +func (co *contextObserver) With(fields []zapcore.Field) zapcore.Core { + return &contextObserver{ + LevelEnabler: co.LevelEnabler, + logs: co.logs, + context: append(co.context[:len(co.context):len(co.context)], fields...), + } +} + +func (co *contextObserver) Write(ent zapcore.Entry, fields []zapcore.Field) error { + all := make([]zapcore.Field, 0, len(fields)+len(co.context)) + all = append(all, co.context...) + all = append(all, fields...) + co.logs.add(LoggedEntry{ent, all}) + return nil +} + +func (co *contextObserver) Sync() error { + return nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 748f2d00..1fa4078f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -739,6 +739,7 @@ go.uber.org/zap/internal/exit go.uber.org/zap/internal/pool go.uber.org/zap/internal/stacktrace go.uber.org/zap/zapcore +go.uber.org/zap/zaptest/observer # golang.org/x/crypto v0.54.0 ## explicit; go 1.25.0 golang.org/x/crypto/blowfish From 091f923f84c51d5adab69f80dff34dd4e8d070df Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 15:50:53 -0300 Subject: [PATCH 04/54] fix: suppress expected-state Debug logs diluting the AccessType skip signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NoAccess (left in place by this connector's own Revoke), Custom, and InheritFromParentFolder are all expected, documented non-grantable states that folder-security entries legitimately carry on every sync — logging them at Debug on every single sync of every previously-revoked/custom/ inherited entry drowns out the case the log exists to catch: a genuinely unrecognized AccessType. --- pkg/connector/clm_folders.go | 36 +++++++++++++++++++++++++------ pkg/connector/clm_folders_test.go | 19 ++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index bcc7b17e..4cb180a8 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -141,8 +141,10 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Groups { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder group-security entry with an unmapped AccessType", - zap.String("folder_id", folderResource.Id.Resource), zap.String("group_href", entry.Href), zap.String("access_type", entry.AccessType)) + if !clmIsBenignUnmappedAccessType(entry.AccessType) { + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder group-security entry with an unmapped AccessType", + zap.String("folder_id", folderResource.Id.Resource), zap.String("group_href", entry.Href), zap.String("access_type", entry.AccessType)) + } continue } principalID := &v2.ResourceId{ResourceType: clmGroupResourceType.Id, Resource: clmIDFromHref(entry.Href)} @@ -158,8 +160,10 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Roles { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry with an unmapped AccessType", - zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item), zap.String("access_type", entry.AccessType)) + if !clmIsBenignUnmappedAccessType(entry.AccessType) { + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry with an unmapped AccessType", + zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item), zap.String("access_type", entry.AccessType)) + } continue } if !clmIsKnownRole(entry.Item) { @@ -177,8 +181,10 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Users { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder user-security entry with an unmapped AccessType", - zap.String("folder_id", folderResource.Id.Resource), zap.String("member_href", entry.Href), zap.String("access_type", entry.AccessType)) + if !clmIsBenignUnmappedAccessType(entry.AccessType) { + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder user-security entry with an unmapped AccessType", + zap.String("folder_id", folderResource.Id.Resource), zap.String("member_href", entry.Href), zap.String("access_type", entry.AccessType)) + } continue } principalID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: clmIDFromHref(entry.Href)} @@ -441,6 +447,24 @@ func clmSlugForAccessType(accessType string) (string, bool) { return "", false } +// clmIsBenignUnmappedAccessType reports whether accessType is one of the two documented +// non-grantable values every folder-security entry can legitimately carry — NoAccess +// (this connector's own Revoke leaves entries in place at this value, so it appears on +// every subsequent sync of a revoked entry) and Custom/InheritFromParentFolder (an +// arbitrary flag combination or an absence-of-override marker, neither round-trippable +// to a single tier — see clmFolderEntitlement's doc). Grants() skips all three the same +// way, but only logs the ones NOT in this set, so an entry with a genuinely unrecognized +// AccessType (a real anomaly) doesn't get lost in three expected values large accounts +// can produce on every single sync. +func clmIsBenignUnmappedAccessType(accessType string) bool { + switch accessType { + case client.ClmAccessTypeNoAccess, client.ClmAccessTypeCustom, client.ClmAccessTypeInherit: + return true + default: + return false + } +} + // clmIsKnownRole reports whether name is one of the 5 fixed CLM account-level roles // (client.ClmRoles) — the same fixed set clmRoleBuilder.List syncs as clm_role // resources. Used to reject a folder-security Roles entry referencing a role outside diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 084196ce..27e68326 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -207,6 +207,25 @@ func TestClmFolderBuilder_Grants_SkipsUnknownRoleName(t *testing.T) { } } +func TestClmIsBenignUnmappedAccessType(t *testing.T) { + tests := []struct { + accessType string + want bool + }{ + {client.ClmAccessTypeNoAccess, true}, + {client.ClmAccessTypeCustom, true}, + {client.ClmAccessTypeInherit, true}, + {client.ClmAccessTypeView, false}, + {"SomethingUnrecognized", false}, + {"", false}, + } + for _, tt := range tests { + if got := clmIsBenignUnmappedAccessType(tt.accessType); got != tt.want { + t.Errorf("clmIsBenignUnmappedAccessType(%q) = %v, want %v", tt.accessType, got, tt.want) + } + } +} + func TestClmFolderBuilder_GrantAndRevoke_Idempotent(t *testing.T) { srv, c := clmtest.NewServer(t) b := newClmFolderBuilder(c) From 6b4a8ed8b52a0004006b42b043ef9c2bde9ad35a Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 16:21:21 -0300 Subject: [PATCH 05/54] fix: narrow clm_role's CLM-availability tolerance to discovery errors only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnsureClmReady is the only CLM call clm_role's List() makes, so an error here that isn't from CLM account discovery itself can only be eSignature's own ensureInitialized failing (a broken/expired token) — a problem that already breaks every other resource type too and should fail the sync loudly rather than be silently mistaken for a plain non-CLM account. Confirmed against ductone/c1's own connector-error classification (isNonRetryableCode in pkg/temporal/activity/connector-actions) that a transient blip causing this class of failure isn't the expected case: tokens are refreshed once per sync activity and reused, so these codes are meant to be stable for a sync's lifetime. The residual, narrower race this doesn't close (two CLM builders' concurrent discovery calls disagreeing within one sync) is documented in the new comment as an accepted risk rather than solved with a connector-side retry, which would fight that platform convention. --- pkg/connector/clm_roles.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index ce580824..2316e931 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -30,7 +30,25 @@ func (b *clmRoleBuilder) ResourceType(_ context.Context) *v2.ResourceType { // apply, unlike the paginated CLM builders). func (b *clmRoleBuilder) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { if err := b.client.EnsureClmReady(ctx); err != nil { - if isOptInFeatureUnavailableError(err) { + // Narrower than every other CLM builder's tolerance (isOptInFeatureUnavailableError): + // EnsureClmReady is the ONLY CLM call clm_role's List() ever makes, so an error here + // that ISN'T from CLM account discovery itself can only be eSignature's own + // ensureInitialized failing (a broken/expired token) — a problem that already breaks + // every other resource type too, CLM or not, and should fail this sync loudly rather + // than be silently mistaken for a plain non-CLM account. + // + // Residual, accepted risk: the SDK's sync engine runs different resource types' + // List() concurrently (see vendor's pkg/sync/parallel_syncer.go), so two CLM + // builders' near-simultaneous discovery calls could in principle still disagree if + // CLM discovery itself answers inconsistently within one sync — e.g. clm_role sees a + // discovery failure and skips while clm_folder's later call succeeds and emits + // grants to clm_role/ principals this sync never produced. Not solved with a + // connector-side retry here: ductone/c1's own connector-error classification + // (isNonRetryableCode) already treats these exact codes as stable/permanent for a + // sync's lifetime, specifically because the token doesn't change mid-sync — so + // retrying would fight that platform convention, and there's no live CLM tenant to + // validate a bespoke cross-builder consistency mechanism against instead. + if client.IsClmDiscoveryError(err) { clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } From d0661b6cdcf5ddfa6281bf2a60aedd9371cd8f13 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 16:39:53 -0300 Subject: [PATCH 06/54] fix: require isOptInFeatureUnavailableError alongside IsClmDiscoveryError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug in the previous commit: ensureClmInitialized wraps EVERY discovery-call failure as a clmDiscoveryError, including transient infrastructure failures (5xx, rate limits, transport errors), not just the 4 codes isOptInFeatureUnavailableError tolerates. Gating solely on client.IsClmDiscoveryError(err) made clm_role's tolerance wider than every other CLM builder's for that class of failure — a discovery-sourced 503 would silently skip all 5 roles instead of failing loud like clm_folder/ clm_group/clm_member do for the same response. Adds Server.ForceClmDiscoveryStatus to clmtest for testing this directly. --- pkg/client/clmtest/server.go | 8 +++++++ pkg/connector/clm_roles.go | 41 +++++++++++++++++++++------------ pkg/connector/clm_roles_test.go | 28 +++++++++++++++++++--- 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index 9e4d41b1..a7256a4a 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -468,6 +468,14 @@ func (s *Server) handleUserInfo(w http.ResponseWriter, _ *http.Request) { // (ApiBaseUrl), matching the field name confirmed on CLM's legacy token-exchange // response for the same concept. func (s *Server) handleClmAccountDiscovery(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + forcedStatus := s.forcedDiscoveryStatus + s.mu.Unlock() + if forcedStatus != 0 { + w.WriteHeader(forcedStatus) + _ = json.NewEncoder(w).Encode(client.ClmErrorResponse{}) + return + } writeJSON(w, map[string]string{client.ClmDiscoveryFieldAPIBaseURL: s.baseURL}) } diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index 2316e931..b73266b1 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -30,25 +30,36 @@ func (b *clmRoleBuilder) ResourceType(_ context.Context) *v2.ResourceType { // apply, unlike the paginated CLM builders). func (b *clmRoleBuilder) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { if err := b.client.EnsureClmReady(ctx); err != nil { - // Narrower than every other CLM builder's tolerance (isOptInFeatureUnavailableError): - // EnsureClmReady is the ONLY CLM call clm_role's List() ever makes, so an error here - // that ISN'T from CLM account discovery itself can only be eSignature's own - // ensureInitialized failing (a broken/expired token) — a problem that already breaks - // every other resource type too, CLM or not, and should fail this sync loudly rather - // than be silently mistaken for a plain non-CLM account. + // Requires BOTH conditions, narrower than every other CLM builder's tolerance + // (isOptInFeatureUnavailableError alone): + // + // - client.IsClmDiscoveryError(err): EnsureClmReady is the ONLY CLM call + // clm_role's List() ever makes, so an error here that ISN'T from CLM account + // discovery itself can only be eSignature's own ensureInitialized failing (a + // broken/expired token) — a problem that already breaks every other resource + // type too, CLM or not, and should fail this sync loudly rather than be + // silently mistaken for a plain non-CLM account. + // - isOptInFeatureUnavailableError(err): ensureClmInitialized wraps EVERY + // doRequestCommon failure as a clmDiscoveryError, including transient + // infrastructure failures (5xx, rate limits, transport errors — + // codes.Unavailable and friends). IsClmDiscoveryError alone would tolerate + // those too, which every other CLM builder deliberately does NOT (see that + // function's doc) — a discovery-sourced 503 should fail loud, not be + // mistaken for "no CLM subscription". // // Residual, accepted risk: the SDK's sync engine runs different resource types' // List() concurrently (see vendor's pkg/sync/parallel_syncer.go), so two CLM // builders' near-simultaneous discovery calls could in principle still disagree if - // CLM discovery itself answers inconsistently within one sync — e.g. clm_role sees a - // discovery failure and skips while clm_folder's later call succeeds and emits - // grants to clm_role/ principals this sync never produced. Not solved with a - // connector-side retry here: ductone/c1's own connector-error classification - // (isNonRetryableCode) already treats these exact codes as stable/permanent for a - // sync's lifetime, specifically because the token doesn't change mid-sync — so - // retrying would fight that platform convention, and there's no live CLM tenant to - // validate a bespoke cross-builder consistency mechanism against instead. - if client.IsClmDiscoveryError(err) { + // CLM discovery itself answers inconsistently within one sync — e.g. clm_role sees + // a tolerated discovery failure and skips while clm_folder's later call succeeds + // and emits grants to clm_role/ principals this sync never produced. Not + // solved with a connector-side retry here: ductone/c1's own connector-error + // classification (isNonRetryableCode) already treats isOptInFeatureUnavailableError's + // codes as stable/permanent for a sync's lifetime, specifically because the token + // doesn't change mid-sync — so retrying would fight that platform convention, and + // there's no live CLM tenant to validate a bespoke cross-builder consistency + // mechanism against instead. + if client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_roles_test.go b/pkg/connector/clm_roles_test.go index 05753920..b099708d 100644 --- a/pkg/connector/clm_roles_test.go +++ b/pkg/connector/clm_roles_test.go @@ -35,9 +35,9 @@ func TestClmRoleBuilder_List(t *testing.T) { } func TestClmRoleBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { - // See clm_members_test.go's identical test for the full rationale. Before the 2a - // fix, clm_roles.go's List() made no API call at all, so this case couldn't happen - // — the 5 fixed roles synced unconditionally even without CLM access. + // See clm_members_test.go's identical test for the full rationale. Before List() + // gated on EnsureClmReady, clm_roles.go made no API call at all, so this case + // couldn't happen — the 5 fixed roles synced unconditionally even without CLM access. s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") b := newClmRoleBuilder(badClient) @@ -55,6 +55,28 @@ func TestClmRoleBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { } } +// TestClmRoleBuilder_List_FailsLoudlyOnTransientDiscoveryFailure is a regression test: +// ensureClmInitialized wraps EVERY discovery-call failure as a clmDiscoveryError, +// including transient infrastructure failures (5xx, rate limits), not just the 4 codes +// isOptInFeatureUnavailableError tolerates. Gating solely on +// client.IsClmDiscoveryError(err) — without also requiring +// isOptInFeatureUnavailableError(err) — would make clm_role silently skip on a 503 that +// every other CLM builder correctly treats as a loud failure. +func TestClmRoleBuilder_List_FailsLoudlyOnTransientDiscoveryFailure(t *testing.T) { + s, c := clmtest.NewServer(t) + s.ForceClmDiscoveryStatus(503) + b := newClmRoleBuilder(c) + ctx := context.Background() + + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{}) + if err == nil { + t.Fatal("expected a transient discovery failure (503) to fail loudly, got nil error") + } + if len(resources) != 0 { + t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) + } +} + func TestClmRoleBuilder_EntitlementsAndGrants_AreNoop(t *testing.T) { b := newClmRoleBuilder(nil) ctx := context.Background() From aec3e9512a00528128d431f7eff80857d54658b5 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 16:48:53 -0300 Subject: [PATCH 07/54] fix: clmIsBenignUnmappedAccessType doc/test correctness (no log-level change) Fixes clmIsBenignUnmappedAccessType's doc, which said "one of the two documented... values" while the function (and Grants()) treats three. Adds a Grants()-level test asserting the observable log behavior (a genuinely unrecognized AccessType logs at Debug; the 3 documented benign values stay silent) instead of only testing the predicate in isolation. Log level stays Debug, unchanged from before. --- pkg/connector/clm_folders.go | 18 ++++++------ pkg/connector/clm_folders_test.go | 47 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 4cb180a8..aa3f2b70 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -447,15 +447,15 @@ func clmSlugForAccessType(accessType string) (string, bool) { return "", false } -// clmIsBenignUnmappedAccessType reports whether accessType is one of the two documented -// non-grantable values every folder-security entry can legitimately carry — NoAccess -// (this connector's own Revoke leaves entries in place at this value, so it appears on -// every subsequent sync of a revoked entry) and Custom/InheritFromParentFolder (an -// arbitrary flag combination or an absence-of-override marker, neither round-trippable -// to a single tier — see clmFolderEntitlement's doc). Grants() skips all three the same -// way, but only logs the ones NOT in this set, so an entry with a genuinely unrecognized -// AccessType (a real anomaly) doesn't get lost in three expected values large accounts -// can produce on every single sync. +// clmIsBenignUnmappedAccessType reports whether accessType is one of the three +// documented non-grantable values every folder-security entry can legitimately carry — +// NoAccess (this connector's own Revoke leaves entries in place at this value, so it +// appears on every subsequent sync of a revoked entry), Custom, and +// InheritFromParentFolder (an arbitrary flag combination or an absence-of-override +// marker, neither round-trippable to a single tier — see clmFolderEntitlement's doc). +// Grants() skips all three the same way, but only logs the ones NOT in this set, so a +// genuinely unrecognized AccessType doesn't get lost in three expected values large +// accounts can produce on every single sync. func clmIsBenignUnmappedAccessType(accessType string) bool { switch accessType { case client.ClmAccessTypeNoAccess, client.ClmAccessTypeCustom, client.ClmAccessTypeInherit: diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 27e68326..ba332756 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -11,6 +11,10 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" "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" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) // --- Pure-function tests: clmSlugForAccessType / clmAccessTypeForSlug --- @@ -226,6 +230,49 @@ func TestClmIsBenignUnmappedAccessType(t *testing.T) { } } +// TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType tests the +// observable behavior Grants() ships, not just the clmIsBenignUnmappedAccessType +// predicate in isolation: a genuinely unrecognized AccessType must still log (at +// Debug), while a benign one (NoAccess here) must stay silent even at Debug. +func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *testing.T) { + _, c := clmtest.NewServer(t) + ctx := context.Background() + + if _, err := c.PatchFolderSecurity(ctx, "folder-templates", client.ClmFolderSecurityWrite{ + Groups: []client.ClmGroupSecurityEntry{ + {AccessType: "SomethingUnrecognized", Href: "https://example.com/groups/group-x"}, + {AccessType: client.ClmAccessTypeNoAccess, Href: "https://example.com/groups/group-y"}, + }, + }); err != nil { + t.Fatalf("PatchFolderSecurity (seed): %v", err) + } + + core, logs := observer.New(zapcore.DebugLevel) + observedCtx := ctxzap.ToContext(ctx, zap.New(core)) + + b := newClmFolderBuilder(c) + folderResource, err := rs.NewResource("Templates", clmFolderResourceType, "folder-templates") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + + grants, _, err := b.Grants(observedCtx, folderResource, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants: %v", err) + } + if len(grants) != 0 { + t.Fatalf("expected both entries to be skipped (neither maps to a grantable tier), got %d grants: %+v", len(grants), grants) + } + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("expected exactly 1 log entry (the genuinely unrecognized AccessType; NoAccess should stay silent), got %d: %+v", len(entries), entries) + } + if entries[0].Level != zapcore.DebugLevel { + t.Errorf("expected the unmapped-AccessType log to be at Debug, got %v", entries[0].Level) + } +} + func TestClmFolderBuilder_GrantAndRevoke_Idempotent(t *testing.T) { srv, c := clmtest.NewServer(t) b := newClmFolderBuilder(c) From fcf849be58d3013ae345fdd182f127a9e85a6a04 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 17:10:22 -0300 Subject: [PATCH 08/54] fix: pin which entry logs in the AccessType-skip test, not just how many MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counting log entries and checking level alone would still pass if clmIsBenignUnmappedAccessType's condition were inverted (silencing the genuinely unrecognized entry and logging the benign one instead) — same count, same level, wrong entry. Assert the logged entry's access_type field to actually pin which one fired. --- pkg/connector/clm_folders_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index ba332756..ba3113be 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -271,6 +271,13 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t if entries[0].Level != zapcore.DebugLevel { t.Errorf("expected the unmapped-AccessType log to be at Debug, got %v", entries[0].Level) } + // Pins WHICH entry logged, not just how many: an inverted clmIsBenignUnmappedAccessType + // check (silencing SomethingUnrecognized and logging NoAccess instead) would still + // produce exactly 1 Debug entry, passing the two assertions above on the exact bug + // this test exists to catch. + if got := entries[0].ContextMap()["access_type"]; got != "SomethingUnrecognized" { + t.Errorf("expected the logged entry's access_type to be %q, got %q", "SomethingUnrecognized", got) + } } func TestClmFolderBuilder_GrantAndRevoke_Idempotent(t *testing.T) { From 96aaebfc24000e4028ace2b4584b6a74d613870e Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 17:29:25 -0300 Subject: [PATCH 09/54] test: cover the other half of clm_role's two-condition CLM-availability gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestClmRoleBuilder_List_SkipsGracefullyWhenClmUnavailable's bad-token client happens to fail at CLM discovery itself in this mock (eSignature's /oauth/userinfo has no auth check), so it only ever pinned the isOptInFeatureUnavailableError conjunct — deleting "client.IsClmDiscoveryError(err) &&" from the gate left the whole suite green. Adds Server.ForceUserInfoStatus to clmtest (mirroring ForceClmDiscoveryStatus) to force a tolerated code out of ensureInitialized specifically, and a test asserting List() still fails loud for it. Verified the new test fails against the exact mutation before restoring the real code. --- pkg/client/clmtest/server.go | 9 +++++++++ pkg/connector/clm_roles_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index a7256a4a..69cad0f2 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -443,6 +443,15 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { } func (s *Server) handleUserInfo(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + forcedStatus := s.forcedUserInfoStatus + s.mu.Unlock() + if forcedStatus != 0 { + w.WriteHeader(forcedStatus) + _ = json.NewEncoder(w).Encode(client.ErrorResponse{}) + return + } + resp := client.UserInfoResponse{ Sub: "clm-test-user", Name: "CLM Test Account", diff --git a/pkg/connector/clm_roles_test.go b/pkg/connector/clm_roles_test.go index b099708d..39eb7799 100644 --- a/pkg/connector/clm_roles_test.go +++ b/pkg/connector/clm_roles_test.go @@ -77,6 +77,30 @@ func TestClmRoleBuilder_List_FailsLoudlyOnTransientDiscoveryFailure(t *testing.T } } +// TestClmRoleBuilder_List_FailsLoudlyOnNonDiscoveryTolerableError is a regression test +// for the OTHER half of List()'s "requires both conditions" gate: a tolerated code +// (Unauthenticated here) that comes from eSignature's own ensureInitialized, not CLM +// account discovery, must still fail loud. Without the client.IsClmDiscoveryError(err) +// conjunct, this would be silently mistaken for "no CLM subscription" — the case +// clm_roles.go's own doc comment names first. Deleting that conjunct alone would leave +// TestClmRoleBuilder_List_SkipsGracefullyWhenClmUnavailable and +// TestClmRoleBuilder_List_FailsLoudlyOnTransientDiscoveryFailure both green, since +// neither exercises a tolerated code from this specific source. +func TestClmRoleBuilder_List_FailsLoudlyOnNonDiscoveryTolerableError(t *testing.T) { + s, c := clmtest.NewServer(t) + s.ForceUserInfoStatus(401) + b := newClmRoleBuilder(c) + ctx := context.Background() + + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{}) + if err == nil { + t.Fatal("expected a tolerated code from a non-discovery source to fail loudly, got nil error") + } + if len(resources) != 0 { + t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) + } +} + func TestClmRoleBuilder_EntitlementsAndGrants_AreNoop(t *testing.T) { b := newClmRoleBuilder(nil) ctx := context.Background() From 3ac142e1ea0047fe5c614b1be1593cb33adab86d Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 17:46:02 -0300 Subject: [PATCH 10/54] fix: carry CLM-discovery source as a log field, not a log level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clmSkipLogLevel logged the "CLM unavailable, skipping sync" line at Warn whenever the tolerated error wasn't from CLM account discovery itself. This repo can't confirm where DocuSign actually enforces the "no CLM subscription" check — if it turns out to be at the per-resource data call for most eSignature-only accounts (the common case), the Warn branch becomes the STEADY STATE for the majority of syncs, inverting what Warn is supposed to signal (the noisy case ends up being the expected one). Replaces it with clmDiscoverySourceField: always log at Info, but attach a from_clm_discovery bool field so a dashboard/alert can still key on the source without either log-level assumption backfiring depending on which turns out to be the common case. --- pkg/connector/clm_folders.go | 2 +- pkg/connector/clm_groups.go | 3 ++- pkg/connector/clm_members.go | 3 ++- pkg/connector/clm_permission_sets.go | 3 ++- pkg/connector/clm_roles.go | 3 ++- pkg/connector/helper.go | 32 ++++++++++------------- pkg/connector/helper_test.go | 38 ++++++++-------------------- 7 files changed, 34 insertions(+), 50 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index aa3f2b70..4f7226f2 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -75,7 +75,7 @@ func (f *clmFolderBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S }) if err != nil { if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err), clmDiscoverySourceField(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index b13bce05..d10ba257 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -10,6 +10,7 @@ import ( "github.com/conductorone/baton-sdk/pkg/connectorbuilder" "github.com/conductorone/baton-sdk/pkg/types/grant" 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" @@ -53,7 +54,7 @@ func (g *clmGroupBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.Sy }) if err != nil { if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err), clmDiscoverySourceField(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 0a867b43..7cec13fd 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -6,6 +6,7 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" ) @@ -35,7 +36,7 @@ func (b *clmMemberBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S }) if err != nil { if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err), clmDiscoverySourceField(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_permission_sets.go b/pkg/connector/clm_permission_sets.go index 477fc124..b538ca8f 100644 --- a/pkg/connector/clm_permission_sets.go +++ b/pkg/connector/clm_permission_sets.go @@ -7,6 +7,7 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/types/entitlement" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" ) @@ -42,7 +43,7 @@ func (b *clmPermissionSetBuilder) List(ctx context.Context, _ *v2.ResourceId, at }) if err != nil { if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err), clmDiscoverySourceField(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index b73266b1..69989245 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -6,6 +6,7 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" ) @@ -60,7 +61,7 @@ func (b *clmRoleBuilder) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOp // there's no live CLM tenant to validate a bespoke cross-builder consistency // mechanism against instead. if client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { - clmSkipLogLevel(ctx, err)("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err), clmDiscoverySourceField(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index cdd6e9d4..64aae387 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -84,24 +84,20 @@ func isOptInFeatureUnavailableError(err error) bool { } } -// clmSkipLogLevel picks Info or Warn for the "CLM is not available, skipping sync" log -// line every CLM builder's List() emits when isOptInFeatureUnavailableError tolerates an -// error. Deliberately does NOT gate whether the sync skips gracefully — a genuine "no -// CLM subscription" signal can legitimately come from either CLM account discovery or a -// later per-resource CLM data call, depending on where DocuSign enforces the check for a -// given account, and this project has no live CLM tenant to confirm which; gating on the -// source would risk turning a real account's previously-graceful skip into a hard sync -// failure. So: the same tolerance either way, but logged louder when the source isn't -// discovery, since that case doesn't have the same one-directional "this really is a -// missing subscription" guarantee a discovery failure does — it could also be a -// narrower problem (a token that expired mid-sync, a scope issue on just this endpoint) -// being silently treated as "nothing to sync" rather than a real failure, which is worth -// a human noticing. -func clmSkipLogLevel(ctx context.Context, err error) func(string, ...zap.Field) { - if client.IsClmDiscoveryError(err) { - return ctxzap.Extract(ctx).Info - } - return ctxzap.Extract(ctx).Warn +// clmDiscoverySourceField attaches whether a tolerated "CLM is not available" error +// actually came from CLM account discovery to the "skipping sync" log line every CLM +// builder's List() emits when isOptInFeatureUnavailableError tolerates an error — a +// genuine "no CLM subscription" signal can legitimately come from either CLM account +// discovery or a later per-resource CLM data call, depending on where DocuSign enforces +// the check for a given account, and this project has no live CLM tenant to confirm +// which. Deliberately carried as a field, not a log level: if the per-resource-call +// source turns out to be the common case for most eSignature-only accounts (this repo +// can't confirm either way), logging that case louder would make the LOUD level the +// steady state for the majority of syncs — inverting what that level is supposed to +// signal. A field lets a dashboard/alert key on the source without either log level +// assumption backfiring. +func clmDiscoverySourceField(err error) zap.Field { + return zap.Bool("from_clm_discovery", client.IsClmDiscoveryError(err)) } // clmIDFromHref extracts the trailing path segment from a CLM object's Href — see diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index eba679dd..622c7f4e 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -8,10 +8,6 @@ import ( "github.com/conductorone/baton-docusign/pkg/client/clmtest" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -93,22 +89,17 @@ func TestClmHrefWithID(t *testing.T) { } } -// TestClmSkipLogLevel confirms clmSkipLogLevel's only real decision: log louder (Warn, -// not Info) when the tolerated error didn't actually come from CLM account discovery. -// It does NOT gate whether the sync skips gracefully — both a discovery error and a -// plain one are equally tolerated by isOptInFeatureUnavailableError, unchanged. See -// clmSkipLogLevel's doc for why gating on the source, not just the log level, was tried -// and reverted. -func TestClmSkipLogLevel(t *testing.T) { - core, logs := observer.New(zapcore.DebugLevel) - ctx := ctxzap.ToContext(context.Background(), zap.New(core)) - +// TestClmDiscoverySourceField confirms the field correctly reports whether a tolerated +// error actually came from CLM account discovery — both a discovery error and a plain +// one are equally tolerated by isOptInFeatureUnavailableError, unchanged; this is purely +// informational, not a gate. See clmDiscoverySourceField's doc for why this is a field +// and not a log level. +func TestClmDiscoverySourceField(t *testing.T) { s, _ := clmtest.NewServer(t) - discoveryErr := s.NewClientWithToken("wrong-token").EnsureClmReady(ctx) + discoveryErr := s.NewClientWithToken("wrong-token").EnsureClmReady(context.Background()) if discoveryErr == nil { t.Fatal("test setup: expected EnsureClmReady to fail for a bad token") } - logs.TakeAll() // discard the underlying HTTP client's own log line from that call above // Stands in for a real per-resource CLM data call (SearchFolders, ListGroups, ...) // failing for an unrelated reason (an expired token mid-sync, a narrower scope // problem) after discovery already succeeded — isOptInFeatureUnavailableError @@ -116,18 +107,11 @@ func TestClmSkipLogLevel(t *testing.T) { // one-directional "this means no CLM subscription" guarantee. plainErr := status.Error(codes.Unauthenticated, "token expired mid-sync") - clmSkipLogLevel(ctx, discoveryErr)("discovery skip") - clmSkipLogLevel(ctx, plainErr)("plain skip") - - entries := logs.All() - if len(entries) != 2 { - t.Fatalf("expected 2 log entries, got %d: %+v", len(entries), entries) - } - if entries[0].Level != zapcore.InfoLevel { - t.Errorf("expected a discovery-sourced error to log at Info, got %v", entries[0].Level) + if got := clmDiscoverySourceField(discoveryErr); got.Integer != 1 { + t.Errorf("expected from_clm_discovery=true for a discovery-sourced error, got %+v", got) } - if entries[1].Level != zapcore.WarnLevel { - t.Errorf("expected a non-discovery error to log at Warn, got %v", entries[1].Level) + if got := clmDiscoverySourceField(plainErr); got.Integer != 0 { + t.Errorf("expected from_clm_discovery=false for a non-discovery error, got %+v", got) } } From b446fe713e43058bb4476be2eeff31e2f66fc157 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 18:14:36 -0300 Subject: [PATCH 11/54] =?UTF-8?q?fix:=20drop=20clmDiscoverySourceField=20?= =?UTF-8?q?=E2=80=94=20the=20error=20text=20already=20says=20why?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zap.Error(err) already carries the full wrapped error text, and every CLM client method names its own operation in that text ("failed to discover the CLM API base URL" vs "failed to search CLM folders" vs "failed to list CLM groups", etc.) — so a human or a Datadog search reading the log line already knows exactly what failed and where, without needing a separate field to encode "did this come from discovery." The field was solving a problem the existing zap.Error(err) already solved. All 5 CLM builders now log a plain Info line with just the error. --- pkg/connector/clm_folders.go | 2 +- pkg/connector/clm_groups.go | 2 +- pkg/connector/clm_members.go | 2 +- pkg/connector/clm_permission_sets.go | 2 +- pkg/connector/clm_roles.go | 2 +- pkg/connector/helper.go | 16 --------------- pkg/connector/helper_test.go | 30 ++-------------------------- 7 files changed, 7 insertions(+), 49 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 4f7226f2..9a944ea5 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -75,7 +75,7 @@ func (f *clmFolderBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S }) if err != nil { if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err), clmDiscoverySourceField(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index d10ba257..318e0302 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -54,7 +54,7 @@ func (g *clmGroupBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.Sy }) if err != nil { if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err), clmDiscoverySourceField(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 7cec13fd..74352724 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -36,7 +36,7 @@ func (b *clmMemberBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S }) if err != nil { if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err), clmDiscoverySourceField(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_permission_sets.go b/pkg/connector/clm_permission_sets.go index b538ca8f..04b81b66 100644 --- a/pkg/connector/clm_permission_sets.go +++ b/pkg/connector/clm_permission_sets.go @@ -43,7 +43,7 @@ func (b *clmPermissionSetBuilder) List(ctx context.Context, _ *v2.ResourceId, at }) if err != nil { if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err), clmDiscoverySourceField(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index 69989245..ebfd2e32 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -61,7 +61,7 @@ func (b *clmRoleBuilder) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOp // there's no live CLM tenant to validate a bespoke cross-builder consistency // mechanism against instead. if client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err), clmDiscoverySourceField(err)) + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } return nil, nil, err diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 64aae387..1795e533 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -84,22 +84,6 @@ func isOptInFeatureUnavailableError(err error) bool { } } -// clmDiscoverySourceField attaches whether a tolerated "CLM is not available" error -// actually came from CLM account discovery to the "skipping sync" log line every CLM -// builder's List() emits when isOptInFeatureUnavailableError tolerates an error — a -// genuine "no CLM subscription" signal can legitimately come from either CLM account -// discovery or a later per-resource CLM data call, depending on where DocuSign enforces -// the check for a given account, and this project has no live CLM tenant to confirm -// which. Deliberately carried as a field, not a log level: if the per-resource-call -// source turns out to be the common case for most eSignature-only accounts (this repo -// can't confirm either way), logging that case louder would make the LOUD level the -// steady state for the majority of syncs — inverting what that level is supposed to -// signal. A field lets a dashboard/alert key on the source without either log level -// assumption backfiring. -func clmDiscoverySourceField(err error) zap.Field { - return zap.Bool("from_clm_discovery", client.IsClmDiscoveryError(err)) -} - // clmIDFromHref extracts the trailing path segment from a CLM object's Href — see // 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 diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index 622c7f4e..8c4e6822 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -1,11 +1,11 @@ package connector import ( - "context" "errors" "testing" - "github.com/conductorone/baton-docusign/pkg/client/clmtest" + "context" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "google.golang.org/grpc/codes" @@ -89,32 +89,6 @@ func TestClmHrefWithID(t *testing.T) { } } -// TestClmDiscoverySourceField confirms the field correctly reports whether a tolerated -// error actually came from CLM account discovery — both a discovery error and a plain -// one are equally tolerated by isOptInFeatureUnavailableError, unchanged; this is purely -// informational, not a gate. See clmDiscoverySourceField's doc for why this is a field -// and not a log level. -func TestClmDiscoverySourceField(t *testing.T) { - s, _ := clmtest.NewServer(t) - discoveryErr := s.NewClientWithToken("wrong-token").EnsureClmReady(context.Background()) - if discoveryErr == nil { - t.Fatal("test setup: expected EnsureClmReady to fail for a bad token") - } - // Stands in for a real per-resource CLM data call (SearchFolders, ListGroups, ...) - // failing for an unrelated reason (an expired token mid-sync, a narrower scope - // problem) after discovery already succeeded — isOptInFeatureUnavailableError - // tolerates this identically to a discovery error, but it doesn't have the same - // one-directional "this means no CLM subscription" guarantee. - plainErr := status.Error(codes.Unauthenticated, "token expired mid-sync") - - if got := clmDiscoverySourceField(discoveryErr); got.Integer != 1 { - t.Errorf("expected from_clm_discovery=true for a discovery-sourced error, got %+v", got) - } - if got := clmDiscoverySourceField(plainErr); got.Integer != 0 { - t.Errorf("expected from_clm_discovery=false for a non-discovery error, got %+v", got) - } -} - func TestClmPreferredHref(t *testing.T) { ctx := context.Background() fallbackCalled := false From 6ff16420086807beca57261fee11a8deb49fd811 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 12:40:43 -0300 Subject: [PATCH 12/54] test: cover Roles/Users branches for the AccessType-skip log; fix ForceClmDiscoveryStatus doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clmIsBenignUnmappedAccessType is re-checked by hand in each of the Groups/Roles/Users loops in clm_folders.go's Grants(), so a copy-paste slip in just one of them left a Groups-only test green. Seeds all three collections and scopes the log assertion to the access_type field key so it isn't brittle to unrelated log traffic from the HTTP/cache layer. Also fixes ForceClmDiscoveryStatus's doc, which overclaimed "every subsequent call" when handleClmAccountDiscovery is registered behind requireAuth — only a client presenting the fixed test bearer token actually reaches the forced status. --- pkg/connector/clm_folders_test.go | 43 +++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index ba3113be..5a6c2fbe 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -238,11 +238,23 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t _, c := clmtest.NewServer(t) ctx := context.Background() + // Seeds all three principal-type collections, not just Groups: clmIsBenignUnmappedAccessType + // is re-checked by hand in each of the Groups/Roles/Users loops in clm_folders.go, so a + // copy-paste slip in just one of them (an inverted !, or the guard omitted entirely) would + // leave a Groups-only test green. if _, err := c.PatchFolderSecurity(ctx, "folder-templates", client.ClmFolderSecurityWrite{ Groups: []client.ClmGroupSecurityEntry{ {AccessType: "SomethingUnrecognized", Href: "https://example.com/groups/group-x"}, {AccessType: client.ClmAccessTypeNoAccess, Href: "https://example.com/groups/group-y"}, }, + Roles: []client.ClmRoleSecurityEntry{ + {AccessType: "SomethingUnrecognized", Item: "FullSubscriber"}, + {AccessType: client.ClmAccessTypeNoAccess, Item: "Guest"}, + }, + Users: []client.ClmUserSecurityEntry{ + {AccessType: "SomethingUnrecognized", Href: "https://example.com/members/member-x"}, + {AccessType: client.ClmAccessTypeNoAccess, Href: "https://example.com/members/member-y"}, + }, }); err != nil { t.Fatalf("PatchFolderSecurity (seed): %v", err) } @@ -261,22 +273,27 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t t.Fatalf("Grants: %v", err) } if len(grants) != 0 { - t.Fatalf("expected both entries to be skipped (neither maps to a grantable tier), got %d grants: %+v", len(grants), grants) + t.Fatalf("expected all 6 entries to be skipped (none map to a grantable tier), got %d grants: %+v", len(grants), grants) } - entries := logs.All() - if len(entries) != 1 { - t.Fatalf("expected exactly 1 log entry (the genuinely unrecognized AccessType; NoAccess should stay silent), got %d: %+v", len(entries), entries) - } - if entries[0].Level != zapcore.DebugLevel { - t.Errorf("expected the unmapped-AccessType log to be at Debug, got %v", entries[0].Level) + // Scoped to the access_type field so this only counts the three skip-log lines + // Grants() emits, not any unrelated log traffic from the HTTP/cache layer + // underneath GetFolder. + entries := logs.FilterFieldKey("access_type").All() + if len(entries) != 3 { + t.Fatalf("expected exactly 3 log entries (one per Groups/Roles/Users branch, for the genuinely unrecognized AccessType only), got %d: %+v", len(entries), entries) } - // Pins WHICH entry logged, not just how many: an inverted clmIsBenignUnmappedAccessType - // check (silencing SomethingUnrecognized and logging NoAccess instead) would still - // produce exactly 1 Debug entry, passing the two assertions above on the exact bug - // this test exists to catch. - if got := entries[0].ContextMap()["access_type"]; got != "SomethingUnrecognized" { - t.Errorf("expected the logged entry's access_type to be %q, got %q", "SomethingUnrecognized", got) + for _, e := range entries { + if e.Level != zapcore.DebugLevel { + t.Errorf("expected the unmapped-AccessType log to be at Debug, got %v", e.Level) + } + // Pins WHICH entry logged, not just how many: an inverted clmIsBenignUnmappedAccessType + // check (silencing SomethingUnrecognized and logging NoAccess instead) would still + // produce exactly 3 Debug entries, passing the assertions above on the exact bug this + // test exists to catch. + if got := e.ContextMap()["access_type"]; got != "SomethingUnrecognized" { + t.Errorf("expected the logged entry's access_type to be %q, got %q", "SomethingUnrecognized", got) + } } } From 7ef387fb5e99122b0b6f82e6cbb1a57ccbe34387 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 13:03:01 -0300 Subject: [PATCH 13/54] test: assert each branch's distinguishing log field, not just count/access_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeding all three collections catches an omitted/inverted guard, but the three log entries were treated as interchangeable — a copy-paste slip that fires the right branch's guard under another branch's field name (e.g. the Users loop logging group_href instead of member_href) still produced 3 Debug entries with access_type: SomethingUnrecognized and passed. Now asserts each of group_href/role/member_href appears exactly once across the three entries. --- pkg/connector/clm_folders_test.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 5a6c2fbe..130d84b4 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -283,6 +283,12 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t if len(entries) != 3 { t.Fatalf("expected exactly 3 log entries (one per Groups/Roles/Users branch, for the genuinely unrecognized AccessType only), got %d: %+v", len(entries), entries) } + // Each branch logs a different distinguishing field alongside access_type — Groups: + // group_href, Roles: role, Users: member_href — so a copy-paste slip that fires the + // right branch's guard but with another branch's field name/message (e.g. Users + // logging group_href) still needs catching, not just the count/level/access_type. + distinguishingFields := []string{"group_href", "role", "member_href"} + seenDistinguishingField := map[string]bool{} for _, e := range entries { if e.Level != zapcore.DebugLevel { t.Errorf("expected the unmapped-AccessType log to be at Debug, got %v", e.Level) @@ -291,9 +297,20 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t // check (silencing SomethingUnrecognized and logging NoAccess instead) would still // produce exactly 3 Debug entries, passing the assertions above on the exact bug this // test exists to catch. - if got := e.ContextMap()["access_type"]; got != "SomethingUnrecognized" { + ctx := e.ContextMap() + if got := ctx["access_type"]; got != "SomethingUnrecognized" { t.Errorf("expected the logged entry's access_type to be %q, got %q", "SomethingUnrecognized", got) } + for _, key := range distinguishingFields { + if _, ok := ctx[key]; ok { + seenDistinguishingField[key] = true + } + } + } + for _, key := range distinguishingFields { + if !seenDistinguishingField[key] { + t.Errorf("expected one log entry carrying the %q field (the branch that never fired, or fired under the wrong field name)", key) + } } } From 7c97d9588ef3732fa90b3a2f4d86e07ce782c697 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 13:55:03 -0300 Subject: [PATCH 14/54] fix: address remaining PR #64 review threads - Give Custom its own distinct Debug log in Grants() (Groups/Roles/Users branches): it's a real, active grant this connector can't round-trip to a single tier, unlike NoAccess/InheritFromParentFolder, which stay fully silent since they're expected inert states, not a visibility gap. - Pin TestClmRoleBuilder_List_FailsLoudlyOnNonDiscoveryTolerableError's preconditions (isOptInFeatureUnavailableError + !IsClmDiscoveryError) so it stays a mutation-killer for the IsClmDiscoveryError conjunct even if the userinfo failure's code mapping changes later. - Note clm_role's residual gap: its gate only helps when DocuSign rejects CLM at discovery, not on a later per-resource data call. --- pkg/connector/clm_folders.go | 42 ++++++++++++++++-------- pkg/connector/clm_folders_test.go | 53 ++++++++++++++++++++++++++++++- pkg/connector/clm_roles.go | 5 +++ pkg/connector/clm_roles_test.go | 12 +++++++ 4 files changed, 98 insertions(+), 14 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 9a944ea5..fe6e4954 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -141,7 +141,11 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Groups { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - if !clmIsBenignUnmappedAccessType(entry.AccessType) { + switch { + case entry.AccessType == client.ClmAccessTypeCustom: + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder group-security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", + zap.String("folder_id", folderResource.Id.Resource), zap.String("group_href", entry.Href)) + case !clmIsBenignUnmappedAccessType(entry.AccessType): ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder group-security entry with an unmapped AccessType", zap.String("folder_id", folderResource.Id.Resource), zap.String("group_href", entry.Href), zap.String("access_type", entry.AccessType)) } @@ -160,7 +164,11 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Roles { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - if !clmIsBenignUnmappedAccessType(entry.AccessType) { + switch { + case entry.AccessType == client.ClmAccessTypeCustom: + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", + zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item)) + case !clmIsBenignUnmappedAccessType(entry.AccessType): ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry with an unmapped AccessType", zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item), zap.String("access_type", entry.AccessType)) } @@ -181,7 +189,11 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Users { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - if !clmIsBenignUnmappedAccessType(entry.AccessType) { + switch { + case entry.AccessType == client.ClmAccessTypeCustom: + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder user-security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", + zap.String("folder_id", folderResource.Id.Resource), zap.String("member_href", entry.Href)) + case !clmIsBenignUnmappedAccessType(entry.AccessType): ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder user-security entry with an unmapped AccessType", zap.String("folder_id", folderResource.Id.Resource), zap.String("member_href", entry.Href), zap.String("access_type", entry.AccessType)) } @@ -447,18 +459,22 @@ func clmSlugForAccessType(accessType string) (string, bool) { return "", false } -// clmIsBenignUnmappedAccessType reports whether accessType is one of the three -// documented non-grantable values every folder-security entry can legitimately carry — -// NoAccess (this connector's own Revoke leaves entries in place at this value, so it -// appears on every subsequent sync of a revoked entry), Custom, and -// InheritFromParentFolder (an arbitrary flag combination or an absence-of-override -// marker, neither round-trippable to a single tier — see clmFolderEntitlement's doc). -// Grants() skips all three the same way, but only logs the ones NOT in this set, so a -// genuinely unrecognized AccessType doesn't get lost in three expected values large -// accounts can produce on every single sync. +// clmIsBenignUnmappedAccessType reports whether accessType is one of the two documented +// non-grantable-but-truly-inert values every folder-security entry can legitimately +// carry — NoAccess (this connector's own Revoke leaves entries in place at this value, +// so it appears on every subsequent sync of a revoked entry) and InheritFromParentFolder +// (an absence-of-override marker — see clmFolderEntitlement's doc). Grants() skips these +// the same way it skips Custom, but stays fully silent for them, unlike Custom: neither +// represents an access grant C1 is failing to show, so logging them would only add +// per-sync noise for two expected states large accounts can produce on every sync. +// +// Custom is deliberately NOT in this set — see its own Debug log at each call site: it's +// a real, active grant this connector can't round-trip to a single tier (an arbitrary +// flag combination), so silencing it the same way would hide an actual access-visibility +// gap, not just an expected inert state. func clmIsBenignUnmappedAccessType(accessType string) bool { switch accessType { - case client.ClmAccessTypeNoAccess, client.ClmAccessTypeCustom, client.ClmAccessTypeInherit: + case client.ClmAccessTypeNoAccess, client.ClmAccessTypeInherit: return true default: return false diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 130d84b4..d2d49792 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -3,6 +3,7 @@ package connector import ( "context" "fmt" + "strings" "testing" "github.com/conductorone/baton-docusign/pkg/client" @@ -217,7 +218,10 @@ func TestClmIsBenignUnmappedAccessType(t *testing.T) { want bool }{ {client.ClmAccessTypeNoAccess, true}, - {client.ClmAccessTypeCustom, true}, + // Custom is deliberately excluded — it's a real, active grant this connector + // can't round-trip, so it gets its own distinct Debug log at each Grants() call + // site instead of being silenced like the truly-inert values here. + {client.ClmAccessTypeCustom, false}, {client.ClmAccessTypeInherit, true}, {client.ClmAccessTypeView, false}, {"SomethingUnrecognized", false}, @@ -314,6 +318,53 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t } } +// TestClmFolderBuilder_Grants_LogsDistinctlyForCustomAccessType confirms Custom gets its +// own distinct Debug line, not silence like NoAccess/InheritFromParentFolder: unlike +// those two, Custom represents a real, active grant this connector can't round-trip to +// a single tier, so silencing it the same way would hide an actual access-visibility +// gap rather than just an expected inert state. +func TestClmFolderBuilder_Grants_LogsDistinctlyForCustomAccessType(t *testing.T) { + _, c := clmtest.NewServer(t) + ctx := context.Background() + + if _, err := c.PatchFolderSecurity(ctx, "folder-templates", client.ClmFolderSecurityWrite{ + Groups: []client.ClmGroupSecurityEntry{ + {AccessType: client.ClmAccessTypeCustom, Href: "https://example.com/groups/group-x"}, + {AccessType: client.ClmAccessTypeNoAccess, Href: "https://example.com/groups/group-y"}, + }, + }); err != nil { + t.Fatalf("PatchFolderSecurity (seed): %v", err) + } + + core, logs := observer.New(zapcore.DebugLevel) + observedCtx := ctxzap.ToContext(ctx, zap.New(core)) + + b := newClmFolderBuilder(c) + folderResource, err := rs.NewResource("Templates", clmFolderResourceType, "folder-templates") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + + grants, _, err := b.Grants(observedCtx, folderResource, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants: %v", err) + } + if len(grants) != 0 { + t.Fatalf("expected both entries to be skipped, got %d grants: %+v", len(grants), grants) + } + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("expected exactly 1 log entry (Custom; NoAccess should stay silent), got %d: %+v", len(entries), entries) + } + if entries[0].Level != zapcore.DebugLevel { + t.Errorf("expected the Custom-AccessType log to be at Debug, got %v", entries[0].Level) + } + if !strings.Contains(entries[0].Message, "Custom") { + t.Errorf("expected the log message to distinctly mention Custom, got %q", entries[0].Message) + } +} + func TestClmFolderBuilder_GrantAndRevoke_Idempotent(t *testing.T) { srv, c := clmtest.NewServer(t) b := newClmFolderBuilder(c) diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index ebfd2e32..abb4916a 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -60,6 +60,11 @@ func (b *clmRoleBuilder) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOp // doesn't change mid-sync — so retrying would fight that platform convention, and // there's no live CLM tenant to validate a bespoke cross-builder consistency // mechanism against instead. + // + // This gate only helps when DocuSign rejects CLM at discovery. If some accounts + // are instead rejected only on a later per-resource data call (unconfirmed either + // way), clm_role still emits its 5 roles while the other CLM builders skip — + // clm_role has no data call of its own to check that case with. if client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil diff --git a/pkg/connector/clm_roles_test.go b/pkg/connector/clm_roles_test.go index 39eb7799..35d7b672 100644 --- a/pkg/connector/clm_roles_test.go +++ b/pkg/connector/clm_roles_test.go @@ -7,6 +7,7 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" "github.com/conductorone/baton-docusign/pkg/client/clmtest" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "google.golang.org/grpc/status" ) func TestClmRoleBuilder_List(t *testing.T) { @@ -96,6 +97,17 @@ func TestClmRoleBuilder_List_FailsLoudlyOnNonDiscoveryTolerableError(t *testing. if err == nil { t.Fatal("expected a tolerated code from a non-discovery source to fail loudly, got nil error") } + // Pins the two preconditions that make this a regression test for the + // IsClmDiscoveryError conjunct rather than for "any error at all": the error must + // carry a code isOptInFeatureUnavailableError tolerates, and must not be + // discovery-sourced. Otherwise a change to the userinfo failure's code mapping + // would leave this test green while no longer exercising the gate. + if !isOptInFeatureUnavailableError(err) { + t.Fatalf("test setup: expected a tolerated code so this test exercises the discovery-source conjunct, got %v: %v", status.Code(err), err) + } + if client.IsClmDiscoveryError(err) { + t.Fatalf("test setup: expected a non-discovery-sourced error, got: %v", err) + } if len(resources) != 0 { t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) } From 39312ee3083e4c5716c9ae397f42abb7c1f9ab0a Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 14:47:07 -0300 Subject: [PATCH 15/54] test: bind unmapped-AccessType log assertions per-message, not set-cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches a branch swapping its distinguishing field/message with another (e.g. Users logging group_href) without exercising every combination — the previous check only verified each field appeared somewhere across the 3 entries. Also renames the shadowing ctx local to fields. --- pkg/connector/clm_folders_test.go | 38 +++++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index d2d49792..9de7f9d1 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -287,12 +287,16 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t if len(entries) != 3 { t.Fatalf("expected exactly 3 log entries (one per Groups/Roles/Users branch, for the genuinely unrecognized AccessType only), got %d: %+v", len(entries), entries) } - // Each branch logs a different distinguishing field alongside access_type — Groups: - // group_href, Roles: role, Users: member_href — so a copy-paste slip that fires the - // right branch's guard but with another branch's field name/message (e.g. Users - // logging group_href) still needs catching, not just the count/level/access_type. - distinguishingFields := []string{"group_href", "role", "member_href"} - seenDistinguishingField := map[string]bool{} + // Each branch's message names its own distinguishing field alongside access_type — + // binding message to field (not just checking each field appears SOMEWHERE across + // the 3 entries) catches a copy-paste slip that swaps them between branches, e.g. + // the Users loop emitting group_href under its own "user-security" message. + wantFieldForMessage := map[string]string{ + "baton-docusign: skipping CLM folder group-security entry with an unmapped AccessType": "group_href", + "baton-docusign: skipping CLM folder role-security entry with an unmapped AccessType": "role", + "baton-docusign: skipping CLM folder user-security entry with an unmapped AccessType": "member_href", + } + seenMessage := map[string]bool{} for _, e := range entries { if e.Level != zapcore.DebugLevel { t.Errorf("expected the unmapped-AccessType log to be at Debug, got %v", e.Level) @@ -301,19 +305,23 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t // check (silencing SomethingUnrecognized and logging NoAccess instead) would still // produce exactly 3 Debug entries, passing the assertions above on the exact bug this // test exists to catch. - ctx := e.ContextMap() - if got := ctx["access_type"]; got != "SomethingUnrecognized" { + fields := e.ContextMap() + if got := fields["access_type"]; got != "SomethingUnrecognized" { t.Errorf("expected the logged entry's access_type to be %q, got %q", "SomethingUnrecognized", got) } - for _, key := range distinguishingFields { - if _, ok := ctx[key]; ok { - seenDistinguishingField[key] = true - } + wantField, ok := wantFieldForMessage[e.Message] + if !ok { + t.Errorf("unexpected log message %q", e.Message) + continue + } + seenMessage[e.Message] = true + if _, ok := fields[wantField]; !ok { + t.Errorf("expected message %q to carry the %q field, got fields %v", e.Message, wantField, fields) } } - for _, key := range distinguishingFields { - if !seenDistinguishingField[key] { - t.Errorf("expected one log entry carrying the %q field (the branch that never fired, or fired under the wrong field name)", key) + for msg := range wantFieldForMessage { + if !seenMessage[msg] { + t.Errorf("expected one log entry with message %q (the branch that never fired)", msg) } } } From 241ad3d36ac31750b235c092c205604495c4bc95 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 14:50:17 -0300 Subject: [PATCH 16/54] refactor: dedupe folder-security skip logging into one helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups/Roles/Users each had a near-verbatim 8-line switch differing only in the noun and one field name — collapsed into logSkippedFolderSecurityEntry. Also fixes the Custom branch missing the access_type field that every other skip line carries, and rescopes its test to the same access_type-filtered query as its sibling test instead of unscoped logs.All(). --- pkg/connector/clm_folders.go | 52 +++++++++++++++++-------------- pkg/connector/clm_folders_test.go | 8 ++++- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index fe6e4954..c7bb3379 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -141,14 +141,8 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Groups { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - switch { - case entry.AccessType == client.ClmAccessTypeCustom: - ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder group-security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", - zap.String("folder_id", folderResource.Id.Resource), zap.String("group_href", entry.Href)) - case !clmIsBenignUnmappedAccessType(entry.AccessType): - ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder group-security entry with an unmapped AccessType", - zap.String("folder_id", folderResource.Id.Resource), zap.String("group_href", entry.Href), zap.String("access_type", entry.AccessType)) - } + logSkippedFolderSecurityEntry(ctx, "group", entry.AccessType, + zap.String("folder_id", folderResource.Id.Resource), zap.String("group_href", entry.Href)) continue } principalID := &v2.ResourceId{ResourceType: clmGroupResourceType.Id, Resource: clmIDFromHref(entry.Href)} @@ -164,14 +158,8 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Roles { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - switch { - case entry.AccessType == client.ClmAccessTypeCustom: - ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", - zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item)) - case !clmIsBenignUnmappedAccessType(entry.AccessType): - ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry with an unmapped AccessType", - zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item), zap.String("access_type", entry.AccessType)) - } + logSkippedFolderSecurityEntry(ctx, "role", entry.AccessType, + zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item)) continue } if !clmIsKnownRole(entry.Item) { @@ -189,14 +177,8 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Users { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - switch { - case entry.AccessType == client.ClmAccessTypeCustom: - ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder user-security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", - zap.String("folder_id", folderResource.Id.Resource), zap.String("member_href", entry.Href)) - case !clmIsBenignUnmappedAccessType(entry.AccessType): - ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder user-security entry with an unmapped AccessType", - zap.String("folder_id", folderResource.Id.Resource), zap.String("member_href", entry.Href), zap.String("access_type", entry.AccessType)) - } + logSkippedFolderSecurityEntry(ctx, "user", entry.AccessType, + zap.String("folder_id", folderResource.Id.Resource), zap.String("member_href", entry.Href)) continue } principalID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: clmIDFromHref(entry.Href)} @@ -206,6 +188,28 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour return grants, &rs.SyncOpResults{Annotations: annos}, nil } +// logSkippedFolderSecurityEntry logs the one Debug line for a folder-security entry +// whose AccessType didn't map to a grantable tier — shared by the Groups/Roles/Users +// branches of Grants, which differ only in kind ("group"/"role"/"user") and the +// caller-supplied fields identifying the entry. Custom gets its own message, since +// unlike NoAccess/InheritFromParentFolder (clmIsBenignUnmappedAccessType) it's a real, +// active grant this connector can't represent — fully silencing it would hide an actual +// access-visibility gap. Both branches carry access_type so either case is findable by +// the same structured-log query as every other skip line in this file. +func logSkippedFolderSecurityEntry(ctx context.Context, kind, accessType string, fields ...zap.Field) { + fields = append(fields, zap.String("access_type", accessType)) + switch { + case accessType == client.ClmAccessTypeCustom: + ctxzap.Extract(ctx).Debug( + fmt.Sprintf("baton-docusign: skipping CLM folder %s-security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", kind), + fields...) + case !clmIsBenignUnmappedAccessType(accessType): + ctxzap.Extract(ctx).Debug( + fmt.Sprintf("baton-docusign: skipping CLM folder %s-security entry with an unmapped AccessType", kind), + fields...) + } +} + // Grant sets a folder-security entry for the principal at the entitlement's tier. // Read-before-write: fetches the folder's current complete security state, modifies // only the one entry belonging to this principal (in whichever of Groups/Roles/Users diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 9de7f9d1..dd954ca6 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -361,7 +361,10 @@ func TestClmFolderBuilder_Grants_LogsDistinctlyForCustomAccessType(t *testing.T) t.Fatalf("expected both entries to be skipped, got %d grants: %+v", len(grants), grants) } - entries := logs.All() + // Scoped to access_type-carrying entries only, like the sibling test above, so + // unrelated log traffic from the HTTP/cache layer underneath GetFolder can't leak + // into the count. + entries := logs.FilterFieldKey("access_type").All() if len(entries) != 1 { t.Fatalf("expected exactly 1 log entry (Custom; NoAccess should stay silent), got %d: %+v", len(entries), entries) } @@ -371,6 +374,9 @@ func TestClmFolderBuilder_Grants_LogsDistinctlyForCustomAccessType(t *testing.T) if !strings.Contains(entries[0].Message, "Custom") { t.Errorf("expected the log message to distinctly mention Custom, got %q", entries[0].Message) } + if got := entries[0].ContextMap()["access_type"]; got != client.ClmAccessTypeCustom { + t.Errorf("expected access_type field to be %q, got %q", client.ClmAccessTypeCustom, got) + } } func TestClmFolderBuilder_GrantAndRevoke_Idempotent(t *testing.T) { From b48a9d8f643dc435cb73b324b2c3a417c6c040d9 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 15:02:23 -0300 Subject: [PATCH 17/54] fix: add missing access_type to unrecognized-role skip log; harden sample-branch Grant test The unrecognized-role skip line was the one skip line in clm_folders.go without an access_type field, contradicting logSkippedFolderSecurityEntry's own doc claim about a single consistent structured-log query. TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch had the same byte-identical-fallback gap just fixed on the group builder's equivalent test: srv.GroupHref/MemberHref build the same shape the fallback derivation would from the same discovered base URL, so the test passed even with clmPreferredHref's sample-preference loop deleted (verified via mutation test). Re-seeds folder-contracts' samples with an alternate host to make the two branches actually distinguishable. --- pkg/connector/clm_folders.go | 2 +- pkg/connector/clm_folders_test.go | 44 ++++++++++++++++++++----------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index c7bb3379..18e771d1 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -167,7 +167,7 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour // name outside that set has no synced principal to grant against. Skip // rather than emit a grant to a dangling/unsynced resource. ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry for an unrecognized role", - zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item)) + zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item), zap.String("access_type", entry.AccessType)) continue } principalID := &v2.ResourceId{ResourceType: clmRoleResourceType.Id, Resource: entry.Item} diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index dd954ca6..288b9b5a 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -759,31 +759,42 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te 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) - 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) - folderResource, err := rs.NewResource("Contracts", clmFolderResourceType, "folder-contracts") if err != nil { t.Fatalf("NewResource: %v", err) } + // srv.GroupHref/srv.MemberHref build the exact same shape client.GroupHref/ + // client.MemberHref's fallback derivation would, from the same discovered base + // URL — so folder-contracts' seeded samples (group-legal, member-bob) can't + // actually distinguish "derived from a sample" from "fell back to the discovered + // base URL" unless a sample carries a host the fallback can't produce. Re-seeding + // with an alternate host makes the two branches observably different. + const sampleHost = "https://other.example.com" + groupSampleHref := fmt.Sprintf("%s/v2/%s/groups/group-legal", sampleHost, clmtest.AccountID) + memberSampleHref := fmt.Sprintf("%s/v2/%s/members/member-bob", sampleHost, clmtest.AccountID) + if _, err := c.PatchFolderSecurity(ctx, "folder-contracts", client.ClmFolderSecurityWrite{ + Groups: []client.ClmGroupSecurityEntry{{AccessType: client.ClmAccessTypeViewEdit, Href: groupSampleHref}}, + Users: []client.ClmUserSecurityEntry{{AccessType: client.ClmAccessTypeView, Href: memberSampleHref}}, + }); err != nil { + t.Fatalf("PatchFolderSecurity (re-seed with alternate host): %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. + // group-ops is not among folder-contracts' existing entries, so clmPreferredHref + // must derive group-ops' Href from the group-legal sample 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) } + wantHref, err := clmHrefWithID(groupSampleHref, "group-ops") + if err != nil { + t.Fatalf("clmHrefWithID: %v", err) + } 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 { if groups[i].Href == wantHref { @@ -800,16 +811,19 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te }) 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. + // member-dave is not folder-contracts' existing member entry, so + // clmPreferredHref must derive member-dave's Href from the member-bob 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) } + wantHref, err := clmHrefWithID(memberSampleHref, "member-dave") + if err != nil { + t.Fatalf("clmHrefWithID: %v", err) + } 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 { if users[i].Href == wantHref { From fa73fbcf154fc1b587413610535c6c8c75f652ed Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 15:39:07 -0300 Subject: [PATCH 18/54] perf: stop building a per-kind message via fmt.Sprintf on every folder-security skip fmt.Sprintf and the fields append() ran unconditionally before zap's Debug-level check, on every skipped entry, every sync (confirmed via hypothesis investigation, 5/5). Switches to one constant message per case plus a principal_kind field, removing the Sprintf cost entirely. Updates the test that keyed on the old per-kind message text to bind principal_kind -> distinguishing field instead. --- pkg/connector/clm_folders.go | 34 ++++++++++++++++--------- pkg/connector/clm_folders_test.go | 41 ++++++++++++++++++------------- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 18e771d1..25638b13 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -18,6 +18,14 @@ import ( var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmFolderBuilder)(nil) +// The three folder-security principal kinds, as passed to logSkippedFolderSecurityEntry +// and (via the principal_kind field) queryable in logs. +const ( + clmFolderPrincipalKindGroup = "group" + clmFolderPrincipalKindRole = "role" + clmFolderPrincipalKindUser = "user" +) + // The 5 grantable Baton entitlement slugs for CLM folder security, in ascending order // of access. const ( @@ -141,7 +149,7 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Groups { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - logSkippedFolderSecurityEntry(ctx, "group", entry.AccessType, + logSkippedFolderSecurityEntry(ctx, clmFolderPrincipalKindGroup, entry.AccessType, zap.String("folder_id", folderResource.Id.Resource), zap.String("group_href", entry.Href)) continue } @@ -158,7 +166,7 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Roles { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - logSkippedFolderSecurityEntry(ctx, "role", entry.AccessType, + logSkippedFolderSecurityEntry(ctx, clmFolderPrincipalKindRole, entry.AccessType, zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item)) continue } @@ -177,7 +185,7 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour for _, entry := range folder.Security.Users { slug, ok := clmSlugForAccessType(entry.AccessType) if !ok { - logSkippedFolderSecurityEntry(ctx, "user", entry.AccessType, + logSkippedFolderSecurityEntry(ctx, clmFolderPrincipalKindUser, entry.AccessType, zap.String("folder_id", folderResource.Id.Resource), zap.String("member_href", entry.Href)) continue } @@ -190,22 +198,24 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour // logSkippedFolderSecurityEntry logs the one Debug line for a folder-security entry // whose AccessType didn't map to a grantable tier — shared by the Groups/Roles/Users -// branches of Grants, which differ only in kind ("group"/"role"/"user") and the -// caller-supplied fields identifying the entry. Custom gets its own message, since -// unlike NoAccess/InheritFromParentFolder (clmIsBenignUnmappedAccessType) it's a real, -// active grant this connector can't represent — fully silencing it would hide an actual -// access-visibility gap. Both branches carry access_type so either case is findable by -// the same structured-log query as every other skip line in this file. +// branches of Grants, which differ only in kind ("group"/"role"/"user", carried as a +// field rather than interpolated into the message, so both messages stay constant +// strings — no per-call fmt.Sprintf) and the caller-supplied fields identifying the +// entry. Custom gets its own message, since unlike NoAccess/InheritFromParentFolder +// (clmIsBenignUnmappedAccessType) it's a real, active grant this connector can't +// represent — fully silencing it would hide an actual access-visibility gap. Both +// branches carry access_type so either case is findable by the same structured-log +// query as every other skip line in this file. func logSkippedFolderSecurityEntry(ctx context.Context, kind, accessType string, fields ...zap.Field) { - fields = append(fields, zap.String("access_type", accessType)) + fields = append(fields, zap.String("principal_kind", kind), zap.String("access_type", accessType)) switch { case accessType == client.ClmAccessTypeCustom: ctxzap.Extract(ctx).Debug( - fmt.Sprintf("baton-docusign: skipping CLM folder %s-security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", kind), + "baton-docusign: skipping CLM folder security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", fields...) case !clmIsBenignUnmappedAccessType(accessType): ctxzap.Extract(ctx).Debug( - fmt.Sprintf("baton-docusign: skipping CLM folder %s-security entry with an unmapped AccessType", kind), + "baton-docusign: skipping CLM folder security entry with an unmapped AccessType", fields...) } } diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 288b9b5a..fd0e3ac8 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -287,20 +287,26 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t if len(entries) != 3 { t.Fatalf("expected exactly 3 log entries (one per Groups/Roles/Users branch, for the genuinely unrecognized AccessType only), got %d: %+v", len(entries), entries) } - // Each branch's message names its own distinguishing field alongside access_type — - // binding message to field (not just checking each field appears SOMEWHERE across - // the 3 entries) catches a copy-paste slip that swaps them between branches, e.g. - // the Users loop emitting group_href under its own "user-security" message. - wantFieldForMessage := map[string]string{ - "baton-docusign: skipping CLM folder group-security entry with an unmapped AccessType": "group_href", - "baton-docusign: skipping CLM folder role-security entry with an unmapped AccessType": "role", - "baton-docusign: skipping CLM folder user-security entry with an unmapped AccessType": "member_href", - } - seenMessage := map[string]bool{} + // The three branches share one constant message (no per-kind fmt.Sprintf — see + // logSkippedFolderSecurityEntry's doc) and instead distinguish themselves via a + // principal_kind field. Binding principal_kind to its distinguishing field (not just + // checking each field appears SOMEWHERE across the 3 entries) catches a copy-paste + // slip that swaps them between branches, e.g. the Users loop emitting group_href + // under principal_kind "user". + const wantMessage = "baton-docusign: skipping CLM folder security entry with an unmapped AccessType" + wantFieldForKind := map[string]string{ + clmFolderPrincipalKindGroup: "group_href", + clmFolderPrincipalKindRole: "role", + clmFolderPrincipalKindUser: "member_href", + } + seenKind := map[string]bool{} for _, e := range entries { if e.Level != zapcore.DebugLevel { t.Errorf("expected the unmapped-AccessType log to be at Debug, got %v", e.Level) } + if e.Message != wantMessage { + t.Errorf("expected message %q, got %q", wantMessage, e.Message) + } // Pins WHICH entry logged, not just how many: an inverted clmIsBenignUnmappedAccessType // check (silencing SomethingUnrecognized and logging NoAccess instead) would still // produce exactly 3 Debug entries, passing the assertions above on the exact bug this @@ -309,19 +315,20 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t if got := fields["access_type"]; got != "SomethingUnrecognized" { t.Errorf("expected the logged entry's access_type to be %q, got %q", "SomethingUnrecognized", got) } - wantField, ok := wantFieldForMessage[e.Message] + kind, _ := fields["principal_kind"].(string) + wantField, ok := wantFieldForKind[kind] if !ok { - t.Errorf("unexpected log message %q", e.Message) + t.Errorf("unexpected principal_kind %q", kind) continue } - seenMessage[e.Message] = true + seenKind[kind] = true if _, ok := fields[wantField]; !ok { - t.Errorf("expected message %q to carry the %q field, got fields %v", e.Message, wantField, fields) + t.Errorf("expected principal_kind %q to carry the %q field, got fields %v", kind, wantField, fields) } } - for msg := range wantFieldForMessage { - if !seenMessage[msg] { - t.Errorf("expected one log entry with message %q (the branch that never fired)", msg) + for kind := range wantFieldForKind { + if !seenKind[kind] { + t.Errorf("expected one log entry with principal_kind %q (the branch that never fired)", kind) } } } From 03901dc2021e743f90c8ad275ee61edadbc4f777 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 15:51:01 -0300 Subject: [PATCH 19/54] fix: close two gaps left by the previous skip-log pass - unrecognized-role skip line still had no principal_kind field, unlike every other skip line that field exists to make queryable. - logSkippedFolderSecurityEntry's fields append() ran before deciding whether to log at all, so the common benign case (NoAccess/Inherit, on every folder of every sync) still allocated for a line never written. Early-return before the append, same spirit as the Sprintf removal. --- pkg/connector/clm_folders.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 25638b13..c651282f 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -175,7 +175,8 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour // name outside that set has no synced principal to grant against. Skip // rather than emit a grant to a dangling/unsynced resource. ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM folder role-security entry for an unrecognized role", - zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item), zap.String("access_type", entry.AccessType)) + zap.String("folder_id", folderResource.Id.Resource), zap.String("role", entry.Item), zap.String("access_type", entry.AccessType), + zap.String("principal_kind", clmFolderPrincipalKindRole)) continue } principalID := &v2.ResourceId{ResourceType: clmRoleResourceType.Id, Resource: entry.Item} @@ -207,17 +208,22 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour // branches carry access_type so either case is findable by the same structured-log // query as every other skip line in this file. func logSkippedFolderSecurityEntry(ctx context.Context, kind, accessType string, fields ...zap.Field) { + if accessType != client.ClmAccessTypeCustom && clmIsBenignUnmappedAccessType(accessType) { + // The common steady-state case (NoAccess/InheritFromParentFolder, on every + // folder of every sync) — return before building fields, not just before + // logging, so it stays allocation-free like the Sprintf removal above. + return + } fields = append(fields, zap.String("principal_kind", kind), zap.String("access_type", accessType)) - switch { - case accessType == client.ClmAccessTypeCustom: + if accessType == client.ClmAccessTypeCustom { ctxzap.Extract(ctx).Debug( "baton-docusign: skipping CLM folder security entry with an unrepresentable Custom AccessType — a real, active grant C1 won't see", fields...) - case !clmIsBenignUnmappedAccessType(accessType): - ctxzap.Extract(ctx).Debug( - "baton-docusign: skipping CLM folder security entry with an unmapped AccessType", - fields...) + return } + ctxzap.Extract(ctx).Debug( + "baton-docusign: skipping CLM folder security entry with an unmapped AccessType", + fields...) } // Grant sets a folder-security entry for the principal at the entitlement's tier. From 6bbf8a039a39ac06b04d084dad5414dc7a5afb93 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 12:46:52 -0300 Subject: [PATCH 20/54] fix: resolve duplicate sample-branch re-seed logic from rebase conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #63 and this branch independently fixed the same tautological-test finding on TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch (a byte-identical sample-derived vs. fallback-derived Href) via two different mechanisms — Server.SetFolderGroupSecurityHref/ SetFolderUserSecurityHref (surgical Href override) vs. a full PatchFolderSecurity re-seed — which the rebase concatenated into one function body (duplicate sampleHost/wantHref declarations, and the re-seed silently overriding the override). Keep the surgical version: it preserves folder-contracts' original seed shape (both group entries) instead of replacing it. --- pkg/connector/clm_folders_test.go | 44 +++++++++++-------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index fd0e3ac8..58a1e163 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -766,42 +766,31 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te 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) + 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) + folderResource, err := rs.NewResource("Contracts", clmFolderResourceType, "folder-contracts") if err != nil { t.Fatalf("NewResource: %v", err) } - // srv.GroupHref/srv.MemberHref build the exact same shape client.GroupHref/ - // client.MemberHref's fallback derivation would, from the same discovered base - // URL — so folder-contracts' seeded samples (group-legal, member-bob) can't - // actually distinguish "derived from a sample" from "fell back to the discovered - // base URL" unless a sample carries a host the fallback can't produce. Re-seeding - // with an alternate host makes the two branches observably different. - const sampleHost = "https://other.example.com" - groupSampleHref := fmt.Sprintf("%s/v2/%s/groups/group-legal", sampleHost, clmtest.AccountID) - memberSampleHref := fmt.Sprintf("%s/v2/%s/members/member-bob", sampleHost, clmtest.AccountID) - if _, err := c.PatchFolderSecurity(ctx, "folder-contracts", client.ClmFolderSecurityWrite{ - Groups: []client.ClmGroupSecurityEntry{{AccessType: client.ClmAccessTypeViewEdit, Href: groupSampleHref}}, - Users: []client.ClmUserSecurityEntry{{AccessType: client.ClmAccessTypeView, Href: memberSampleHref}}, - }); err != nil { - t.Fatalf("PatchFolderSecurity (re-seed with alternate host): %v", err) - } - t.Run("clm_group principal", func(t *testing.T) { - // group-ops is not among folder-contracts' existing entries, so clmPreferredHref - // must derive group-ops' Href from the group-legal sample via clmHrefWithID, not - // just echo a pre-existing entry. + // 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) } - wantHref, err := clmHrefWithID(groupSampleHref, "group-ops") - if err != nil { - t.Fatalf("clmHrefWithID: %v", err) - } 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 { if groups[i].Href == wantHref { @@ -818,19 +807,16 @@ func TestClmFolderBuilder_Grant_SurvivesIdentityOnlyPrincipal_SampleBranch(t *te }) t.Run("clm_member principal", func(t *testing.T) { - // member-dave is not folder-contracts' existing member entry, so - // clmPreferredHref must derive member-dave's Href from the member-bob sample. + // 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) } - wantHref, err := clmHrefWithID(memberSampleHref, "member-dave") - if err != nil { - t.Fatalf("clmHrefWithID: %v", err) - } 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 { if users[i].Href == wantHref { From 31aae8a7dc86a08c9ef8db7899d0d0a5d592690a Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 15:56:56 -0300 Subject: [PATCH 21/54] fix: apply the discovery-source two-conjunct gate to the other 4 CLM builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clm_roles.go already requires client.IsClmDiscoveryError(err) in addition to isOptInFeatureUnavailableError(err) before soft-skipping a sync, since the code alone can come from either CLM account discovery or a later per-resource data call. clm_folders/clm_groups/clm_members/clm_permission_sets each call ensureClmReady first (via SearchFolders/ListGroups/ListMembers/ ListPermissionSets), whose own data-call failures are never wrapped as clmDiscoveryError, so the same conjunct applies cleanly here too — closing the gap flagged in PR review (CXH-2209). Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_folders.go | 6 +++++- pkg/connector/clm_groups.go | 6 +++++- pkg/connector/clm_members.go | 6 +++++- pkg/connector/clm_permission_sets.go | 6 +++++- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index c651282f..0c680445 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -82,7 +82,11 @@ func (f *clmFolderBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + // Requires the error to come from CLM discovery itself, not just a tolerated + // code — see client.IsClmDiscoveryError's doc and clm_roles.go's List() for why + // the code alone isn't enough to distinguish "no CLM subscription" from a + // same-coded failure on this resource's own SearchFolders call. + if attr.PageToken.Token == "" && client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 318e0302..a8aa7764 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -53,7 +53,11 @@ func (g *clmGroupBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.Sy PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + // Requires the error to come from CLM discovery itself, not just a tolerated + // code — see client.IsClmDiscoveryError's doc and clm_roles.go's List() for why + // the code alone isn't enough to distinguish "no CLM subscription" from a + // same-coded failure on this resource's own ListGroups call. + if attr.PageToken.Token == "" && client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 74352724..c353e220 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -35,7 +35,11 @@ func (b *clmMemberBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + // Requires the error to come from CLM discovery itself, not just a tolerated + // code — see client.IsClmDiscoveryError's doc and clm_roles.go's List() for why + // the code alone isn't enough to distinguish "no CLM subscription" from a + // same-coded failure on this resource's own ListMembers call. + if attr.PageToken.Token == "" && client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_permission_sets.go b/pkg/connector/clm_permission_sets.go index 04b81b66..e002805d 100644 --- a/pkg/connector/clm_permission_sets.go +++ b/pkg/connector/clm_permission_sets.go @@ -42,7 +42,11 @@ func (b *clmPermissionSetBuilder) List(ctx context.Context, _ *v2.ResourceId, at PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { + // Requires the error to come from CLM discovery itself, not just a tolerated + // code — see client.IsClmDiscoveryError's doc and clm_roles.go's List() for why + // the code alone isn't enough to distinguish "no CLM subscription" from a + // same-coded failure on this resource's own ListPermissionSets call. + if attr.PageToken.Token == "" && client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } From e988e24f007fc8c42cb8be6770cbd51162fdc441 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 16:25:11 -0300 Subject: [PATCH 22/54] revert: pull back the discovery-source gate from the 4 data-backed CLM builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e85ccf9 re-applied exactly the narrowing eca81a7 deliberately reverted earlier in this PR, for the same reason that revert gave: this repo has no live CLM tenant to confirm whether DocuSign rejects a non-CLM account at discovery or at the per-resource data call. Unlike clm_role (which has no data call of its own, so IsClmDiscoveryError can only ever reject a non-discovery failure that already breaks every other resource type too), clm_folder/clm_group/clm_member/clm_permission_set each make a real data call after discovery succeeds. Requiring IsClmDiscoveryError there turns a same-coded rejection from that data call — e.g. a token that loses scope mid-sync — into a hard List() error, which baton-sdk's parallel syncer treats as fatal for the ENTIRE sync (all resource types, not just the one CLM builder), not a per-resource-type skip. That's a worse regression than the narrower gap being closed. Restores isOptInFeatureUnavailableError(err) alone for these four builders; clm_role's own two-conjunct gate is untouched. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_folders.go | 6 +----- pkg/connector/clm_groups.go | 6 +----- pkg/connector/clm_members.go | 6 +----- pkg/connector/clm_permission_sets.go | 6 +----- 4 files changed, 4 insertions(+), 20 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 0c680445..c651282f 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -82,11 +82,7 @@ func (f *clmFolderBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - // Requires the error to come from CLM discovery itself, not just a tolerated - // code — see client.IsClmDiscoveryError's doc and clm_roles.go's List() for why - // the code alone isn't enough to distinguish "no CLM subscription" from a - // same-coded failure on this resource's own SearchFolders call. - if attr.PageToken.Token == "" && client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { + if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index a8aa7764..318e0302 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -53,11 +53,7 @@ func (g *clmGroupBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.Sy PageToken: pageToken, }) if err != nil { - // Requires the error to come from CLM discovery itself, not just a tolerated - // code — see client.IsClmDiscoveryError's doc and clm_roles.go's List() for why - // the code alone isn't enough to distinguish "no CLM subscription" from a - // same-coded failure on this resource's own ListGroups call. - if attr.PageToken.Token == "" && client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { + if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index c353e220..74352724 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -35,11 +35,7 @@ func (b *clmMemberBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - // Requires the error to come from CLM discovery itself, not just a tolerated - // code — see client.IsClmDiscoveryError's doc and clm_roles.go's List() for why - // the code alone isn't enough to distinguish "no CLM subscription" from a - // same-coded failure on this resource's own ListMembers call. - if attr.PageToken.Token == "" && client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { + if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } diff --git a/pkg/connector/clm_permission_sets.go b/pkg/connector/clm_permission_sets.go index e002805d..04b81b66 100644 --- a/pkg/connector/clm_permission_sets.go +++ b/pkg/connector/clm_permission_sets.go @@ -42,11 +42,7 @@ func (b *clmPermissionSetBuilder) List(ctx context.Context, _ *v2.ResourceId, at PageToken: pageToken, }) if err != nil { - // Requires the error to come from CLM discovery itself, not just a tolerated - // code — see client.IsClmDiscoveryError's doc and clm_roles.go's List() for why - // the code alone isn't enough to distinguish "no CLM subscription" from a - // same-coded failure on this resource's own ListPermissionSets call. - if attr.PageToken.Token == "" && client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { + if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } From 940dd66b86cc4e3f722cf17f9eff4a1cdd89f2aa Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 16:46:12 -0300 Subject: [PATCH 23/54] fix: fail loudly instead of silently skipping when an opt-in feature is unavailable Every resource type that used isOptInFeatureUnavailableError to tolerate "feature not available" errors (clm_role, clm_folder, clm_group, clm_member, clm_permission_set, signing_group) carries OptInRequired: C1 excludes them from a customer's sync by default, and List() only ever runs once a customer has explicitly opted in. C1's opt-in toggle has no upstream check against DocuSign, so a customer can enable one of these without actually having the subscription/scopes it needs. Per review feedback (luisina-santos): that's a real misconfiguration, not an expected/transient state, and every connector should fail its sync when it lacks sufficient permission or capability to sync an opted-in resource - silently succeeding with zero resources just hides the problem instead of surfacing it. This also fully closes the discovery-vs-data-call ambiguity debated earlier in this PR (mateoHernandez123's finding, the bot's regression finding) - there's no longer a tolerance branch left to gate. Removes the now-dead isOptInFeatureUnavailableError helper and its test. client.IsClmDiscoveryError is left in pkg/client (still tested there, still documents a real distinction in the client's error taxonomy) even though it has no remaining connector-layer caller - deleting it is a separate, client-layer cleanup, not part of this behavior change. Co-Authored-By: Claude Sonnet 5 --- README.md | 20 +++--- docs/connector.mdx | 2 +- pkg/connector/clm_folders.go | 4 -- pkg/connector/clm_folders_test.go | 13 ++-- pkg/connector/clm_groups.go | 6 -- pkg/connector/clm_groups_test.go | 13 ++-- pkg/connector/clm_members.go | 6 -- pkg/connector/clm_members_test.go | 23 +++---- pkg/connector/clm_permission_sets.go | 6 -- pkg/connector/clm_permission_sets_test.go | 13 ++-- pkg/connector/clm_roles.go | 50 +++------------ pkg/connector/clm_roles_test.go | 76 +++-------------------- pkg/connector/helper.go | 45 -------------- pkg/connector/helper_test.go | 31 +-------- pkg/connector/singing_groups.go | 4 -- 15 files changed, 57 insertions(+), 255 deletions(-) diff --git a/README.md b/README.md index 196ab938..a81f4b04 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ Copy the `code` parameter value and paste it when prompted. Save the refresh tok DocuSign CLM (Contract Lifecycle Management) is a separate DocuSign product from eSignature, with its own API and a separate production subscription. CLM members, roles, -groups, folders, folder security, and permission sets sync alongside the standard -eSignature resources, with no config flag to enable — accounts that don't have CLM simply -sync no CLM resources. +groups, folders, folder security, and permission sets are opt-in: they don't sync by +default, and a customer must explicitly enable each CLM resource type in C1's sync +configuration. Requirements: @@ -115,11 +115,15 @@ Requirements: also be granted the CLM API scopes on ConductorOne's platform side before any CLM data will sync. Contact ConductorOne if no CLM data appears in this mode. -The 5 CLM resource types are always registered and visible to C1 — this avoids a C1 sync -engine treating CLM resources as deleted if they stop appearing (see -[CHANGE_TYPES.md](CHANGE_TYPES.md) if you're touching this). Without the CLM OAuth scopes -(or without a CLM subscription on the account), each CLM resource type's sync is skipped -gracefully rather than erroring the whole sync. +The 5 CLM resource types are always registered and visible to C1, but each carries +`OptInRequired` — C1 excludes them from a customer's sync by default, and they only run +once a customer explicitly opts in (see [CHANGE_TYPES.md](CHANGE_TYPES.md) if you're +touching this). C1's opt-in toggle does not validate the underlying DocuSign account +first, so a customer can enable CLM sync without actually having the subscription or +scopes above. If that happens, the sync fails loudly rather than silently succeeding +with zero CLM resources — an account that opted in but can't reach CLM is treated as a +misconfiguration to fix (disable the resource type, or activate the CLM feature), not an +expected state to tolerate. CLM permission sets sync for visibility only — DocuSign's CLM API has no endpoint to assign or unassign a permission set, so they cannot be granted or revoked through this diff --git a/docs/connector.mdx b/docs/connector.mdx index f9568456..cb3468af 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -28,7 +28,7 @@ Every Docusign account must be assigned at least one permission profile. If all *By default, signing groups are not synced. Enable the **Include Signing Groups** setting to sync signing groups, if your account has the feature enabled. -**DocuSign CLM (Contract Lifecycle Management) is a separate, separately-licensed DocuSign product. CLM resources sync automatically if the account has a DocuSign CLM production subscription and the credential has been granted the OAuth scopes CLM needs; accounts without CLM simply sync no CLM resources. CLM permission sets sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign one. +**DocuSign CLM (Contract Lifecycle Management) is a separate, separately-licensed DocuSign product. CLM resources are opt-in — enable each CLM resource type in your sync configuration to turn them on. Once enabled, your DocuSign account must have a CLM production subscription and the credential must have been granted the OAuth scopes CLM needs; enabling a CLM resource type without them will fail the sync rather than silently sync no data, since ConductorOne doesn't validate the underlying subscription before letting you opt in. CLM permission sets sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign one. If you use **OAuth Authentication** (the default, managed method), syncing CLM data requires ConductorOne's managed OAuth app to be granted the CLM API scopes on the platform side. If CLM data doesn't appear after setup, contact ConductorOne. This doesn't apply to **Custom App (Demo Environment)**, where the connector requests the CLM scopes directly using your own DocuSign app credentials. diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index c651282f..8c955c0b 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -82,10 +82,6 @@ func (f *clmFolderBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_folder sync", zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil - } return nil, nil, err } diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 58a1e163..a9b37bcb 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -63,22 +63,19 @@ func TestClmAccessTypeForSlug_RoundTrips(t *testing.T) { // --- Integration tests against the clmtest mock server --- -func TestClmFolderBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { +func TestClmFolderBuilder_List_FailsWhenClmUnavailable(t *testing.T) { // See clm_members_test.go's identical test for the full rationale. s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") b := newClmFolderBuilder(badClient) ctx := context.Background() - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) - if err != nil { - t.Fatalf("expected List to tolerate an unavailable CLM account and skip gracefully, got error: %v", err) + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) + if err == nil { + t.Fatal("expected List to fail when CLM is unavailable, got nil error") } if len(resources) != 0 { - t.Errorf("expected zero resources when CLM is unavailable, got %d", len(resources)) - } - if res == nil || res.NextPageToken != "" { - t.Errorf("expected an empty (non-paginating) result, got %+v", res) + t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) } } diff --git a/pkg/connector/clm_groups.go b/pkg/connector/clm_groups.go index 318e0302..1bf6c087 100644 --- a/pkg/connector/clm_groups.go +++ b/pkg/connector/clm_groups.go @@ -10,8 +10,6 @@ import ( "github.com/conductorone/baton-sdk/pkg/connectorbuilder" "github.com/conductorone/baton-sdk/pkg/types/grant" 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" ) @@ -53,10 +51,6 @@ func (g *clmGroupBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.Sy PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_group sync", zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil - } return nil, nil, err } diff --git a/pkg/connector/clm_groups_test.go b/pkg/connector/clm_groups_test.go index 34c6630f..423f444e 100644 --- a/pkg/connector/clm_groups_test.go +++ b/pkg/connector/clm_groups_test.go @@ -37,22 +37,19 @@ func TestClmGroupBuilder_List(t *testing.T) { } } -func TestClmGroupBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { +func TestClmGroupBuilder_List_FailsWhenClmUnavailable(t *testing.T) { // See clm_members_test.go's identical test for the full rationale. s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") b := newClmGroupBuilder(badClient) ctx := context.Background() - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) - if err != nil { - t.Fatalf("expected List to tolerate an unavailable CLM account and skip gracefully, got error: %v", err) + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) + if err == nil { + t.Fatal("expected List to fail when CLM is unavailable, got nil error") } if len(resources) != 0 { - t.Errorf("expected zero resources when CLM is unavailable, got %d", len(resources)) - } - if res == nil || res.NextPageToken != "" { - t.Errorf("expected an empty (non-paginating) result, got %+v", res) + t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) } } diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 74352724..1899219f 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -6,8 +6,6 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - "go.uber.org/zap" ) // clmMemberBuilder syncs CLM Members — CLM's own principal object. Synced as its own @@ -35,10 +33,6 @@ func (b *clmMemberBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_member sync", zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil - } return nil, nil, err } diff --git a/pkg/connector/clm_members_test.go b/pkg/connector/clm_members_test.go index ff578cce..0db9e2c4 100644 --- a/pkg/connector/clm_members_test.go +++ b/pkg/connector/clm_members_test.go @@ -37,26 +37,23 @@ func TestClmMemberBuilder_List_Pagination(t *testing.T) { } } -func TestClmMemberBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { - // Regression test for the wipe-risk fix: clm_member (and the other CLM/signing_group - // builders) is now registered unconditionally in ResourceSyncers() rather than - // gated by a config flag, so an account/token that genuinely can't use CLM must - // have its List() tolerate the resulting auth error and skip gracefully instead of - // failing the whole sync — see isOptInFeatureUnavailableError in helper.go. +func TestClmMemberBuilder_List_FailsWhenClmUnavailable(t *testing.T) { + // clm_member (like every OptInRequired CLM/signing_group resource type) only ever + // syncs once a customer has explicitly opted it in, and C1's opt-in toggle has no + // upstream check against DocuSign — so an account/token that can't use CLM at that + // point is a real misconfiguration, not an expected state. List() must fail loudly + // rather than silently succeed with zero resources — see clm_roles.go's doc comment. s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") b := newClmMemberBuilder(badClient) ctx := context.Background() - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) - if err != nil { - t.Fatalf("expected List to tolerate an unavailable CLM account and skip gracefully, got error: %v", err) + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) + if err == nil { + t.Fatal("expected List to fail when CLM is unavailable, got nil error") } if len(resources) != 0 { - t.Errorf("expected zero resources when CLM is unavailable, got %d", len(resources)) - } - if res == nil || res.NextPageToken != "" { - t.Errorf("expected an empty (non-paginating) result, got %+v", res) + t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) } } diff --git a/pkg/connector/clm_permission_sets.go b/pkg/connector/clm_permission_sets.go index 04b81b66..1c997ed5 100644 --- a/pkg/connector/clm_permission_sets.go +++ b/pkg/connector/clm_permission_sets.go @@ -7,8 +7,6 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/types/entitlement" rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - "go.uber.org/zap" ) // clmPermissionSetAssignedTag mirrors permissionProfileAssignedTag's pattern for the @@ -42,10 +40,6 @@ func (b *clmPermissionSetBuilder) List(ctx context.Context, _ *v2.ResourceId, at PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_permission_set sync", zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil - } return nil, nil, err } diff --git a/pkg/connector/clm_permission_sets_test.go b/pkg/connector/clm_permission_sets_test.go index 09e0fb91..aa484488 100644 --- a/pkg/connector/clm_permission_sets_test.go +++ b/pkg/connector/clm_permission_sets_test.go @@ -10,22 +10,19 @@ import ( rs "github.com/conductorone/baton-sdk/pkg/types/resource" ) -func TestClmPermissionSetBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { +func TestClmPermissionSetBuilder_List_FailsWhenClmUnavailable(t *testing.T) { // See clm_members_test.go's identical test for the full rationale. s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") b := newClmPermissionSetBuilder(badClient) ctx := context.Background() - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) - if err != nil { - t.Fatalf("expected List to tolerate an unavailable CLM account and skip gracefully, got error: %v", err) + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) + if err == nil { + t.Fatal("expected List to fail when CLM is unavailable, got nil error") } if len(resources) != 0 { - t.Errorf("expected zero resources when CLM is unavailable, got %d", len(resources)) - } - if res == nil || res.NextPageToken != "" { - t.Errorf("expected an empty (non-paginating) result, got %+v", res) + t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) } } diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index abb4916a..09f6e03f 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -6,16 +6,13 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - "go.uber.org/zap" ) // clmRoleBuilder syncs the 5 fixed CLM account-level roles (client.ClmRoles). The role // set itself isn't backed by an API call — see resource_types.go for why this resource // type exists — but List() still checks CLM availability via EnsureClmReady before // emitting it, the same discovery check every other CLM builder's real API call runs -// internally; otherwise these 5 roles would sync unconditionally even on an account -// with no CLM subscription, unlike every other CLM resource type. +// internally. type clmRoleBuilder struct { resourceType *v2.ResourceType client *client.Client @@ -29,46 +26,15 @@ func (b *clmRoleBuilder) ResourceType(_ context.Context) *v2.ResourceType { // account. No pagination needed — the set is small and hardcoded, not fetched from the // API — so the availability check always runs (there's no first-page-only gate to // apply, unlike the paginated CLM builders). +// +// clm_role is OptInRequired (resource_types.go), so this List() only ever runs once a +// customer has explicitly enabled it in their sync config — the C1 platform's toggle for +// that has no upstream validation against DocuSign, so a customer can opt in without +// actually having a CLM subscription. When that happens, EnsureClmReady failing here is +// a real misconfiguration, not a transient/expected condition: fail loudly so it's +// visible, rather than silently syncing zero roles indefinitely. func (b *clmRoleBuilder) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { if err := b.client.EnsureClmReady(ctx); err != nil { - // Requires BOTH conditions, narrower than every other CLM builder's tolerance - // (isOptInFeatureUnavailableError alone): - // - // - client.IsClmDiscoveryError(err): EnsureClmReady is the ONLY CLM call - // clm_role's List() ever makes, so an error here that ISN'T from CLM account - // discovery itself can only be eSignature's own ensureInitialized failing (a - // broken/expired token) — a problem that already breaks every other resource - // type too, CLM or not, and should fail this sync loudly rather than be - // silently mistaken for a plain non-CLM account. - // - isOptInFeatureUnavailableError(err): ensureClmInitialized wraps EVERY - // doRequestCommon failure as a clmDiscoveryError, including transient - // infrastructure failures (5xx, rate limits, transport errors — - // codes.Unavailable and friends). IsClmDiscoveryError alone would tolerate - // those too, which every other CLM builder deliberately does NOT (see that - // function's doc) — a discovery-sourced 503 should fail loud, not be - // mistaken for "no CLM subscription". - // - // Residual, accepted risk: the SDK's sync engine runs different resource types' - // List() concurrently (see vendor's pkg/sync/parallel_syncer.go), so two CLM - // builders' near-simultaneous discovery calls could in principle still disagree if - // CLM discovery itself answers inconsistently within one sync — e.g. clm_role sees - // a tolerated discovery failure and skips while clm_folder's later call succeeds - // and emits grants to clm_role/ principals this sync never produced. Not - // solved with a connector-side retry here: ductone/c1's own connector-error - // classification (isNonRetryableCode) already treats isOptInFeatureUnavailableError's - // codes as stable/permanent for a sync's lifetime, specifically because the token - // doesn't change mid-sync — so retrying would fight that platform convention, and - // there's no live CLM tenant to validate a bespoke cross-builder consistency - // mechanism against instead. - // - // This gate only helps when DocuSign rejects CLM at discovery. If some accounts - // are instead rejected only on a later per-resource data call (unconfirmed either - // way), clm_role still emits its 5 roles while the other CLM builders skip — - // clm_role has no data call of its own to check that case with. - if client.IsClmDiscoveryError(err) && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_role sync", zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil - } return nil, nil, err } diff --git a/pkg/connector/clm_roles_test.go b/pkg/connector/clm_roles_test.go index 35d7b672..971168c8 100644 --- a/pkg/connector/clm_roles_test.go +++ b/pkg/connector/clm_roles_test.go @@ -7,7 +7,6 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" "github.com/conductorone/baton-docusign/pkg/client/clmtest" rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "google.golang.org/grpc/status" ) func TestClmRoleBuilder_List(t *testing.T) { @@ -35,78 +34,23 @@ func TestClmRoleBuilder_List(t *testing.T) { } } -func TestClmRoleBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { - // See clm_members_test.go's identical test for the full rationale. Before List() - // gated on EnsureClmReady, clm_roles.go made no API call at all, so this case - // couldn't happen — the 5 fixed roles synced unconditionally even without CLM access. +// TestClmRoleBuilder_List_FailsWhenClmUnavailable is a deliberate design choice, not an +// oversight: clm_role is OptInRequired (resource_types.go), so List() only ever runs +// once a customer has explicitly enabled it in their sync config, and C1's opt-in toggle +// has no upstream check against DocuSign — a customer can enable it without actually +// having a CLM subscription. When EnsureClmReady then fails, that's a real +// misconfiguration (wrong resource enabled, or the feature needs activating), not an +// expected/transient state, so it must fail the sync loudly rather than silently +// succeed with zero roles indefinitely. +func TestClmRoleBuilder_List_FailsWhenClmUnavailable(t *testing.T) { s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") b := newClmRoleBuilder(badClient) ctx := context.Background() - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{}) - if err != nil { - t.Fatalf("expected List to tolerate an unavailable CLM account and skip gracefully, got error: %v", err) - } - if len(resources) != 0 { - t.Errorf("expected zero resources when CLM is unavailable, got %d", len(resources)) - } - if res == nil { - t.Errorf("expected a non-nil SyncOpResults, got %+v", res) - } -} - -// TestClmRoleBuilder_List_FailsLoudlyOnTransientDiscoveryFailure is a regression test: -// ensureClmInitialized wraps EVERY discovery-call failure as a clmDiscoveryError, -// including transient infrastructure failures (5xx, rate limits), not just the 4 codes -// isOptInFeatureUnavailableError tolerates. Gating solely on -// client.IsClmDiscoveryError(err) — without also requiring -// isOptInFeatureUnavailableError(err) — would make clm_role silently skip on a 503 that -// every other CLM builder correctly treats as a loud failure. -func TestClmRoleBuilder_List_FailsLoudlyOnTransientDiscoveryFailure(t *testing.T) { - s, c := clmtest.NewServer(t) - s.ForceClmDiscoveryStatus(503) - b := newClmRoleBuilder(c) - ctx := context.Background() - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{}) if err == nil { - t.Fatal("expected a transient discovery failure (503) to fail loudly, got nil error") - } - if len(resources) != 0 { - t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) - } -} - -// TestClmRoleBuilder_List_FailsLoudlyOnNonDiscoveryTolerableError is a regression test -// for the OTHER half of List()'s "requires both conditions" gate: a tolerated code -// (Unauthenticated here) that comes from eSignature's own ensureInitialized, not CLM -// account discovery, must still fail loud. Without the client.IsClmDiscoveryError(err) -// conjunct, this would be silently mistaken for "no CLM subscription" — the case -// clm_roles.go's own doc comment names first. Deleting that conjunct alone would leave -// TestClmRoleBuilder_List_SkipsGracefullyWhenClmUnavailable and -// TestClmRoleBuilder_List_FailsLoudlyOnTransientDiscoveryFailure both green, since -// neither exercises a tolerated code from this specific source. -func TestClmRoleBuilder_List_FailsLoudlyOnNonDiscoveryTolerableError(t *testing.T) { - s, c := clmtest.NewServer(t) - s.ForceUserInfoStatus(401) - b := newClmRoleBuilder(c) - ctx := context.Background() - - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{}) - if err == nil { - t.Fatal("expected a tolerated code from a non-discovery source to fail loudly, got nil error") - } - // Pins the two preconditions that make this a regression test for the - // IsClmDiscoveryError conjunct rather than for "any error at all": the error must - // carry a code isOptInFeatureUnavailableError tolerates, and must not be - // discovery-sourced. Otherwise a change to the userinfo failure's code mapping - // would leave this test green while no longer exercising the gate. - if !isOptInFeatureUnavailableError(err) { - t.Fatalf("test setup: expected a tolerated code so this test exercises the discovery-source conjunct, got %v: %v", status.Code(err), err) - } - if client.IsClmDiscoveryError(err) { - t.Fatalf("test setup: expected a non-discovery-sourced error, got: %v", err) + t.Fatal("expected List to fail when CLM is unavailable, got nil error") } if len(resources) != 0 { t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 1795e533..9e2dd7b3 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -11,8 +11,6 @@ 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" ) // Shared profile/field map keys, reused across builders (and the AccountCreationSchema @@ -41,49 +39,6 @@ func parsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, strin return b, b.PageToken(), nil } -// isOptInFeatureUnavailableError reports whether err indicates this account/token -// simply can't use an optional DocuSign feature — no subscription (CLM), the feature -// isn't enabled on the account (signing groups), or the OAuth token lacks the scopes it -// needs (CLM's spring_read/spring_write — see oauth.go) — rather than an unexpected -// failure. -// -// The 5 CLM resource types (and signing_group's List() has the same shape of check) -// are registered unconditionally in ResourceSyncers() and their List() bodies always -// run, with no config flag gating them, specifically so that a resource type never -// disappears from a later sync and gets treated as fully deleted. Tolerating this error -// on the first page of List() (see call sites) is what makes unconditional registration -// safe: the sync skips that one resource type gracefully instead of failing outright. -// -// Covers four codes, each tied to a specific confirmed failure mode of -// ensureClmInitialized's CLM base-URL discovery call (clm_client.go) — the first thing -// every CLM builder's List() does, now unconditionally: -// - 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'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 -// fields at all), which would otherwise surface as an unrecognized codes.Unknown -// and fail the whole sync. -// -// Deliberately still doesn't cover codes.Unknown itself (an un-coded, unwrapped error) -// or 5xx/transport failures (codes.Unavailable/DeadlineExceeded/etc.) — those stay -// loud, since they're as likely to indicate a real outage or bug as a no-CLM account, -// and swallowing them broadly would hide genuine failures. Every other resource type -// (user, group, permission_profile) is always attempted and does not tolerate this -// error at all, so a truly broken token still fails the sync via those. -func isOptInFeatureUnavailableError(err error) bool { - switch status.Code(err) { - case codes.PermissionDenied, codes.Unauthenticated, codes.NotFound, codes.FailedPrecondition: - return true - default: - return false - } -} - // clmIDFromHref extracts the trailing path segment from a CLM object's Href — see // 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 diff --git a/pkg/connector/helper_test.go b/pkg/connector/helper_test.go index 8c4e6822..49612925 100644 --- a/pkg/connector/helper_test.go +++ b/pkg/connector/helper_test.go @@ -1,42 +1,13 @@ package connector import ( - "errors" - "testing" - "context" + "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" ) -func TestIsOptInFeatureUnavailableError(t *testing.T) { - tests := []struct { - name string - err error - want bool - }{ - {"nil error", nil, false}, - {"permission denied", status.Error(codes.PermissionDenied, "no CLM subscription"), true}, - {"unauthenticated", status.Error(codes.Unauthenticated, "insufficient scope"), true}, - {"not found (e.g. account never provisioned in SpringCM)", status.Error(codes.NotFound, "no such account"), true}, - {"failed precondition (e.g. discovery response missing a recognized base-URL field)", status.Error(codes.FailedPrecondition, "no recognized field"), true}, - {"unavailable (rate limit/5xx)", status.Error(codes.Unavailable, "rate limited"), false}, - {"internal", status.Error(codes.Internal, "boom"), false}, - {"unknown (bare unwrapped error)", status.Error(codes.Unknown, "boom"), false}, - {"plain non-gRPC error", errors.New("some transport error"), false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := isOptInFeatureUnavailableError(tt.err); got != tt.want { - t.Errorf("isOptInFeatureUnavailableError(%v) = %v, want %v", tt.err, got, tt.want) - } - }) - } -} - func TestClmHrefWithID(t *testing.T) { got, err := clmHrefWithID("https://clm.example.com/v2/acct-1/groups/group-old", "group-new") if err != nil { diff --git a/pkg/connector/singing_groups.go b/pkg/connector/singing_groups.go index 4e2c58fc..e6cc333d 100644 --- a/pkg/connector/singing_groups.go +++ b/pkg/connector/singing_groups.go @@ -38,10 +38,6 @@ func (g *signingGroupBuilder) List(ctx context.Context, _ *v2.ResourceId, attr r PageToken: pageToken, }) if err != nil { - if attr.PageToken.Token == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: signing groups are not available for this account, skipping signing_group sync", zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil - } return nil, nil, err } From a69026f517462226d69d647be872b375ce5aba3c Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 12:35:55 -0300 Subject: [PATCH 24/54] fix: exclude CLM/signing-group risk from CI by pinning sync-resource-types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-groups/test-signing-groups/test-permission-profiles run baton-docusign directly via the sync-test action, with no C1 platform in the loop and no --sync-resource-types filter — so every registered resource type, including the 5 OptInRequired clm_* types, gets attempted regardless of whether anyone "opted in." Since 47f58c3 made CLM builders fail loud instead of skipping gracefully, and this repo's CI DocuSign account has never had a CLM subscription, every one of these jobs now fails the instant clm_folder's List() hits the CLM discovery 401 - confirmed by diffing this exact job's log against the immediately preceding (passing) commit. Pin BATON_SYNC_RESOURCE_TYPES to the 4 types each job actually needs (user, group, permission_profile, signing_group), matching how a real C1-hosted sync would behave for an account that never opted a CLM type in - that filtering only happens on C1's side, not in baton-sdk itself. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 92a1b1c3..6ca9bf05 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,6 +36,12 @@ jobs: BATON_REFRESH_TOKEN: ${{ secrets.REFRESHTOKEN }} BATON_DEMO: "true" BATON_INCLUDE_SIGNING_GROUPS: "true" + # This CI account has no CLM subscription, and (unlike a C1-hosted sync) nothing + # here filters resource types by OptInRequired — every registered resource type is + # attempted by default. CLM builders now fail the whole sync rather than skip + # gracefully when CLM isn't available (see pkg/connector/clm_roles.go), so the 5 + # clm_* types must be excluded here explicitly to test the ones this job cares about. + BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group steps: - name: Checkout code uses: actions/checkout@v4 @@ -65,6 +71,10 @@ jobs: BATON_REFRESH_TOKEN: ${{ secrets.REFRESHTOKEN }} BATON_DEMO: "true" BATON_INCLUDE_SIGNING_GROUPS: "true" + # See test-groups' identical setting above: this CI account has no CLM + # subscription, and nothing here filters by OptInRequired the way a C1-hosted sync + # would, so the 5 clm_* types must be excluded explicitly or this job fails too. + BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group steps: - name: Checkout code uses: actions/checkout@v4 @@ -94,6 +104,10 @@ jobs: BATON_REFRESH_TOKEN: ${{ secrets.REFRESHTOKEN }} BATON_DEMO: "true" BATON_INCLUDE_SIGNING_GROUPS: "true" + # See test-groups' identical setting above: this CI account has no CLM + # subscription, and nothing here filters by OptInRequired the way a C1-hosted sync + # would, so the 5 clm_* types must be excluded explicitly or this job fails too. + BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group steps: - name: Checkout code uses: actions/checkout@v4 From 01b398f7e9ced49d641b62c8728a1c7102c7ce25 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 12:35:55 -0300 Subject: [PATCH 25/54] refactor: drop dead CLM-discovery-error machinery, fix signing_group docs gap Three cleanups surfaced by automated review on 47f58c3: - clm_client.go's clmDiscoveryError type and exported IsClmDiscoveryError had zero remaining callers once isOptInFeatureUnavailableError was deleted (the only consumer their own doc comments named never existed under that name). ensureClmInitialized now returns its errors unwrapped; removed the type, the predicate, and their now-dead unit test. - clmtest/server.go's ForceClmDiscoveryStatus/ForceUserInfoStatus test knobs were purpose-built for the two clm_role regression tests 47f58c3 deleted (the two-conjunct gate's discovery-vs-non-discovery halves) and had no other caller left. - docs/connector.mdx's signing-groups paragraph still described the old graceful-skip behavior; only the CLM paragraph got updated in 47f58c3. Co-Authored-By: Claude Sonnet 5 --- docs/connector.mdx | 2 +- pkg/client/clm_client.go | 35 +++------------------------------ pkg/client/clm_client_test.go | 21 -------------------- pkg/client/clmtest/server.go | 17 ---------------- pkg/connector/helper.go | 2 ++ pkg/connector/resource_types.go | 8 +++----- 6 files changed, 9 insertions(+), 76 deletions(-) diff --git a/docs/connector.mdx b/docs/connector.mdx index cb3468af..563186a5 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -26,7 +26,7 @@ The Docusign connector supports [automatic account provisioning and deprovisioni Every Docusign account must be assigned at least one permission profile. If all other permission profiles are revoked, the account will be automatically assigned the **DocuSign Viewer** profile, which cannot be revoked. -*By default, signing groups are not synced. Enable the **Include Signing Groups** setting to sync signing groups, if your account has the feature enabled. +*By default, signing groups are not synced. Enable the **Include Signing Groups** setting to sync signing groups. Once enabled, your account must actually have the signing groups feature — ConductorOne doesn't validate this before letting you turn the setting on, so enabling it without the feature will fail the sync rather than silently sync no signing groups. **DocuSign CLM (Contract Lifecycle Management) is a separate, separately-licensed DocuSign product. CLM resources are opt-in — enable each CLM resource type in your sync configuration to turn them on. Once enabled, your DocuSign account must have a CLM production subscription and the credential must have been granted the OAuth scopes CLM needs; enabling a CLM resource type without them will fail the sync rather than silently sync no data, since ConductorOne doesn't validate the underlying subscription before letting you opt in. CLM permission sets sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign one. diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 2aeab2b0..2a5f412c 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -81,7 +81,6 @@ package client import ( "context" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -200,7 +199,7 @@ func (c *Client) ensureClmInitialized(ctx context.Context) error { var raw map[string]json.RawMessage if _, _, err := doRequestCommon(c.wrapper, request, &raw, &ClmErrorResponse{}); err != nil { - return &clmDiscoveryError{err: fmt.Errorf("baton-docusign: failed to discover the CLM API base URL: %w", err)} + return fmt.Errorf("baton-docusign: failed to discover the CLM API base URL: %w", err) } baseURL, ok := clmExtractBaseURLField(raw) @@ -209,14 +208,8 @@ func (c *Client) ensureClmInitialized(ctx context.Context) error { for k := range raw { keys = append(keys, k) } - // codes.FailedPrecondition (not a bare error, which status.Code() would read as - // codes.Unknown): a non-CLM account's discovery response plausibly has a - // different shape entirely (e.g. a bare account object with none of the - // candidate fields), so isOptInFeatureUnavailableError needs a recognizable - // code to tolerate this specific failure the same way it tolerates 401/403 — - // see that function's doc in helper.go. - return &clmDiscoveryError{err: status.Errorf(codes.FailedPrecondition, "baton-docusign: CLM account discovery response at %s did not contain a recognized "+ - "base-URL field (checked %v); response contained these fields instead: %v", discoveryURL, clmBaseURLCandidateFields, keys)} + return status.Errorf(codes.FailedPrecondition, "baton-docusign: CLM account discovery response at %s did not contain a recognized "+ + "base-URL field (checked %v); response contained these fields instead: %v", discoveryURL, clmBaseURLCandidateFields, keys) } c.clmBaseURI = baseURL @@ -224,28 +217,6 @@ func (c *Client) ensureClmInitialized(ctx context.Context) error { return nil } -// clmDiscoveryError marks an error as originating specifically from CLM account -// discovery (ensureClmInitialized above), not from a later per-resource CLM data call -// (SearchFolders, ListGroups, ...). isOptInFeatureUnavailableError's tolerated codes -// (401/403/404/412-equivalent) aren't unique to "no CLM subscription" — a real -// per-resource call can fail with the same code once discovery has already succeeded -// and been cached, for an unrelated reason (a token that expired mid-sync, a narrower -// scope problem on just that endpoint). Without this marker, that later failure would -// be silently treated as "no CLM" too. See IsClmDiscoveryError. -type clmDiscoveryError struct { - err error -} - -func (e *clmDiscoveryError) Error() string { return e.err.Error() } -func (e *clmDiscoveryError) Unwrap() error { return e.err } - -// IsClmDiscoveryError reports whether err (or a wrapped error within it) originated -// from ensureClmInitialized's CLM account discovery call — see clmDiscoveryError. -func IsClmDiscoveryError(err error) bool { - var discoveryErr *clmDiscoveryError - return errors.As(err, &discoveryErr) -} - // EnsureClmReady exposes the CLM-readiness check every other CLM client method runs // internally before its real request, for callers with no CLM endpoint of their own // (clm_role — see pkg/connector/clm_roles.go) that still need to detect CLM diff --git a/pkg/client/clm_client_test.go b/pkg/client/clm_client_test.go index 0512a966..5d773de0 100644 --- a/pkg/client/clm_client_test.go +++ b/pkg/client/clm_client_test.go @@ -8,8 +8,6 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" "github.com/conductorone/baton-docusign/pkg/client/clmtest" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestSearchFolders_Pagination(t *testing.T) { @@ -387,22 +385,3 @@ func TestListPermissionSets_Pagination(t *testing.T) { t.Fatalf("expected 5 permission sets across all pages, got %d", len(all)) } } - -func TestIsClmDiscoveryError(t *testing.T) { - s, _ := clmtest.NewServer(t) - ctx := context.Background() - - badClient := s.NewClientWithToken("wrong-token") - err := badClient.EnsureClmReady(ctx) - if err == nil { - t.Fatal("expected EnsureClmReady to fail for a bad token") - } - if !client.IsClmDiscoveryError(err) { - t.Errorf("expected a real CLM discovery failure to be detected as a discovery error, got: %v", err) - } - - plain := status.Error(codes.Unauthenticated, "not from discovery — a real per-resource CLM data call failure instead") - if client.IsClmDiscoveryError(plain) { - t.Error("expected a plain gRPC-coded error not produced by discovery to not be treated as a CLM discovery error") - } -} diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index 69cad0f2..9e4d41b1 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -443,15 +443,6 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { } func (s *Server) handleUserInfo(w http.ResponseWriter, _ *http.Request) { - s.mu.Lock() - forcedStatus := s.forcedUserInfoStatus - s.mu.Unlock() - if forcedStatus != 0 { - w.WriteHeader(forcedStatus) - _ = json.NewEncoder(w).Encode(client.ErrorResponse{}) - return - } - resp := client.UserInfoResponse{ Sub: "clm-test-user", Name: "CLM Test Account", @@ -477,14 +468,6 @@ func (s *Server) handleUserInfo(w http.ResponseWriter, _ *http.Request) { // (ApiBaseUrl), matching the field name confirmed on CLM's legacy token-exchange // response for the same concept. func (s *Server) handleClmAccountDiscovery(w http.ResponseWriter, _ *http.Request) { - s.mu.Lock() - forcedStatus := s.forcedDiscoveryStatus - s.mu.Unlock() - if forcedStatus != 0 { - w.WriteHeader(forcedStatus) - _ = json.NewEncoder(w).Encode(client.ClmErrorResponse{}) - return - } writeJSON(w, map[string]string{client.ClmDiscoveryFieldAPIBaseURL: s.baseURL}) } diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 9e2dd7b3..bbc4ea02 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -11,6 +11,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" ) // Shared profile/field map keys, reused across builders (and the AccountCreationSchema diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 31e0640f..440f0b2f 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -40,11 +40,9 @@ var ( } // CLM (Contract Lifecycle Management) resource types. CLM is a separate DocuSign - // product/API surface from eSignature above. Registered unconditionally and no - // longer gated by any config flag (see connector.go's ResourceSyncers): each CLM - // builder's List() always runs, and &v2.OptInRequired{} plus - // isOptInFeatureUnavailableError (helper.go) are what keep an account without a CLM - // subscription from failing the sync. + // product/API surface from eSignature above. &v2.OptInRequired{} keeps these out of + // a customer's sync until explicitly enabled; once enabled, List() fails the sync + // loudly if the account can't actually reach CLM — see clm_roles.go's doc comment. // clmMemberResourceType is CLM's own principal object. Deliberately NOT reusing // userResourceType's id ("user") — the CLM Members API is a distinct upstream From de6d11390ac67109d5ee533323b0768ed5b66458 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 12:57:11 -0300 Subject: [PATCH 26/54] fix: drop dead conjunct and fix stale comments in folder-security logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more findings from repeated automated review passes on the same area: - logSkippedFolderSecurityEntry's early return checked `accessType != client.ClmAccessTypeCustom && clmIsBenignUnmappedAccessType(accessType)`, but clmIsBenignUnmappedAccessType never returns true for Custom (its own doc says so), so the left conjunct was never the deciding factor. Also corrected the comment's inaccurate "allocation-free" claim (the caller builds the variadic fields before this function is ever entered) and dropped a dangling "like the Sprintf removal above" with no referent. - clmIsBenignUnmappedAccessType's doc pointed at "its own Debug log at each call site" for Custom, which was true before logSkippedFolderSecurityEntry centralized that logging into one shared function. - A test comment claimed clmIsBenignUnmappedAccessType was "re-checked by hand in each of the Groups/Roles/Users loops" — also stale post-dedupe; retargeted to the actual reason for seeding all three collections. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_folders.go | 14 +++++++------- pkg/connector/clm_folders_test.go | 7 +++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 8c955c0b..87e6fe7d 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -204,10 +204,10 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour // branches carry access_type so either case is findable by the same structured-log // query as every other skip line in this file. func logSkippedFolderSecurityEntry(ctx context.Context, kind, accessType string, fields ...zap.Field) { - if accessType != client.ClmAccessTypeCustom && clmIsBenignUnmappedAccessType(accessType) { + if clmIsBenignUnmappedAccessType(accessType) { // The common steady-state case (NoAccess/InheritFromParentFolder, on every - // folder of every sync) — return before building fields, not just before - // logging, so it stays allocation-free like the Sprintf removal above. + // folder of every sync) — return before this function's own append/log call. + // The caller's fields are already built by this point regardless. return } fields = append(fields, zap.String("principal_kind", kind), zap.String("access_type", accessType)) @@ -484,10 +484,10 @@ func clmSlugForAccessType(accessType string) (string, bool) { // represents an access grant C1 is failing to show, so logging them would only add // per-sync noise for two expected states large accounts can produce on every sync. // -// Custom is deliberately NOT in this set — see its own Debug log at each call site: it's -// a real, active grant this connector can't round-trip to a single tier (an arbitrary -// flag combination), so silencing it the same way would hide an actual access-visibility -// gap, not just an expected inert state. +// Custom is deliberately NOT in this set — see its own Debug log in +// logSkippedFolderSecurityEntry: it's a real, active grant this connector can't +// round-trip to a single tier (an arbitrary flag combination), so silencing it the same +// way would hide an actual access-visibility gap, not just an expected inert state. func clmIsBenignUnmappedAccessType(accessType string) bool { switch accessType { case client.ClmAccessTypeNoAccess, client.ClmAccessTypeInherit: diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index a9b37bcb..46f5502e 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -239,10 +239,9 @@ func TestClmFolderBuilder_Grants_LogsOnlyForGenuinelyUnrecognizedAccessType(t *t _, c := clmtest.NewServer(t) ctx := context.Background() - // Seeds all three principal-type collections, not just Groups: clmIsBenignUnmappedAccessType - // is re-checked by hand in each of the Groups/Roles/Users loops in clm_folders.go, so a - // copy-paste slip in just one of them (an inverted !, or the guard omitted entirely) would - // leave a Groups-only test green. + // Seeds all three principal-type collections, not just Groups: that's what makes the + // principal_kind-to-distinguishing-field binding assertion below meaningful, rather + // than only ever exercising the Groups branch. if _, err := c.PatchFolderSecurity(ctx, "folder-templates", client.ClmFolderSecurityWrite{ Groups: []client.ClmGroupSecurityEntry{ {AccessType: "SomethingUnrecognized", Href: "https://example.com/groups/group-x"}, From 4c1145997dfe86917081dcc4ab082e1f7c42b251 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 13:27:39 -0300 Subject: [PATCH 27/54] docs: warn self-hosted/CLI users that OptInRequired doesn't protect them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 47f58c3's fail-loud behavior change was documented purely in terms of C1's opt-in toggle, but that gate is platform-side only (confirmed earlier this PR, when the CI jobs needed BATON_SYNC_RESOURCE_TYPES added to keep passing). Running baton-docusign directly — the brew/docker/go install quickstarts, or any self-hosted/CLI invocation — attempts all 5 CLM resource types unconditionally, so an eSignature-only account run this way now fails its entire sync instead of skipping CLM. Added a paragraph pointing at --sync-resource-types/BATON_SYNC_RESOURCE_TYPES so the workaround isn't discoverable only by reading ci.yaml. Also fixed one more stale "at each Grants() call site" reference in clm_folders_test.go — same claim as clm_folders.go:454, missed there in 4d7ed4d's fix. Co-Authored-By: Claude Sonnet 5 --- README.md | 9 +++++++++ pkg/connector/clm_folders_test.go | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a81f4b04..0e1a516c 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,15 @@ with zero CLM resources — an account that opted in but can't reach CLM is trea misconfiguration to fix (disable the resource type, or activate the CLM feature), not an expected state to tolerate. +`OptInRequired` is enforced by ConductorOne's platform, not by the connector or baton-sdk +itself — running `baton-docusign` directly (the quickstarts below, or any self-hosted/CLI +invocation) attempts all 5 CLM resource types by default, with no opt-in gate at all. If +that account doesn't have a CLM subscription, the sync now fails instead of skipping CLM +gracefully. Pass `--sync-resource-types` (or `BATON_SYNC_RESOURCE_TYPES`, comma-separated) +with the resource type IDs you actually want (e.g. `user,group,permission_profile`) to +exclude `clm_member,clm_role,clm_group,clm_permission_set,clm_folder` on an +eSignature-only account run this way. + CLM permission sets sync for visibility only — DocuSign's CLM API has no endpoint to assign or unassign a permission set, so they cannot be granted or revoked through this connector. diff --git a/pkg/connector/clm_folders_test.go b/pkg/connector/clm_folders_test.go index 46f5502e..f28607f7 100644 --- a/pkg/connector/clm_folders_test.go +++ b/pkg/connector/clm_folders_test.go @@ -216,8 +216,8 @@ func TestClmIsBenignUnmappedAccessType(t *testing.T) { }{ {client.ClmAccessTypeNoAccess, true}, // Custom is deliberately excluded — it's a real, active grant this connector - // can't round-trip, so it gets its own distinct Debug log at each Grants() call - // site instead of being silenced like the truly-inert values here. + // can't round-trip, so logSkippedFolderSecurityEntry gives it its own distinct + // Debug log instead of silencing it like the truly-inert values here. {client.ClmAccessTypeCustom, false}, {client.ClmAccessTypeInherit, true}, {client.ClmAccessTypeView, false}, From 151555c2a04fe05a75525ebb12f85d17add1e364 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 14:55:42 -0300 Subject: [PATCH 28/54] test: add signing_groups_test.go, covering the new fail-loud behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit signing_groups.go had zero test coverage before this — the fail-loud change in 47f58c3 (dropping isOptInFeatureUnavailableError's tolerance) was unverified. This package had no shared eSignature mock server (unlike pkg/client/clmtest for CLM), so added a small local one covering just what List() needs: /oauth/userinfo (the failure path GetSigningGroups hits first) and a bare /signing_groups response (the happy path, to distinguish "the mock is wired correctly" from "everything errors regardless"). Co-Authored-By: Claude Sonnet 5 --- pkg/connector/singing_groups_test.go | 122 +++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 pkg/connector/singing_groups_test.go diff --git a/pkg/connector/singing_groups_test.go b/pkg/connector/singing_groups_test.go new file mode 100644 index 00000000..12f46683 --- /dev/null +++ b/pkg/connector/singing_groups_test.go @@ -0,0 +1,122 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/conductorone/baton-docusign/pkg/client" + "github.com/conductorone/baton-sdk/pkg/pagination" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "golang.org/x/oauth2" +) + +// rewriteTransport redirects every outgoing request to the given target host — this +// package has no shared eSignature mock server (unlike pkg/client/clmtest for CLM), so +// this small helper is duplicated locally rather than exported from pkg/client's own +// unexported test-only copy. +type rewriteTransport struct { + target *url.URL + base http.RoundTripper +} + +func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Scheme = t.target.Scheme + req.URL.Host = t.target.Host + return t.base.RoundTrip(req) +} + +// newSigningGroupsTestClient builds a *client.Client wired to a mock server that only +// serves /oauth/userinfo. signingGroupBuilder.List()'s only failure path that matters +// here is ensureInitialized (called by GetSigningGroups before it ever reaches the +// signing-groups endpoint), so a full eSignature REST API mock isn't needed to exercise +// it — matching how the CLM builders' equivalent tests fail at CLM account discovery. +func newSigningGroupsTestClient(t *testing.T, userInfoStatus int) *client.Client { + t.Helper() + var mockServer *httptest.Server + mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth/userinfo" { + if userInfoStatus != http.StatusOK { + w.WriteHeader(userInfoStatus) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(client.UserInfoResponse{ + Sub: "test-user", + Name: "Test User", + Accounts: []client.AccountInfo{ + {AccountId: "acct-1", AccountName: "Test Account", IsDefault: true, BaseURI: mockServer.URL}, + }, + }) + return + } + if strings.HasSuffix(r.URL.Path, "/signing_groups") { + // An empty {} body round-trips through SigningGroupResponse as zero + // signing groups and no next page (getNextToken's EndPosition+1 < + // TotalSetSize is 0+1 < 0, false) — enough to exercise the happy path + // without a full pagination fixture. + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{}")) + return + } + http.NotFound(w, r) + })) + t.Cleanup(mockServer.Close) + + mockServerURL, err := url.Parse(mockServer.URL) + if err != nil { + t.Fatalf("parsing mock server URL: %v", err) + } + transport := &rewriteTransport{target: mockServerURL, base: http.DefaultTransport} + wrapper := uhttp.NewBaseHttpClient(&http.Client{Transport: transport}) + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}) + + return client.NewClient(context.Background(), false, tokenSource, "", "", wrapper) +} + +// TestSigningGroupBuilder_List_FailsWhenUnavailable is a regression test for the +// fail-loud behavior change in 47f58c3: signing_group is gated behind the +// --include-signing-groups flag (connector.go), but that flag doesn't validate the +// account actually has the feature before letting an operator turn it on. List() must +// now propagate any error (here, a 401 from eSignature account discovery) instead of +// tolerating it and silently syncing zero signing groups. +func TestSigningGroupBuilder_List_FailsWhenUnavailable(t *testing.T) { + c := newSigningGroupsTestClient(t, http.StatusUnauthorized) + b := newSigningGroupBuilder(c) + ctx := context.Background() + + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) + if err == nil { + t.Fatal("expected List to fail when signing groups are unavailable, got nil error") + } + if len(resources) != 0 { + t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) + } +} + +// TestSigningGroupBuilder_List_Succeeds is a sanity check for +// newSigningGroupsTestClient itself: confirms the happy path (account discovery +// succeeds) reaches List()'s normal return, distinguishing a correctly-wired mock from +// the fail-loud test above passing only because everything errors regardless. +func TestSigningGroupBuilder_List_Succeeds(t *testing.T) { + c := newSigningGroupsTestClient(t, http.StatusOK) + b := newSigningGroupBuilder(c) + ctx := context.Background() + + resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) + if err != nil { + t.Fatalf("List: %v", err) + } + if res == nil { + t.Fatal("expected a non-nil SyncOpResults") + } + if len(resources) != 0 { + t.Errorf("expected zero signing groups from this bare mock (no signing-groups endpoint served), got %d: %+v", len(resources), resources) + } +} From 72d91998733337fc885e96a3a9ab6f0db3b5bf52 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 18:20:27 -0300 Subject: [PATCH 29/54] fix: address bot review findings on PR #64, dedupe rewriteTransport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - singing_groups_test.go: fix stale doc comment/failure message about the mock not serving /signing_groups (it does, with an empty body); seed one signing group instead so the happy-path test also exercises parseIntoSigningGroupResource rather than only asserting zero results. - Drop singing_groups_test.go's duplicate rewriteTransport declaration (users_test.go already declares it in the same package) — this was a pre-existing `go test ./pkg/connector/...` build failure, caught while fixing the above. - docs/doc-info.md: update the CLM opt-in bullet to match the OptInRequired/fail-loud contract README.md and docs/connector.mdx already describe, instead of the old no-opt-in-flag wording. - connector_test.go: update alwaysRegisteredTypeIDs' comment to stop crediting gating to isOptInFeatureUnavailableError, which this PR deletes from helper.go. Co-Authored-By: Claude Sonnet 5 --- docs/doc-info.md | 2 +- pkg/connector/connector_test.go | 5 ++- pkg/connector/singing_groups_test.go | 60 +++++++++++++++------------- 3 files changed, 37 insertions(+), 30 deletions(-) diff --git a/docs/doc-info.md b/docs/doc-info.md index d9048da8..1cc78a14 100644 --- a/docs/doc-info.md +++ b/docs/doc-info.md @@ -45,7 +45,7 @@ **Important Note about CLM:** - - CLM (Contract Lifecycle Management) is a separate, separately-licensed DocuSign product with its own API. There is no config flag to enable it: CLM resources sync whenever the account and credential can reach the CLM API, and accounts without CLM sync no CLM resources. + - CLM (Contract Lifecycle Management) is a separate, separately-licensed DocuSign product with its own API. The 5 CLM resource types carry `OptInRequired` and don't sync until a customer explicitly enables them in C1's sync configuration; C1's opt-in toggle doesn't validate the underlying subscription/scopes first, so an account that opts in but can't reach CLM fails the sync loudly rather than silently syncing zero CLM resources. - Requires a DocuSign CLM production subscription. - When using ConductorOne's managed OAuth app (the default cloud-hosted authentication method), CLM also requires that managed app to be granted the CLM API scope on ConductorOne's platform side — this is outside the connector's own configuration. Self-hosted or demo-environment setups using a customer-supplied DocuSign app do not have this extra requirement. - CLM permission sets sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign one, so they cannot be granted or revoked. diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 6abf82b3..7c4d779d 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -12,8 +12,9 @@ import ( // registering conditionally would make ListResourceTypes() advertise fewer types than a // prior sync did, and C1 can then bucket every previously-synced resource and grant of a // vanished type as deleted. Gating happens via &v2.OptInRequired{} (resource_types.go) -// and each opt-in builder's List() tolerating an unavailable-feature error (helper.go), -// not by omitting the builder. +// alone, not by omitting the builder — List() now fails loudly rather than tolerating an +// unavailable-feature error when a customer opts in without a reachable CLM subscription +// (see clm_roles.go's doc comment). var alwaysRegisteredTypeIDs = []string{ "user", "group", diff --git a/pkg/connector/singing_groups_test.go b/pkg/connector/singing_groups_test.go index 12f46683..caff7850 100644 --- a/pkg/connector/singing_groups_test.go +++ b/pkg/connector/singing_groups_test.go @@ -16,27 +16,22 @@ import ( "golang.org/x/oauth2" ) -// rewriteTransport redirects every outgoing request to the given target host — this -// package has no shared eSignature mock server (unlike pkg/client/clmtest for CLM), so -// this small helper is duplicated locally rather than exported from pkg/client's own -// unexported test-only copy. -type rewriteTransport struct { - target *url.URL - base http.RoundTripper -} +// rewriteTransport is already declared in users_test.go (same package) — reused here +// rather than duplicated. -func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req = req.Clone(req.Context()) - req.URL.Scheme = t.target.Scheme - req.URL.Host = t.target.Host - return t.base.RoundTrip(req) -} +// testSigningGroupID and testSigningGroupName are the one signing group +// newSigningGroupsTestClient's mock seeds in its /signing_groups response. +const ( + testSigningGroupID = "sg-1" + testSigningGroupName = "Test Signing Group" +) -// newSigningGroupsTestClient builds a *client.Client wired to a mock server that only -// serves /oauth/userinfo. signingGroupBuilder.List()'s only failure path that matters -// here is ensureInitialized (called by GetSigningGroups before it ever reaches the -// signing-groups endpoint), so a full eSignature REST API mock isn't needed to exercise -// it — matching how the CLM builders' equivalent tests fail at CLM account discovery. +// newSigningGroupsTestClient builds a *client.Client wired to a mock server serving +// /oauth/userinfo plus a minimal /signing_groups response (one seeded group). +// signingGroupBuilder.List()'s only failure path that matters here is ensureInitialized +// (called by GetSigningGroups before it ever reaches the signing-groups endpoint), so a +// full eSignature REST API mock isn't needed to exercise it — matching how the CLM +// builders' equivalent tests fail at CLM account discovery. func newSigningGroupsTestClient(t *testing.T, userInfoStatus int) *client.Client { t.Helper() var mockServer *httptest.Server @@ -57,12 +52,16 @@ func newSigningGroupsTestClient(t *testing.T, userInfoStatus int) *client.Client return } if strings.HasSuffix(r.URL.Path, "/signing_groups") { - // An empty {} body round-trips through SigningGroupResponse as zero - // signing groups and no next page (getNextToken's EndPosition+1 < - // TotalSetSize is 0+1 < 0, false) — enough to exercise the happy path - // without a full pagination fixture. + // One seeded group is enough to exercise both the happy path and + // parseIntoSigningGroupResource, without a full pagination fixture (no next + // page: the zero-valued embedded Page makes getNextToken's + // EndPosition+1 < TotalSetSize read 0+1 < 0, false). w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte("{}")) + _ = json.NewEncoder(w).Encode(client.SigningGroupResponse{ + SigningGroups: []client.SigningGroup{ + {SigningGroupId: testSigningGroupID, GroupName: testSigningGroupName}, + }, + }) return } http.NotFound(w, r) @@ -102,7 +101,8 @@ func TestSigningGroupBuilder_List_FailsWhenUnavailable(t *testing.T) { // TestSigningGroupBuilder_List_Succeeds is a sanity check for // newSigningGroupsTestClient itself: confirms the happy path (account discovery -// succeeds) reaches List()'s normal return, distinguishing a correctly-wired mock from +// succeeds) reaches List()'s normal return and correctly parses the one seeded signing +// group via parseIntoSigningGroupResource, distinguishing a correctly-wired mock from // the fail-loud test above passing only because everything errors regardless. func TestSigningGroupBuilder_List_Succeeds(t *testing.T) { c := newSigningGroupsTestClient(t, http.StatusOK) @@ -116,7 +116,13 @@ func TestSigningGroupBuilder_List_Succeeds(t *testing.T) { if res == nil { t.Fatal("expected a non-nil SyncOpResults") } - if len(resources) != 0 { - t.Errorf("expected zero signing groups from this bare mock (no signing-groups endpoint served), got %d: %+v", len(resources), resources) + if len(resources) != 1 { + t.Fatalf("expected the one seeded signing group, got %d: %+v", len(resources), resources) + } + if got := resources[0].Id.Resource; got != testSigningGroupID { + t.Errorf("expected resource ID %q, got %q", testSigningGroupID, got) + } + if got := resources[0].DisplayName; got != testSigningGroupName { + t.Errorf("expected display name %q, got %q", testSigningGroupName, got) } } From 15d592c65ca2ae2905a1210e8656753b9c84fbf7 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 18:40:13 -0300 Subject: [PATCH 30/54] docs: warn maintainers that CI's resource-type allowlist needs updating BATON_SYNC_RESOURCE_TYPES pins an explicit allowlist (not a CLM-only exclusion) in all three sync-test jobs. A new non-CLM resource type registered in connector.go's ResourceSyncers() with no corresponding update here would get zero CI sync-test coverage, with nothing failing to signal the gap. Adds a maintainer reminder to the canonical comment block the other two jobs already point back to. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ca9bf05..7cc7e0e3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -41,6 +41,10 @@ jobs: # attempted by default. CLM builders now fail the whole sync rather than skip # gracefully when CLM isn't available (see pkg/connector/clm_roles.go), so the 5 # clm_* types must be excluded here explicitly to test the ones this job cares about. + # This is an allowlist, not a CLM-only exclusion: if you register a new non-CLM + # resource type in pkg/connector/connector.go, add it here too (and to the other two + # BATON_SYNC_RESOURCE_TYPES settings below, which mirror this one) — otherwise it + # silently gets zero CI sync-test coverage. BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group steps: - name: Checkout code From efba5fbc954be6da8d1d30f8b7693810dea34ce5 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 19:06:20 -0300 Subject: [PATCH 31/54] fix: serialize CI jobs that hit the shared DocuSign demo account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-groups/test-signing-groups/test-permission-profiles were only chained via needs within a single workflow run — two different branches' runs (e.g. this stack's two PRs, pushed close together) could still race concurrently against the same live demo account, one run's mid-cycle Grant/Revoke corrupting another's "should be zero grants after Revoke" assertion. Confirmed via CI history: test-groups flips pass/fail across commits that never touch pkg/connector/groups.go, correlated with near-simultaneous run start times across branches. Adds a shared concurrency group (not ref-scoped) across all three jobs so only one runs against the real account at a time, queuing the rest. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7cc7e0e3..310eda0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,6 +28,14 @@ env: jobs: test-groups: runs-on: ubuntu-latest + # Serializes against every other job in this file sharing this same group name + # (across every branch/PR, not just this one) — all three 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). + concurrency: + group: docusign-demo-account + cancel-in-progress: false env: BATON_LOG_LEVEL: debug BATON_DOCUSIGN_CLIENT_ID: ${{ secrets.CLIENTID }} @@ -67,6 +75,10 @@ jobs: test-signing-groups: needs: [test-groups] runs-on: ubuntu-latest + # See test-groups' identical setting above — same shared demo account. + concurrency: + group: docusign-demo-account + cancel-in-progress: false env: BATON_LOG_LEVEL: debug BATON_DOCUSIGN_CLIENT_ID: ${{ secrets.CLIENTID }} @@ -100,6 +112,10 @@ jobs: test-permission-profiles: needs: [test-signing-groups] runs-on: ubuntu-latest + # See test-groups' identical setting above — same shared demo account. + concurrency: + group: docusign-demo-account + cancel-in-progress: false env: BATON_LOG_LEVEL: debug BATON_DOCUSIGN_CLIENT_ID: ${{ secrets.CLIENTID }} From f56957e19910b54ced9447b08a20091b6c7e07fe Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 19:35:15 -0300 Subject: [PATCH 32/54] docs: dedupe BATON_SYNC_RESOURCE_TYPES into a workflow-level env block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-groups/test-signing-groups/test-permission-profiles each carried an identical BATON_SYNC_RESOURCE_TYPES line plus an explanatory comment, with a note instructing maintainers to keep all three copies in sync. Declaring it once at the workflow level removes that drift hazard — all three jobs inherit it the same way. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yaml | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 310eda0a..0503da22 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,6 +16,16 @@ concurrency: group: docusign-demo-account cancel-in-progress: false env: + # This CI account has no CLM subscription, and (unlike a C1-hosted sync) nothing here + # filters resource types by OptInRequired — every registered resource type is attempted + # by default. CLM builders now fail the whole sync rather than skip gracefully when CLM + # isn't available (see pkg/connector/clm_roles.go), so the 5 clm_* types must be + # excluded here explicitly to test the ones the three test-* jobs below care about. + # This is an allowlist, not a CLM-only exclusion: if you register a new non-CLM + # resource type in pkg/connector/connector.go, add it here too — otherwise it silently + # gets zero CI sync-test coverage. Declared once at the workflow level (all three jobs + # inherit it) so there's no per-job copy to keep in sync. + BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group # Forces the legacy v1/SQLite c1z format instead of baton-sdk v0.25.0's new default # (Pebble/v3). The `baton` CLI these jobs download (ConductorOne/github-workflows' # get-baton action, currently v0.4.5) is built against baton-sdk v0.8.24 — long before @@ -44,16 +54,6 @@ jobs: BATON_REFRESH_TOKEN: ${{ secrets.REFRESHTOKEN }} BATON_DEMO: "true" BATON_INCLUDE_SIGNING_GROUPS: "true" - # This CI account has no CLM subscription, and (unlike a C1-hosted sync) nothing - # here filters resource types by OptInRequired — every registered resource type is - # attempted by default. CLM builders now fail the whole sync rather than skip - # gracefully when CLM isn't available (see pkg/connector/clm_roles.go), so the 5 - # clm_* types must be excluded here explicitly to test the ones this job cares about. - # This is an allowlist, not a CLM-only exclusion: if you register a new non-CLM - # resource type in pkg/connector/connector.go, add it here too (and to the other two - # BATON_SYNC_RESOURCE_TYPES settings below, which mirror this one) — otherwise it - # silently gets zero CI sync-test coverage. - BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group steps: - name: Checkout code uses: actions/checkout@v4 @@ -87,10 +87,6 @@ jobs: BATON_REFRESH_TOKEN: ${{ secrets.REFRESHTOKEN }} BATON_DEMO: "true" BATON_INCLUDE_SIGNING_GROUPS: "true" - # See test-groups' identical setting above: this CI account has no CLM - # subscription, and nothing here filters by OptInRequired the way a C1-hosted sync - # would, so the 5 clm_* types must be excluded explicitly or this job fails too. - BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group steps: - name: Checkout code uses: actions/checkout@v4 @@ -124,10 +120,6 @@ jobs: BATON_REFRESH_TOKEN: ${{ secrets.REFRESHTOKEN }} BATON_DEMO: "true" BATON_INCLUDE_SIGNING_GROUPS: "true" - # See test-groups' identical setting above: this CI account has no CLM - # subscription, and nothing here filters by OptInRequired the way a C1-hosted sync - # would, so the 5 clm_* types must be excluded explicitly or this job fails too. - BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group steps: - name: Checkout code uses: actions/checkout@v4 From 760b67e7d1a2a66a986d113717abad3507cac53f Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 18 Aug 2026 00:54:04 -0300 Subject: [PATCH 33/54] fix: centralize CLM and base-credential checks in Connector.Validate() Per luisina-santos's review: each opted-in CLM builder independently checked CLM availability inside its own List(), redundant and racy under baton-sdk's concurrent resource-type syncing, when a single upfront check in Validate() (called once, before any List()) produces the same fail-loud outcome sooner. Centralizes that check behind a new includeClm field. Fixed two constructor bugs found while wiring includeClm through: NewWithTokenSource -- the ConductorOne-hosted path, i.e. the common production case -- had no includeClm parameter at all, so Validate() would have silently never run its CLM check there; NewWithRefreshToken received the parameter but never stored it. Also extended Validate() to check base eSignature credentials unconditionally (previously an unconditional no-op for any account without CLM), catching a misconfigured account upfront instead of leaving that to whichever builder's List() happens to run first mid-sync. clm_roles.go's own now-fully-redundant EnsureClmReady call is removed. NewWithClient (zero callers anywhere in this repo) is kept rather than deleted, since removing an exported function is a breaking change for any external consumer that may exist -- documented as such, with a test matching its two siblings. Co-Authored-By: Claude Sonnet 5 --- pkg/client/client.go | 10 +++ pkg/client/clm_client.go | 7 +- pkg/connector/clm_roles.go | 31 +++------ pkg/connector/clm_roles_test.go | 29 +------- pkg/connector/connector.go | 57 +++++++++++++--- pkg/connector/connector_test.go | 117 +++++++++++++++++++++++++++++++- pkg/connector/resource_types.go | 5 +- 7 files changed, 192 insertions(+), 64 deletions(-) diff --git a/pkg/client/client.go b/pkg/client/client.go index ed033d7d..d027bc7f 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -313,6 +313,16 @@ func (c *Client) ensureInitialized(ctx context.Context) error { return nil } +// EnsureReady exposes the base eSignature-credential check every other client method +// runs internally before its real request, for callers with no eSignature endpoint of +// their own that still need to detect whether the base connection/credentials are +// valid — namely Connector.Validate() (see pkg/connector/connector.go), which runs +// this once, up front, before any resource type's List() executes. Memoized after the +// first successful call, same as every other method — see ensureInitialized. +func (c *Client) EnsureReady(ctx context.Context) error { + return c.ensureInitialized(ctx) +} + // buildClientURL safely reads baseURI and accountId to build a URL. func (c *Client) buildClientURL(path string, params ...any) (*url.URL, error) { c.mutex.RLock() diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 2a5f412c..aa8dfa25 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -219,9 +219,10 @@ func (c *Client) ensureClmInitialized(ctx context.Context) error { // EnsureClmReady exposes the CLM-readiness check every other CLM client method runs // internally before its real request, for callers with no CLM endpoint of their own -// (clm_role — see pkg/connector/clm_roles.go) that still need to detect CLM -// availability. Memoized after the first successful call, same as every other CLM -// method — see ensureClmInitialized. +// that still need to detect CLM availability — namely Connector.Validate() (see +// pkg/connector/connector.go), which runs this once, up front, before any CLM +// builder's List() executes. Memoized after the first successful call, same as every +// other CLM method — see ensureClmInitialized. func (c *Client) EnsureClmReady(ctx context.Context) error { return c.ensureClmReady(ctx) } diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index 09f6e03f..7bba47e7 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -8,11 +8,11 @@ import ( rs "github.com/conductorone/baton-sdk/pkg/types/resource" ) -// clmRoleBuilder syncs the 5 fixed CLM account-level roles (client.ClmRoles). The role -// set itself isn't backed by an API call — see resource_types.go for why this resource -// type exists — but List() still checks CLM availability via EnsureClmReady before -// emitting it, the same discovery check every other CLM builder's real API call runs -// internally. +// clmRoleBuilder syncs the 5 fixed CLM account-level roles (client.ClmRoles). Not +// backed by an API call — see resource_types.go for why this resource type exists. +// CLM availability is checked once, up front, by Connector.Validate() rather than here +// — see that method's doc for why centralizing it there is better than every opted-in +// CLM builder repeating the same check on its own first page. type clmRoleBuilder struct { resourceType *v2.ResourceType client *client.Client @@ -22,22 +22,11 @@ func (b *clmRoleBuilder) ResourceType(_ context.Context) *v2.ResourceType { return clmRoleResourceType } -// List returns the fixed set of CLM roles, gated on CLM being available for this -// account. No pagination needed — the set is small and hardcoded, not fetched from the -// API — so the availability check always runs (there's no first-page-only gate to -// apply, unlike the paginated CLM builders). -// -// clm_role is OptInRequired (resource_types.go), so this List() only ever runs once a -// customer has explicitly enabled it in their sync config — the C1 platform's toggle for -// that has no upstream validation against DocuSign, so a customer can opt in without -// actually having a CLM subscription. When that happens, EnsureClmReady failing here is -// a real misconfiguration, not a transient/expected condition: fail loudly so it's -// visible, rather than silently syncing zero roles indefinitely. -func (b *clmRoleBuilder) List(ctx context.Context, _ *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { - if err := b.client.EnsureClmReady(ctx); err != nil { - return nil, nil, err - } - +// List returns the fixed set of CLM roles. No pagination needed — the set is small and +// hardcoded, not fetched from the API. CLM availability was already confirmed once, up +// front, by Connector.Validate() before any builder's List() runs — see that method's +// doc — so there's no error path here beyond rs.NewRoleResource construction failing. +func (b *clmRoleBuilder) List(_ context.Context, _ *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { var resources []*v2.Resource for _, role := range client.ClmRoles { roleResource, err := rs.NewRoleResource( diff --git a/pkg/connector/clm_roles_test.go b/pkg/connector/clm_roles_test.go index 971168c8..0f051dff 100644 --- a/pkg/connector/clm_roles_test.go +++ b/pkg/connector/clm_roles_test.go @@ -10,9 +10,9 @@ import ( ) func TestClmRoleBuilder_List(t *testing.T) { - // The role set itself isn't backed by an API call, but List() now checks CLM - // availability via EnsureClmReady first (see clm_roles.go), so it needs a working - // mock client to reach the "CLM is available" branch. + // The role set isn't backed by an API call at all — CLM availability is checked + // once, up front, by Connector.Validate() (see connector_test.go), not here — so + // this only needs a client to satisfy the builder's field, never calls it. _, c := clmtest.NewServer(t) b := newClmRoleBuilder(c) ctx := context.Background() @@ -34,29 +34,6 @@ func TestClmRoleBuilder_List(t *testing.T) { } } -// TestClmRoleBuilder_List_FailsWhenClmUnavailable is a deliberate design choice, not an -// oversight: clm_role is OptInRequired (resource_types.go), so List() only ever runs -// once a customer has explicitly enabled it in their sync config, and C1's opt-in toggle -// has no upstream check against DocuSign — a customer can enable it without actually -// having a CLM subscription. When EnsureClmReady then fails, that's a real -// misconfiguration (wrong resource enabled, or the feature needs activating), not an -// expected/transient state, so it must fail the sync loudly rather than silently -// succeed with zero roles indefinitely. -func TestClmRoleBuilder_List_FailsWhenClmUnavailable(t *testing.T) { - s, _ := clmtest.NewServer(t) - badClient := s.NewClientWithToken("wrong-token") - b := newClmRoleBuilder(badClient) - ctx := context.Background() - - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{}) - if err == nil { - t.Fatal("expected List to fail when CLM is unavailable, got nil error") - } - if len(resources) != 0 { - t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) - } -} - func TestClmRoleBuilder_EntitlementsAndGrants_AreNoop(t *testing.T) { b := newClmRoleBuilder(nil) ctx := context.Background() diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index fe190e8d..93fa3be7 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -24,6 +24,13 @@ type Connector struct { // at all (see ResourceSyncers). Unlike the CLM types, which are always registered, // this means ListResourceTypes() advertises a different set depending on the flag. includeSigningGroups bool + // includeClm reports whether this sync will touch any CLM resource type — the same + // opts.WillSyncResourceType(...) signal that already determines whether any CLM + // builder's List() gets invoked this run (see New()). Gates Validate()'s upfront CLM + // readiness check: does NOT gate resource-type registration (ResourceSyncers always + // registers all 5 CLM builders unconditionally — see that comment for why a + // registration-level gate was tried and reverted once before, in 9cbbd06/002a649). + includeClm bool // skipPermissionProfileResourceType reports whether permission_profile is // excluded from the sync filter. skipPermissionProfileResourceType bool @@ -130,8 +137,25 @@ func (d *Connector) Metadata(_ context.Context) (*v2.ConnectorMetadata, error) { }, nil } -func (d *Connector) Validate(_ context.Context) (annotations.Annotations, error) { - return nil, nil +// Validate runs once, before any resource type's List() (see baton-sdk's +// pkg/sync/syncer.go Sync()), so it's the right place to check readiness a single time +// upfront rather than discovering a bad account mid-sync at whichever builder's List() +// happens to run first. EnsureReady (base eSignature credentials) runs unconditionally +// — every sync needs those regardless of CLM — while EnsureClmReady is gated on +// includeClm: an account that never opted into any CLM resource type has no reason to +// pay for, or fail on, a CLM discovery call it doesn't need — see this file's +// includeClm field doc for why this gate doesn't repeat the registration-level opt-in +// flag this connector already tried and reverted once (9cbbd06/002a649). See +// clm_roles.go's doc comment for the review discussion that led to centralizing the +// CLM check here instead of inside every opted-in CLM builder's own List(). +func (d *Connector) Validate(ctx context.Context) (annotations.Annotations, error) { + if err := d.client.EnsureReady(ctx); err != nil { + return nil, err + } + if !d.includeClm { + return nil, nil + } + return nil, d.client.EnsureClmReady(ctx) } func NewWithRefreshToken( @@ -153,24 +177,34 @@ func NewWithRefreshToken( return &Connector{ client: docusignClient, includeSigningGroups: includeSigningGroups, + includeClm: includeClm, skipPermissionProfileResourceType: skipPermissionProfileResourceType, }, nil } -func NewWithClient(client *client.Client, includeSigningGroups bool, skipPermissionProfileResourceType bool) (*Connector, error) { +// NewWithClient has no caller anywhere in this repo today (confirmed by repo-wide +// grep) — unlike NewWithRefreshToken and NewWithTokenSource, nothing in New() ever +// constructs a Connector this way. Its purpose (an external test harness? a future call +// site?) isn't established anywhere in this codebase. Kept compiling and in sync with +// the other two constructors' fields — rather than deleted — since removing an exported +// function is a breaking change for any consumer of this module outside this repo that +// may exist. +func NewWithClient(client *client.Client, includeSigningGroups, includeClm bool, skipPermissionProfileResourceType bool) (*Connector, error) { return &Connector{ client: client, includeSigningGroups: includeSigningGroups, + includeClm: includeClm, skipPermissionProfileResourceType: skipPermissionProfileResourceType, }, nil } -// NewWithTokenSource takes no includeClm: the token source is minted by ConductorOne's -// OAuth flow, so this path can't influence which scopes were granted, and the CLM -// builders no longer gate their List() bodies on it. +// NewWithTokenSource's token source is minted by ConductorOne's OAuth flow, so this +// path can't influence which scopes were granted (unlike NewWithRefreshToken, where +// includeClm also drives buildScopes) — but it still needs includeClm to gate +// Validate()'s CLM readiness check, so it's threaded through for that purpose alone. func NewWithTokenSource( ctx context.Context, isDemo bool, tokenSource oauth2.TokenSource, accountId string, - includeSigningGroups bool, clmBaseURLOverride string, + includeSigningGroups, includeClm bool, clmBaseURLOverride string, skipPermissionProfileResourceType bool, ) (*Connector, error) { docusignClient := client.NewClient(ctx, isDemo, tokenSource, accountId, clmBaseURLOverride) @@ -178,6 +212,7 @@ func NewWithTokenSource( return &Connector{ client: docusignClient, includeSigningGroups: includeSigningGroups, + includeClm: includeClm, skipPermissionProfileResourceType: skipPermissionProfileResourceType, }, nil } @@ -186,7 +221,11 @@ func New(ctx context.Context, docusignCfg *cfg.Docusign, opts *cli.ConnectorOpts l := ctxzap.Extract(ctx) var cb *Connector - includeClm := opts.WillSyncResourceType(clmMemberResourceType.Id) || opts.WillSyncResourceType(clmRoleResourceType.Id) || + // nil opts means no filter, so nothing is skipped — every CLM type would sync too, + // the same "nil means unfiltered" convention skipPermissionProfileResourceType's + // guard below applies (inverted here, since this is an "include" flag, not a + // "skip" one). + includeClm := opts == nil || opts.WillSyncResourceType(clmMemberResourceType.Id) || opts.WillSyncResourceType(clmRoleResourceType.Id) || opts.WillSyncResourceType(clmGroupResourceType.Id) || opts.WillSyncResourceType(clmPermissionSetResourceType.Id) || opts.WillSyncResourceType(clmFolderResourceType.Id) @@ -205,7 +244,7 @@ func New(ctx context.Context, docusignCfg *cfg.Docusign, opts *cli.ConnectorOpts if opts.TokenSource != nil { cbWithTokenSource, err := NewWithTokenSource( ctx, isDemo, opts.TokenSource, docusignCfg.AccountId, - docusignCfg.IncludeSigningGroups, docusignCfg.ClmBaseUrl, + docusignCfg.IncludeSigningGroups, includeClm, docusignCfg.ClmBaseUrl, skipPermissionProfileResourceType, ) if err != nil { diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 7c4d779d..f8748ca0 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -2,9 +2,11 @@ package connector import ( "context" + "net/http" "testing" "github.com/conductorone/baton-docusign/pkg/client/clmtest" + "golang.org/x/oauth2" ) // alwaysRegisteredTypeIDs are the resource types ResourceSyncers registers on every @@ -12,9 +14,9 @@ import ( // registering conditionally would make ListResourceTypes() advertise fewer types than a // prior sync did, and C1 can then bucket every previously-synced resource and grant of a // vanished type as deleted. Gating happens via &v2.OptInRequired{} (resource_types.go) -// alone, not by omitting the builder — List() now fails loudly rather than tolerating an -// unavailable-feature error when a customer opts in without a reachable CLM subscription -// (see clm_roles.go's doc comment). +// alone, not by omitting the builder — Connector.Validate() fails the sync loudly, up +// front, rather than tolerating an unavailable-feature error, when a customer opts in +// without a reachable CLM subscription (see connector.go's Validate doc comment). var alwaysRegisteredTypeIDs = []string{ "user", "group", @@ -82,3 +84,112 @@ func TestResourceSyncers_SigningGroupRegistrationFollowsFlag(t *testing.T) { } } } + +// TestConnectorValidate covers both readiness checks Validate() centralizes (see +// connector.go's doc comment): EnsureReady (base eSignature credentials) runs +// unconditionally, EnsureClmReady only when includeClm is set. The includeClm=false +// subtests are the ones that matter most: one proves the CLM-specific check is +// genuinely skipped rather than coincidentally passing, the other proves Validate() now +// catches a misconfigured account upfront even when CLM was never opted into, instead +// of leaving that to whichever builder's List() happens to run first. +func TestConnectorValidate(t *testing.T) { + s, c := clmtest.NewServer(t) + badClmClient := s.NewClientWithToken("wrong-token") + ctx := context.Background() + + t.Run("includeClm=true, base and CLM both reachable: succeeds", func(t *testing.T) { + d := &Connector{client: c, includeClm: true} + if _, err := d.Validate(ctx); err != nil { + t.Errorf("expected Validate to succeed, got %v", err) + } + }) + + t.Run("includeClm=true, CLM unreachable: fails loudly", func(t *testing.T) { + d := &Connector{client: badClmClient, includeClm: true} + if _, err := d.Validate(ctx); err == nil { + t.Error("expected Validate to fail when CLM is unreachable, got nil error") + } + }) + + t.Run("includeClm=false, CLM unreachable but base fine: still succeeds", func(t *testing.T) { + // badClmClient's bad token only fails clmtest's requireAuth-gated CLM routes — + // its /oauth/userinfo (the base check) succeeds regardless of token (see + // clmtest/server.go's handleUserInfo) — so a nil error here proves the + // CLM-specific check was genuinely skipped, not just coincidentally passing. + d := &Connector{client: badClmClient, includeClm: false} + if _, err := d.Validate(ctx); err != nil { + t.Errorf("expected Validate to skip the CLM check (nil error) when includeClm is false, got %v", err) + } + }) + + t.Run("includeClm=false, base credentials bad: fails", func(t *testing.T) { + badBaseClient := newSigningGroupsTestClient(t, http.StatusUnauthorized) + d := &Connector{client: badBaseClient, includeClm: false} + if _, err := d.Validate(ctx); err == nil { + t.Error("expected Validate to fail on bad base credentials even when includeClm is false, got nil error") + } + }) +} + +// TestNewWithRefreshToken_StoresIncludeClm is a regression test for a real bug caught +// during review: NewWithRefreshToken already received includeClm as a parameter (used +// for OAuth scope selection via client.New) but silently dropped it instead of storing +// it on the returned Connector, so Validate() would never have run its CLM check for +// any connector built this way. A non-empty baseURLOverride makes client.New build a +// StaticTokenSource with zero network I/O (see client.go), so this needs no mock server. +func TestNewWithRefreshToken_StoresIncludeClm(t *testing.T) { + ctx := context.Background() + + for _, includeClm := range []bool{true, false} { + cb, err := NewWithRefreshToken( + ctx, false, "client-id", "client-secret", "https://redirect.example.com", + "refresh-token", "account-1", false, includeClm, + "https://clm.example.com", "https://api.example.com", false, + ) + if err != nil { + t.Fatalf("includeClm=%v: NewWithRefreshToken: %v", includeClm, err) + } + if cb.includeClm != includeClm { + t.Errorf("includeClm=%v: expected Connector.includeClm=%v, got %v", includeClm, includeClm, cb.includeClm) + } + } +} + +// TestNewWithClient_StoresIncludeClm is a regression test matching its two siblings +// above, for consistency — see NewWithClient's doc comment for why this constructor is +// kept despite having no caller anywhere in this repo today. nil is a valid client here +// since NewWithClient only stores it, never calls it (same pattern as newClmRoleBuilder(nil) +// elsewhere in this package). +func TestNewWithClient_StoresIncludeClm(t *testing.T) { + for _, includeClm := range []bool{true, false} { + cb, err := NewWithClient(nil, false, includeClm, false) + if err != nil { + t.Fatalf("includeClm=%v: NewWithClient: %v", includeClm, err) + } + if cb.includeClm != includeClm { + t.Errorf("includeClm=%v: expected Connector.includeClm=%v, got %v", includeClm, includeClm, cb.includeClm) + } + } +} + +// TestNewWithTokenSource_StoresIncludeClm is a regression test for the more serious of +// the two constructor gaps: NewWithTokenSource — the ConductorOne-hosted path, i.e. the +// common production case — had no includeClm parameter at all, so Validate() would have +// silently never checked CLM readiness for the majority deployment path. client.NewClient +// makes zero network I/O at construction (see client.go), so this needs no mock server. +func TestNewWithTokenSource_StoresIncludeClm(t *testing.T) { + ctx := context.Background() + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok"}) + + for _, includeClm := range []bool{true, false} { + cb, err := NewWithTokenSource( + ctx, false, tokenSource, "account-1", false, includeClm, "https://clm.example.com", false, + ) + if err != nil { + t.Fatalf("includeClm=%v: NewWithTokenSource: %v", includeClm, err) + } + if cb.includeClm != includeClm { + t.Errorf("includeClm=%v: expected Connector.includeClm=%v, got %v", includeClm, includeClm, cb.includeClm) + } + } +} diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 440f0b2f..6032aec3 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -41,8 +41,9 @@ var ( // CLM (Contract Lifecycle Management) resource types. CLM is a separate DocuSign // product/API surface from eSignature above. &v2.OptInRequired{} keeps these out of - // a customer's sync until explicitly enabled; once enabled, List() fails the sync - // loudly if the account can't actually reach CLM — see clm_roles.go's doc comment. + // a customer's sync until explicitly enabled; once enabled, Connector.Validate() + // fails the sync loudly, up front, if the account can't actually reach CLM — see + // that method's doc comment in connector.go. // clmMemberResourceType is CLM's own principal object. Deliberately NOT reusing // userResourceType's id ("user") — the CLM Members API is a distinct upstream From 60751be97b7b0708a99777f042b52db2228dd69a Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 18 Aug 2026 00:54:09 -0300 Subject: [PATCH 34/54] docs: narrow README's self-hosted/CLI language to the one-shot CLI case A self-hosted connector running in service mode still receives the platform's resource-type filter (confirmed in the c1 repo's rpc_baton.go SyncResourceTypeIds passthrough) -- only a bare one-shot CLI invocation, with no service/task involved at all, attempts every CLM resource type unconditionally. The prior wording lumped both cases together as "self-hosted/CLI." Co-Authored-By: Claude Sonnet 5 --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0e1a516c..d9fd0238 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,15 @@ misconfiguration to fix (disable the resource type, or activate the CLM feature) expected state to tolerate. `OptInRequired` is enforced by ConductorOne's platform, not by the connector or baton-sdk -itself — running `baton-docusign` directly (the quickstarts below, or any self-hosted/CLI -invocation) attempts all 5 CLM resource types by default, with no opt-in gate at all. If -that account doesn't have a CLM subscription, the sync now fails instead of skipping CLM -gracefully. Pass `--sync-resource-types` (or `BATON_SYNC_RESOURCE_TYPES`, comma-separated) -with the resource type IDs you actually want (e.g. `user,group,permission_profile`) to -exclude `clm_member,clm_role,clm_group,clm_permission_set,clm_folder` on an -eSignature-only account run this way. +itself — a self-hosted connector running in service mode still receives the platform's +resource-type filter, but running `baton-docusign` directly as a one-shot CLI sync (the +quickstarts below, with no service/task involved at all) attempts all 5 CLM resource +types by default, with no opt-in gate at all. If that account doesn't have a CLM +subscription, the sync now fails instead of skipping CLM gracefully. Pass +`--sync-resource-types` (or `BATON_SYNC_RESOURCE_TYPES`, comma-separated) with the +resource type IDs you actually want (e.g. `user,group,permission_profile`) to exclude +`clm_member,clm_role,clm_group,clm_permission_set,clm_folder` on an eSignature-only +account run this way. CLM permission sets sync for visibility only — DocuSign's CLM API has no endpoint to assign or unassign a permission set, so they cannot be granted or revoked through this From 09573b252d4e9a13f2936dbfa7997616f0ebdd6f Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 18 Aug 2026 00:54:14 -0300 Subject: [PATCH 35/54] fix: hoist ci.yaml's per-job concurrency block to workflow level 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. With test-groups/ test-signing-groups/test-permission-profiles needs-chained but each carrying its own copy of the same group, two overlapping workflow runs could still cancel each other's pending jobs mid-chain. Declaring it once at the workflow level instead makes the whole three-job run queue/cancel as one unit against the shared demo-account group. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yaml | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0503da22..4fad8e29 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,8 @@ on: # 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 +# same group when a newer one queues, so two overlapping workflow runs could cancel each +# other's pending jobs mid-chain. Declaring it once here instead makes the whole # needs-chained run (all three jobs) queue/cancel as one unit against the shared group. concurrency: group: docusign-demo-account @@ -38,14 +39,6 @@ env: jobs: test-groups: runs-on: ubuntu-latest - # Serializes against every other job in this file sharing this same group name - # (across every branch/PR, not just this one) — all three 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). - concurrency: - group: docusign-demo-account - cancel-in-progress: false env: BATON_LOG_LEVEL: debug BATON_DOCUSIGN_CLIENT_ID: ${{ secrets.CLIENTID }} @@ -75,10 +68,6 @@ jobs: test-signing-groups: needs: [test-groups] runs-on: ubuntu-latest - # See test-groups' identical setting above — same shared demo account. - concurrency: - group: docusign-demo-account - cancel-in-progress: false env: BATON_LOG_LEVEL: debug BATON_DOCUSIGN_CLIENT_ID: ${{ secrets.CLIENTID }} @@ -108,10 +97,6 @@ jobs: test-permission-profiles: needs: [test-signing-groups] runs-on: ubuntu-latest - # See test-groups' identical setting above — same shared demo account. - concurrency: - group: docusign-demo-account - cancel-in-progress: false env: BATON_LOG_LEVEL: debug BATON_DOCUSIGN_CLIENT_ID: ${{ secrets.CLIENTID }} From 49e2e6c43ef9f7772df657071be49818f041b6c6 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 18 Aug 2026 01:01:18 -0300 Subject: [PATCH 36/54] fix: correct stale signingGroupResourceType registration comment The comment claimed signing_group is "registered unconditionally ... OptInRequired is the gate, not a config flag, matching the CLM types below" -- but ResourceSyncers() only appends its builder when includeSigningGroups is set, contradicting the comment, TestResourceSyncers_SigningGroupRegistrationFollowsFlag's own doc, and singing_groups_test.go. Caught by the automated PR reviewer. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/resource_types.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 6032aec3..c716832a 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -29,9 +29,12 @@ var ( DisplayName: "Permission Profile", } - // signingGroupResourceType is registered unconditionally (see connector.go's - // ResourceSyncers) — OptInRequired is the gate, not a config flag, matching the - // CLM types below. + // signingGroupResourceType is registered only when includeSigningGroups is set (see + // connector.go's ResourceSyncers) — unlike the CLM types below, which are always + // registered and rely on OptInRequired alone. That means ListResourceTypes() + // advertises a different set depending on the flag; see + // TestResourceSyncers_SigningGroupRegistrationFollowsFlag for the tradeoff this + // carries. signingGroupResourceType = &v2.ResourceType{ Id: "signing_group", DisplayName: "Signing Group", From e2889b7e5ff3974b350dd4dd92cb157d16fdbd29 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 18 Aug 2026 17:53:14 -0300 Subject: [PATCH 37/54] fix: drop NewWithClient's self-contradictory compatibility rationale The doc comment justified keeping NewWithClient on "removing an exported function is a breaking change" -- but this same branch already changed its signature (and NewWithTokenSource's) to add includeClm, which breaks a hypothetical external caller just as hard as deletion would. Reworded to state plainly that it carries no compatibility guarantee and is kept only in sync with its siblings' fields. Caught by the automated PR reviewer. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/connector.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 93fa3be7..e83a6c39 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -184,11 +184,12 @@ func NewWithRefreshToken( // NewWithClient has no caller anywhere in this repo today (confirmed by repo-wide // grep) — unlike NewWithRefreshToken and NewWithTokenSource, nothing in New() ever -// constructs a Connector this way. Its purpose (an external test harness? a future call -// site?) isn't established anywhere in this codebase. Kept compiling and in sync with -// the other two constructors' fields — rather than deleted — since removing an exported -// function is a breaking change for any consumer of this module outside this repo that -// may exist. +// constructs a Connector this way, and its purpose (an external test harness? a future +// call site?) isn't established anywhere in this codebase. Its signature has already +// changed more than once as sibling constructors gained fields (most recently, +// includeClm) — so it carries no compatibility guarantee to preserve; it's kept purely +// in sync with its siblings' fields, not because removing or reshaping it would be a +// breaking change worth avoiding. func NewWithClient(client *client.Client, includeSigningGroups, includeClm bool, skipPermissionProfileResourceType bool) (*Connector, error) { return &Connector{ client: client, From 8cc9abf9455faf4156789e4460775a6e99d62541 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 18 Aug 2026 18:08:36 -0300 Subject: [PATCH 38/54] fix: delete dead NewWithClient constructor and its test The doc comment fixed in 6de1e1b for its self-contradictory rationale still claimed "no caller anywhere in this repo" while the very test added alongside it called NewWithClient directly -- and that test's own comment pointed back at a doc comment that, once corrected, no longer gave an affirmative reason to keep the constructor at all. Rather than keep patching the doc comment around a caller-less, never-stabilized constructor, delete both. Caught by the automated PR reviewer. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/connector.go | 17 ----------------- pkg/connector/connector_test.go | 17 ----------------- 2 files changed, 34 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index e83a6c39..b90d1134 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -182,23 +182,6 @@ func NewWithRefreshToken( }, nil } -// NewWithClient has no caller anywhere in this repo today (confirmed by repo-wide -// grep) — unlike NewWithRefreshToken and NewWithTokenSource, nothing in New() ever -// constructs a Connector this way, and its purpose (an external test harness? a future -// call site?) isn't established anywhere in this codebase. Its signature has already -// changed more than once as sibling constructors gained fields (most recently, -// includeClm) — so it carries no compatibility guarantee to preserve; it's kept purely -// in sync with its siblings' fields, not because removing or reshaping it would be a -// breaking change worth avoiding. -func NewWithClient(client *client.Client, includeSigningGroups, includeClm bool, skipPermissionProfileResourceType bool) (*Connector, error) { - return &Connector{ - client: client, - includeSigningGroups: includeSigningGroups, - includeClm: includeClm, - skipPermissionProfileResourceType: skipPermissionProfileResourceType, - }, nil -} - // NewWithTokenSource's token source is minted by ConductorOne's OAuth flow, so this // path can't influence which scopes were granted (unlike NewWithRefreshToken, where // includeClm also drives buildScopes) — but it still needs includeClm to gate diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index f8748ca0..9043a989 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -155,23 +155,6 @@ func TestNewWithRefreshToken_StoresIncludeClm(t *testing.T) { } } -// TestNewWithClient_StoresIncludeClm is a regression test matching its two siblings -// above, for consistency — see NewWithClient's doc comment for why this constructor is -// kept despite having no caller anywhere in this repo today. nil is a valid client here -// since NewWithClient only stores it, never calls it (same pattern as newClmRoleBuilder(nil) -// elsewhere in this package). -func TestNewWithClient_StoresIncludeClm(t *testing.T) { - for _, includeClm := range []bool{true, false} { - cb, err := NewWithClient(nil, false, includeClm, false) - if err != nil { - t.Fatalf("includeClm=%v: NewWithClient: %v", includeClm, err) - } - if cb.includeClm != includeClm { - t.Errorf("includeClm=%v: expected Connector.includeClm=%v, got %v", includeClm, includeClm, cb.includeClm) - } - } -} - // TestNewWithTokenSource_StoresIncludeClm is a regression test for the more serious of // the two constructor gaps: NewWithTokenSource — the ConductorOne-hosted path, i.e. the // common production case — had no includeClm parameter at all, so Validate() would have From f31ec4ccc834f250ddd0c0cf05453221e0f488d1 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 20 Aug 2026 00:26:00 -0300 Subject: [PATCH 39/54] fix: wrap Validate() errors with context, trim design-log comments Validate() previously returned raw client errors from EnsureReady/ EnsureClmReady with no indication of which readiness stage failed or what to do about it (flagged twice by the CI reviewer, never fixed). Wraps both branches with baton-docusign: context, and the CLM branch names the actual remedy. Also trims three comments that narrated past design attempts via commit SHAs (includeClm field doc, Validate()'s doc, a test's doc) down to the current invariant, matching this repo's established comment-tone convention. Doc note: CLM setup docs (README.md, docs/doc-info.md) were still missing the impersonation OAuth scope alongside spring_read/ spring_write, and didn't mention that an already-connected install needs --configure to re-consent since refresh tokens don't resend scopes. Co-Authored-By: Claude Sonnet 5 --- README.md | 7 +++++-- docs/doc-info.md | 3 ++- pkg/connector/connector.go | 22 ++++++++++++---------- pkg/connector/singing_groups_test.go | 11 +++++------ 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d9fd0238..52b857af 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,14 @@ Requirements: - Your DocuSign account must have a CLM production subscription. - **Demo environment or self-hosted with your own DocuSign app**: no extra setup — the - connector requests the additional CLM OAuth scopes (`spring_read`/`spring_write`) - automatically. + connector requests the additional CLM OAuth scopes (`impersonation`/`spring_read`/ + `spring_write`) automatically. - **Cloud-hosted production (ConductorOne's managed OAuth app)**: the managed app must also be granted the CLM API scopes on ConductorOne's platform side before any CLM data will sync. Contact ConductorOne if no CLM data appears in this mode. +- **Already-connected install**: an existing OAuth connection keeps its old consent on + refresh, so re-run `--configure` (re-consent) once to pick up the new `impersonation` + scope. The 5 CLM resource types are always registered and visible to C1, but each carries `OptInRequired` — C1 excludes them from a customer's sync by default, and they only run diff --git a/docs/doc-info.md b/docs/doc-info.md index 1cc78a14..c6855946 100644 --- a/docs/doc-info.md +++ b/docs/doc-info.md @@ -170,8 +170,9 @@ DocuSign Signing Groups are an optional feature. To sync signing groups: DocuSign CLM is a separate, separately-licensed DocuSign product. To sync CLM data: 1. Confirm your DocuSign account has a CLM production subscription. -2. Confirm the credential has been granted the CLM OAuth scopes (`spring_read`/`spring_write`). +2. Confirm the credential has been granted the CLM OAuth scopes (`impersonation`/`spring_read`/`spring_write`). 3. The connector then syncs CLM Members, Roles, Groups, Folders, Folder Security, and Permission Sets automatically — there is no flag to set. +4. An already-connected credential keeps its old consent on refresh (refresh tokens don't resend scopes) — re-run with `--configure` once to re-consent and pick up the new `impersonation` scope. If running against ConductorOne's managed OAuth app (the default cloud-hosted production authentication method), the managed app also needs the CLM API scopes diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index b90d1134..693356b6 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -27,9 +27,10 @@ type Connector struct { // includeClm reports whether this sync will touch any CLM resource type — the same // opts.WillSyncResourceType(...) signal that already determines whether any CLM // builder's List() gets invoked this run (see New()). Gates Validate()'s upfront CLM - // readiness check: does NOT gate resource-type registration (ResourceSyncers always - // registers all 5 CLM builders unconditionally — see that comment for why a - // registration-level gate was tried and reverted once before, in 9cbbd06/002a649). + // readiness check only: it does NOT gate resource-type registration. ResourceSyncers + // always registers all 5 CLM builders unconditionally, because toggling registration + // itself would make ListResourceTypes() advertise a different set between syncs and + // C1 would read previously-synced CLM resources/grants as deleted. includeClm bool // skipPermissionProfileResourceType reports whether permission_profile is // excluded from the sync filter. @@ -143,19 +144,20 @@ func (d *Connector) Metadata(_ context.Context) (*v2.ConnectorMetadata, error) { // happens to run first. EnsureReady (base eSignature credentials) runs unconditionally // — every sync needs those regardless of CLM — while EnsureClmReady is gated on // includeClm: an account that never opted into any CLM resource type has no reason to -// pay for, or fail on, a CLM discovery call it doesn't need — see this file's -// includeClm field doc for why this gate doesn't repeat the registration-level opt-in -// flag this connector already tried and reverted once (9cbbd06/002a649). See -// clm_roles.go's doc comment for the review discussion that led to centralizing the -// CLM check here instead of inside every opted-in CLM builder's own List(). +// pay for, or fail on, a CLM discovery call it doesn't need. This gate is separate from +// resource-type registration (see this file's includeClm field doc) and replaces each +// CLM builder's own List() checking readiness independently. func (d *Connector) Validate(ctx context.Context) (annotations.Annotations, error) { if err := d.client.EnsureReady(ctx); err != nil { - return nil, err + return nil, fmt.Errorf("baton-docusign: eSignature credential check failed: %w", err) } if !d.includeClm { return nil, nil } - return nil, d.client.EnsureClmReady(ctx) + if err := d.client.EnsureClmReady(ctx); err != nil { + return nil, fmt.Errorf("baton-docusign: CLM readiness check failed — clm_* resource types are enabled for this sync but this account/credential cannot reach the CLM API; disable those resource types or enable CLM on the account: %w", err) + } + return nil, nil } func NewWithRefreshToken( diff --git a/pkg/connector/singing_groups_test.go b/pkg/connector/singing_groups_test.go index caff7850..6f1af89f 100644 --- a/pkg/connector/singing_groups_test.go +++ b/pkg/connector/singing_groups_test.go @@ -79,12 +79,11 @@ func newSigningGroupsTestClient(t *testing.T, userInfoStatus int) *client.Client return client.NewClient(context.Background(), false, tokenSource, "", "", wrapper) } -// TestSigningGroupBuilder_List_FailsWhenUnavailable is a regression test for the -// fail-loud behavior change in 47f58c3: signing_group is gated behind the -// --include-signing-groups flag (connector.go), but that flag doesn't validate the -// account actually has the feature before letting an operator turn it on. List() must -// now propagate any error (here, a 401 from eSignature account discovery) instead of -// tolerating it and silently syncing zero signing groups. +// TestSigningGroupBuilder_List_FailsWhenUnavailable is a regression test: signing_group +// is gated behind the --include-signing-groups flag (connector.go), but that flag +// doesn't validate the account actually has the feature before letting an operator turn +// it on. List() must propagate any error (here, a 401 from eSignature account discovery) +// instead of tolerating it and silently syncing zero signing groups. func TestSigningGroupBuilder_List_FailsWhenUnavailable(t *testing.T) { c := newSigningGroupsTestClient(t, http.StatusUnauthorized) b := newSigningGroupBuilder(c) From 668a17bdcc6c204a7cc01e984538f9be8f56b6d9 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 20 Aug 2026 00:34:26 -0300 Subject: [PATCH 40/54] fix: wrap connector.go:158's error string to satisfy revive line-length-limit The CLM readiness remedy message added in d419057 was 246 characters on one line, over golangci-lint's 200-char limit (revive line-length-limit). Co-Authored-By: Claude Sonnet 5 --- pkg/connector/connector.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 693356b6..97b60420 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -155,7 +155,9 @@ func (d *Connector) Validate(ctx context.Context) (annotations.Annotations, erro return nil, nil } if err := d.client.EnsureClmReady(ctx); err != nil { - return nil, fmt.Errorf("baton-docusign: CLM readiness check failed — clm_* resource types are enabled for this sync but this account/credential cannot reach the CLM API; disable those resource types or enable CLM on the account: %w", err) + return nil, fmt.Errorf("baton-docusign: CLM readiness check failed — clm_* resource types "+ + "are enabled for this sync but this account/credential cannot reach the CLM API; "+ + "disable those resource types or enable CLM on the account: %w", err) } return nil, nil } From dac1f9202df0e64cd440b5bb25e4adcae89ff2bb Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 20 Aug 2026 00:44:02 -0300 Subject: [PATCH 41/54] fix: drop dead opts==nil guard, fix doc-info.md CLM opt-in contradiction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit includeClm's opts==nil guard was inert dead code — two lines below, opts.SelectedAuthMethod/opts.TokenSource already dereference opts unconditionally, so a nil opts would panic before the guard mattered. docs/doc-info.md's CLM setup step 3 said CLM syncs "automatically — there is no flag to set", directly contradicting the same file's line 48 (CLM types carry OptInRequired and require explicit opt-in in C1's sync configuration). Reworded to match. Co-Authored-By: Claude Sonnet 5 --- docs/doc-info.md | 2 +- pkg/connector/connector.go | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/doc-info.md b/docs/doc-info.md index c6855946..8683499f 100644 --- a/docs/doc-info.md +++ b/docs/doc-info.md @@ -171,7 +171,7 @@ DocuSign CLM is a separate, separately-licensed DocuSign product. To sync CLM da 1. Confirm your DocuSign account has a CLM production subscription. 2. Confirm the credential has been granted the CLM OAuth scopes (`impersonation`/`spring_read`/`spring_write`). -3. The connector then syncs CLM Members, Roles, Groups, Folders, Folder Security, and Permission Sets automatically — there is no flag to set. +3. The connector then syncs CLM Members, Roles, Groups, Folders, Folder Security, and Permission Sets once a customer explicitly enables each CLM resource type in C1's sync configuration (see the CLM note above — these types carry `OptInRequired`). 4. An already-connected credential keeps its old consent on refresh (refresh tokens don't resend scopes) — re-run with `--configure` once to re-consent and pick up the new `impersonation` scope. If running against ConductorOne's managed OAuth app (the default cloud-hosted diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 97b60420..315e6057 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -209,11 +209,7 @@ func New(ctx context.Context, docusignCfg *cfg.Docusign, opts *cli.ConnectorOpts l := ctxzap.Extract(ctx) var cb *Connector - // nil opts means no filter, so nothing is skipped — every CLM type would sync too, - // the same "nil means unfiltered" convention skipPermissionProfileResourceType's - // guard below applies (inverted here, since this is an "include" flag, not a - // "skip" one). - includeClm := opts == nil || opts.WillSyncResourceType(clmMemberResourceType.Id) || opts.WillSyncResourceType(clmRoleResourceType.Id) || + includeClm := opts.WillSyncResourceType(clmMemberResourceType.Id) || opts.WillSyncResourceType(clmRoleResourceType.Id) || opts.WillSyncResourceType(clmGroupResourceType.Id) || opts.WillSyncResourceType(clmPermissionSetResourceType.Id) || opts.WillSyncResourceType(clmFolderResourceType.Id) From d2f3a3e61e48bac428f410566c7e3b6c7885067c Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 21 Aug 2026 15:08:02 -0300 Subject: [PATCH 42/54] docs: trim folder-security helper comments per review Co-authored-by: Cursor --- pkg/connector/clm_folders.go | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/pkg/connector/clm_folders.go b/pkg/connector/clm_folders.go index 87e6fe7d..3d3cdba5 100644 --- a/pkg/connector/clm_folders.go +++ b/pkg/connector/clm_folders.go @@ -193,16 +193,9 @@ func (f *clmFolderBuilder) Grants(ctx context.Context, folderResource *v2.Resour return grants, &rs.SyncOpResults{Annotations: annos}, nil } -// logSkippedFolderSecurityEntry logs the one Debug line for a folder-security entry -// whose AccessType didn't map to a grantable tier — shared by the Groups/Roles/Users -// branches of Grants, which differ only in kind ("group"/"role"/"user", carried as a -// field rather than interpolated into the message, so both messages stay constant -// strings — no per-call fmt.Sprintf) and the caller-supplied fields identifying the -// entry. Custom gets its own message, since unlike NoAccess/InheritFromParentFolder -// (clmIsBenignUnmappedAccessType) it's a real, active grant this connector can't -// represent — fully silencing it would hide an actual access-visibility gap. Both -// branches carry access_type so either case is findable by the same structured-log -// query as every other skip line in this file. +// logSkippedFolderSecurityEntry Debug-logs an unmapped folder-security AccessType. +// Benign values (NoAccess / Inherit) return without logging; Custom logs as an +// unrepresentable active grant. kind is "group"|"role"|"user". func logSkippedFolderSecurityEntry(ctx context.Context, kind, accessType string, fields ...zap.Field) { if clmIsBenignUnmappedAccessType(accessType) { // The common steady-state case (NoAccess/InheritFromParentFolder, on every @@ -475,19 +468,8 @@ func clmSlugForAccessType(accessType string) (string, bool) { return "", false } -// clmIsBenignUnmappedAccessType reports whether accessType is one of the two documented -// non-grantable-but-truly-inert values every folder-security entry can legitimately -// carry — NoAccess (this connector's own Revoke leaves entries in place at this value, -// so it appears on every subsequent sync of a revoked entry) and InheritFromParentFolder -// (an absence-of-override marker — see clmFolderEntitlement's doc). Grants() skips these -// the same way it skips Custom, but stays fully silent for them, unlike Custom: neither -// represents an access grant C1 is failing to show, so logging them would only add -// per-sync noise for two expected states large accounts can produce on every sync. -// -// Custom is deliberately NOT in this set — see its own Debug log in -// logSkippedFolderSecurityEntry: it's a real, active grant this connector can't -// round-trip to a single tier (an arbitrary flag combination), so silencing it the same -// way would hide an actual access-visibility gap, not just an expected inert state. +// clmIsBenignUnmappedAccessType is true for NoAccess and InheritFromParentFolder — +// inert AccessTypes Grants() skips without logging. Custom is not benign. func clmIsBenignUnmappedAccessType(accessType string) bool { switch accessType { case client.ClmAccessTypeNoAccess, client.ClmAccessTypeInherit: From 85a5fdd34ee398e42a4e8c9dca40acc159ee97fe Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 02:48:52 -0300 Subject: [PATCH 43/54] docs: record the includeClm/Validate() gap as a reviewed, accepted tradeoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same finding (opts.WillSyncResourceType vs C1's real per-task filter) has been re-raised three times on this PR with identical file:line evidence. Documenting the decision inline, at the exact line the bot keeps flagging, so it reads as settled rather than an open item each time the diff gets re-reviewed. Also tightens README's self-hosted/service-mode claim: the platform filter does reach the syncer's List() dispatch, but not Validate() — that's the actual gap, not the sentence itself. --- README.md | 18 ++++++++++++------ pkg/connector/connector.go | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 52b857af..f478ec12 100644 --- a/README.md +++ b/README.md @@ -129,16 +129,22 @@ misconfiguration to fix (disable the resource type, or activate the CLM feature) expected state to tolerate. `OptInRequired` is enforced by ConductorOne's platform, not by the connector or baton-sdk -itself — a self-hosted connector running in service mode still receives the platform's -resource-type filter, but running `baton-docusign` directly as a one-shot CLI sync (the -quickstarts below, with no service/task involved at all) attempts all 5 CLM resource -types by default, with no opt-in gate at all. If that account doesn't have a CLM -subscription, the sync now fails instead of skipping CLM gracefully. Pass -`--sync-resource-types` (or `BATON_SYNC_RESOURCE_TYPES`, comma-separated) with the +itself — a self-hosted connector running in service mode still has its per-resource-type +`List()` calls filtered by the platform's opt-in selection (applied inside baton-sdk's +syncer, not surfaced to the connector's own code), but running `baton-docusign` directly +as a one-shot CLI sync (the quickstarts below, with no service/task involved at all) +attempts all 5 CLM resource types by default, with no opt-in gate at all. If that account +doesn't have a CLM subscription, the sync now fails instead of skipping CLM gracefully. +Pass `--sync-resource-types` (or `BATON_SYNC_RESOURCE_TYPES`, comma-separated) with the resource type IDs you actually want (e.g. `user,group,permission_profile`) to exclude `clm_member,clm_role,clm_group,clm_permission_set,clm_folder` on an eSignature-only account run this way. +One check does NOT see that platform filter in either deployment mode: `Connector.Validate()`'s +upfront CLM-readiness check (see its doc comment in `pkg/connector/connector.go`) runs once, +before any resource type's `List()` and before the platform filter is applied to anything — +a known, reviewed, and deliberately accepted gap, not an oversight. + CLM permission sets sync for visibility only — DocuSign's CLM API has no endpoint to assign or unassign a permission set, so they cannot be granted or revoked through this connector. diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 315e6057..9e8883c4 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -147,6 +147,24 @@ func (d *Connector) Metadata(_ context.Context) (*v2.ConnectorMetadata, error) { // pay for, or fail on, a CLM discovery call it doesn't need. This gate is separate from // resource-type registration (see this file's includeClm field doc) and replaces each // CLM builder's own List() checking readiness independently. +// +// # Known limitation, reviewed and accepted (not an open finding) +// +// includeClm is opts.WillSyncResourceType(...), sourced solely from the local +// --sync-resource-types/BATON_SYNC_RESOURCE_TYPES flag (baton-sdk pkg/cli/commands.go). +// ConductorOne's real per-task CLM opt-in is delivered on a separate path — +// pkg/tasks/c1api/full_sync.go's Task_SyncFullTask.GetSyncFull().GetSyncResourceTypeIds() +// feeds the syncer directly (pkg/sync/syncer.go) and never reaches ConnectorOpts. With +// no local flag set, includeClm is true on every ConductorOne-hosted and self-hosted +// service-mode run, regardless of whether that account opted into any clm_* type — so +// an eSignature-only account fails this entire sync below via EnsureClmReady, not just +// CLM. This has been raised and investigated multiple times (PR #63/#64 review history) +// with the same file:line evidence chain each time; the finding is accurate every time +// it resurfaces, but the fix (reverting to each CLM builder's own List()-time check, the +// design this replaced) was deliberately not taken: DocuSign authenticates via OAuth +// only, which makes the one deployment mode this gap actually affects — self-hosted / +// CLI service mode — assessed as impractical for real DocuSign customers. Re-flagging +// this exact gap is not new information; revisiting the tradeoff itself would be. func (d *Connector) Validate(ctx context.Context) (annotations.Annotations, error) { if err := d.client.EnsureReady(ctx); err != nil { return nil, fmt.Errorf("baton-docusign: eSignature credential check failed: %w", err) From 5edc9ee56a6530163acc439ea57dc90c5272bbdb Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 02:59:10 -0300 Subject: [PATCH 44/54] test: cover the actual removed tolerance in signing_groups' fail-loud test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing regression test forced a 401 on /oauth/userinfo — a base credential failure Connector.Validate()'s EnsureReady already catches for every resource type, never reaching the removed branch. The removed tolerance was for a 401/403/404 from /signing_groups itself. Added a second status knob and a test that actually exercises it. --- pkg/connector/connector_test.go | 2 +- pkg/connector/singing_groups_test.go | 39 +++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 9043a989..37775704 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -123,7 +123,7 @@ func TestConnectorValidate(t *testing.T) { }) t.Run("includeClm=false, base credentials bad: fails", func(t *testing.T) { - badBaseClient := newSigningGroupsTestClient(t, http.StatusUnauthorized) + badBaseClient := newSigningGroupsTestClient(t, http.StatusUnauthorized, http.StatusOK) d := &Connector{client: badBaseClient, includeClm: false} if _, err := d.Validate(ctx); err == nil { t.Error("expected Validate to fail on bad base credentials even when includeClm is false, got nil error") diff --git a/pkg/connector/singing_groups_test.go b/pkg/connector/singing_groups_test.go index 6f1af89f..89c410cf 100644 --- a/pkg/connector/singing_groups_test.go +++ b/pkg/connector/singing_groups_test.go @@ -28,11 +28,11 @@ const ( // newSigningGroupsTestClient builds a *client.Client wired to a mock server serving // /oauth/userinfo plus a minimal /signing_groups response (one seeded group). -// signingGroupBuilder.List()'s only failure path that matters here is ensureInitialized -// (called by GetSigningGroups before it ever reaches the signing-groups endpoint), so a -// full eSignature REST API mock isn't needed to exercise it — matching how the CLM -// builders' equivalent tests fail at CLM account discovery. -func newSigningGroupsTestClient(t *testing.T, userInfoStatus int) *client.Client { +// signingGroupsStatus forces the /signing_groups response itself to fail — the +// realistic "feature not enabled on this account" signal — independent of +// userInfoStatus, which only fails base account discovery. Pass http.StatusOK for +// either to use the normal (working) response. +func newSigningGroupsTestClient(t *testing.T, userInfoStatus, signingGroupsStatus int) *client.Client { t.Helper() var mockServer *httptest.Server mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -52,6 +52,11 @@ func newSigningGroupsTestClient(t *testing.T, userInfoStatus int) *client.Client return } if strings.HasSuffix(r.URL.Path, "/signing_groups") { + if signingGroupsStatus != http.StatusOK { + w.WriteHeader(signingGroupsStatus) + _ = json.NewEncoder(w).Encode(client.ErrorResponse{ErrorCode: "FEATURE_NOT_ENABLED", ErrorMessage: "Signing groups are not enabled for this account"}) + return + } // One seeded group is enough to exercise both the happy path and // parseIntoSigningGroupResource, without a full pagination fixture (no next // page: the zero-valued embedded Page makes getNextToken's @@ -85,7 +90,7 @@ func newSigningGroupsTestClient(t *testing.T, userInfoStatus int) *client.Client // it on. List() must propagate any error (here, a 401 from eSignature account discovery) // instead of tolerating it and silently syncing zero signing groups. func TestSigningGroupBuilder_List_FailsWhenUnavailable(t *testing.T) { - c := newSigningGroupsTestClient(t, http.StatusUnauthorized) + c := newSigningGroupsTestClient(t, http.StatusUnauthorized, http.StatusOK) b := newSigningGroupBuilder(c) ctx := context.Background() @@ -98,13 +103,33 @@ func TestSigningGroupBuilder_List_FailsWhenUnavailable(t *testing.T) { } } +// TestSigningGroupBuilder_List_FailsOnSigningGroupsEndpointError is a regression test +// for the tolerance the fail-loud commit actually removed: the old code caught a +// 401/403/404 from the /signing_groups endpoint itself (this account's real "feature +// not enabled" signal), not from base account discovery — the sibling test above forces +// a 401 on /oauth/userinfo instead, which Connector.Validate()'s EnsureReady already +// catches for every resource type and never reached the removed branch. +func TestSigningGroupBuilder_List_FailsOnSigningGroupsEndpointError(t *testing.T) { + c := newSigningGroupsTestClient(t, http.StatusOK, http.StatusForbidden) + b := newSigningGroupBuilder(c) + ctx := context.Background() + + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) + if err == nil { + t.Fatal("expected List to fail when the /signing_groups endpoint itself errors, got nil error") + } + if len(resources) != 0 { + t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) + } +} + // TestSigningGroupBuilder_List_Succeeds is a sanity check for // newSigningGroupsTestClient itself: confirms the happy path (account discovery // succeeds) reaches List()'s normal return and correctly parses the one seeded signing // group via parseIntoSigningGroupResource, distinguishing a correctly-wired mock from // the fail-loud test above passing only because everything errors regardless. func TestSigningGroupBuilder_List_Succeeds(t *testing.T) { - c := newSigningGroupsTestClient(t, http.StatusOK) + c := newSigningGroupsTestClient(t, http.StatusOK, http.StatusOK) b := newSigningGroupBuilder(c) ctx := context.Background() From 9ecc8d6268e1da2867c8ab5f2e78f2b229ccd7c7 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 02:59:10 -0300 Subject: [PATCH 45/54] docs: drop the impersonation scope claim, confirmed reverted from the code oauth.go's clmScopes has been spring_read/spring_write only since an earlier round of live testing confirmed impersonation is JWT-Grant-only and unneeded here; four doc mentions never caught up, including one instructing operators to re-consent for a scope the connector never requests. --- README.md | 7 ++----- docs/doc-info.md | 3 +-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f478ec12..f671571d 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,11 @@ Requirements: - Your DocuSign account must have a CLM production subscription. - **Demo environment or self-hosted with your own DocuSign app**: no extra setup — the - connector requests the additional CLM OAuth scopes (`impersonation`/`spring_read`/ - `spring_write`) automatically. + connector requests the additional CLM OAuth scopes (`spring_read`/`spring_write`) + automatically. - **Cloud-hosted production (ConductorOne's managed OAuth app)**: the managed app must also be granted the CLM API scopes on ConductorOne's platform side before any CLM data will sync. Contact ConductorOne if no CLM data appears in this mode. -- **Already-connected install**: an existing OAuth connection keeps its old consent on - refresh, so re-run `--configure` (re-consent) once to pick up the new `impersonation` - scope. The 5 CLM resource types are always registered and visible to C1, but each carries `OptInRequired` — C1 excludes them from a customer's sync by default, and they only run diff --git a/docs/doc-info.md b/docs/doc-info.md index 8683499f..cb75a414 100644 --- a/docs/doc-info.md +++ b/docs/doc-info.md @@ -170,9 +170,8 @@ DocuSign Signing Groups are an optional feature. To sync signing groups: DocuSign CLM is a separate, separately-licensed DocuSign product. To sync CLM data: 1. Confirm your DocuSign account has a CLM production subscription. -2. Confirm the credential has been granted the CLM OAuth scopes (`impersonation`/`spring_read`/`spring_write`). +2. Confirm the credential has been granted the CLM OAuth scopes (`spring_read`/`spring_write`). 3. The connector then syncs CLM Members, Roles, Groups, Folders, Folder Security, and Permission Sets once a customer explicitly enables each CLM resource type in C1's sync configuration (see the CLM note above — these types carry `OptInRequired`). -4. An already-connected credential keeps its old consent on refresh (refresh tokens don't resend scopes) — re-run with `--configure` once to re-consent and pick up the new `impersonation` scope. If running against ConductorOne's managed OAuth app (the default cloud-hosted production authentication method), the managed app also needs the CLM API scopes From 1b9391657e150c887b7d085bbdb67208c8786beb Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 02:59:10 -0300 Subject: [PATCH 46/54] docs: note ci.yaml's signing_group allowlist entry needs its per-job flag signing_group is only advertised by ListResourceTypes() when BATON_INCLUDE_SIGNING_GROUPS is set, and the SDK hard-errors on any sync-resource-types filter entry the connector doesn't advertise. Works today only because every job here also sets that flag. --- .github/workflows/ci.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4fad8e29..37c87a67 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,6 +26,14 @@ env: # resource type in pkg/connector/connector.go, add it here too — otherwise it silently # gets zero CI sync-test coverage. Declared once at the workflow level (all three jobs # inherit it) so there's no per-job copy to keep in sync. + # + # signing_group specifically only works here because every job below also sets + # BATON_INCLUDE_SIGNING_GROUPS: "true" — the connector only advertises signing_group + # via ListResourceTypes() when that flag is set (pkg/connector/connector.go), and the + # SDK hard-errors on any filter entry the connector doesn't advertise + # ("invalid resource type 'signing_group' in filter"). If a future job drops that + # per-job env var, it fails with that confusing filter error instead of just skipping + # signing groups. BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group # Forces the legacy v1/SQLite c1z format instead of baton-sdk v0.25.0's new default # (Pebble/v3). The `baton` CLI these jobs download (ConductorOne/github-workflows' From 023b3c26a52c1d31fec9c941dd8467a3ae6b4828 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 02:59:10 -0300 Subject: [PATCH 47/54] fix: drop clmRoleBuilder's dead client field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unused since the CLM readiness check moved to Connector.Validate() — clm_role never calls the API at all. The test previously spun up a full clmtest mock server solely to populate a field nothing reads. --- pkg/connector/clm_roles.go | 7 +++---- pkg/connector/clm_roles_test.go | 8 +++----- pkg/connector/connector.go | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/pkg/connector/clm_roles.go b/pkg/connector/clm_roles.go index 7bba47e7..ce65622f 100644 --- a/pkg/connector/clm_roles.go +++ b/pkg/connector/clm_roles.go @@ -12,10 +12,10 @@ import ( // backed by an API call — see resource_types.go for why this resource type exists. // CLM availability is checked once, up front, by Connector.Validate() rather than here // — see that method's doc for why centralizing it there is better than every opted-in -// CLM builder repeating the same check on its own first page. +// CLM builder repeating the same check on its own first page. Unlike every other CLM +// builder, this one never calls the API at all, so it holds no *client.Client. type clmRoleBuilder struct { resourceType *v2.ResourceType - client *client.Client } func (b *clmRoleBuilder) ResourceType(_ context.Context) *v2.ResourceType { @@ -55,9 +55,8 @@ func (b *clmRoleBuilder) Grants(_ context.Context, _ *v2.Resource, _ rs.SyncOpAt return nil, nil, nil } -func newClmRoleBuilder(c *client.Client) *clmRoleBuilder { +func newClmRoleBuilder() *clmRoleBuilder { return &clmRoleBuilder{ resourceType: clmRoleResourceType, - client: c, } } diff --git a/pkg/connector/clm_roles_test.go b/pkg/connector/clm_roles_test.go index 0f051dff..c8b4a7dc 100644 --- a/pkg/connector/clm_roles_test.go +++ b/pkg/connector/clm_roles_test.go @@ -5,16 +5,14 @@ import ( "testing" "github.com/conductorone/baton-docusign/pkg/client" - "github.com/conductorone/baton-docusign/pkg/client/clmtest" rs "github.com/conductorone/baton-sdk/pkg/types/resource" ) func TestClmRoleBuilder_List(t *testing.T) { // The role set isn't backed by an API call at all — CLM availability is checked // once, up front, by Connector.Validate() (see connector_test.go), not here — so - // this only needs a client to satisfy the builder's field, never calls it. - _, c := clmtest.NewServer(t) - b := newClmRoleBuilder(c) + // this needs no client at all. + b := newClmRoleBuilder() ctx := context.Background() resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{}) @@ -35,7 +33,7 @@ func TestClmRoleBuilder_List(t *testing.T) { } func TestClmRoleBuilder_EntitlementsAndGrants_AreNoop(t *testing.T) { - b := newClmRoleBuilder(nil) + b := newClmRoleBuilder() ctx := context.Background() roleResource, err := rs.NewRoleResource("FullSubscriber", clmRoleResourceType, "FullSubscriber", nil) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 9e8883c4..bc3fd66f 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -81,7 +81,7 @@ func (d *Connector) ResourceSyncers(_ context.Context) []connectorbuilder.Resour newGroupBuilder(d.client), newPermissionProfilesBuilder(d.client), newClmMemberBuilder(d.client), - newClmRoleBuilder(d.client), + newClmRoleBuilder(), newClmGroupBuilder(d.client), newClmPermissionSetBuilder(d.client), newClmFolderBuilder(d.client), From c7d5496a1d047a81950163afd4b78cc43373a5cc Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 03:09:46 -0300 Subject: [PATCH 48/54] docs: correct scope claim in the Validate()/includeClm decision record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment's own mechanism description already said includeClm is true on hosted and self-hosted runs alike, but the accepted-risk conclusion narrowed impact to self-hosted only — a non sequitur a reviewer correctly called out. The actual reasoning behind the accepted risk was never about which deployment mode is affected: it's a design position that a connector should fail its entire sync when it can't trust whether an enabled resource type is reachable, rather than guess. --- pkg/connector/connector.go | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index bc3fd66f..cd446d75 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -154,17 +154,23 @@ func (d *Connector) Metadata(_ context.Context) (*v2.ConnectorMetadata, error) { // --sync-resource-types/BATON_SYNC_RESOURCE_TYPES flag (baton-sdk pkg/cli/commands.go). // ConductorOne's real per-task CLM opt-in is delivered on a separate path — // pkg/tasks/c1api/full_sync.go's Task_SyncFullTask.GetSyncFull().GetSyncResourceTypeIds() -// feeds the syncer directly (pkg/sync/syncer.go) and never reaches ConnectorOpts. With -// no local flag set, includeClm is true on every ConductorOne-hosted and self-hosted -// service-mode run, regardless of whether that account opted into any clm_* type — so -// an eSignature-only account fails this entire sync below via EnsureClmReady, not just -// CLM. This has been raised and investigated multiple times (PR #63/#64 review history) -// with the same file:line evidence chain each time; the finding is accurate every time -// it resurfaces, but the fix (reverting to each CLM builder's own List()-time check, the -// design this replaced) was deliberately not taken: DocuSign authenticates via OAuth -// only, which makes the one deployment mode this gap actually affects — self-hosted / -// CLI service mode — assessed as impractical for real DocuSign customers. Re-flagging -// this exact gap is not new information; revisiting the tradeoff itself would be. +// feeds the syncer directly (pkg/sync/syncer.go) and never reaches ConnectorOpts. With no +// local flag set, includeClm is true on every ConductorOne-hosted and self-hosted run +// alike, regardless of whether that account opted into any clm_* type — so an +// eSignature-only account fails this entire sync below via EnsureClmReady, not just CLM. +// This has been raised and investigated multiple times (PR #63/#64 review history) with +// the same file:line evidence chain each time; the finding is accurate every time it +// resurfaces, and it is NOT scoped to self-hosted deployment specifically — it affects +// ConductorOne-hosted accounts too. +// +// The fix (reverting to each CLM builder's own List()-time check, the design this +// replaced) was deliberately not taken. This isn't a bet that the affected deployment +// mode is rare: it's a design position, confirmed with the team, that a connector should +// fail its entire sync when it can't determine whether an enabled resource type is +// actually reachable, rather than guess and risk silently syncing partial/zero data for +// a misconfigured account. An account whose CLM signal this connector can't trust is, by +// that standard, exactly the case that should fail loud. Re-flagging this exact gap is +// not new information; revisiting that design position itself would be. func (d *Connector) Validate(ctx context.Context) (annotations.Annotations, error) { if err := d.client.EnsureReady(ctx); err != nil { return nil, fmt.Errorf("baton-docusign: eSignature credential check failed: %w", err) From 16a9ef126b9e87ddc844c9adc00814fe7e1bec74 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 16:44:33 -0300 Subject: [PATCH 49/54] test: pin New()'s includeClm derivation from opts.SyncResourceTypeIDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing tests cover the constructors storing includeClm correctly, but nothing exercised New()'s own opts.WillSyncResourceType(clm*) disjunction that computes it — the exact logic CI's own BATON_SYNC_RESOURCE_TYPES allowlist depends on to keep includeClm=false. A renamed clm_* resource type ID or a dropped disjunction term would have gone uncaught. --- pkg/connector/connector_test.go | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 37775704..3a097f89 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/conductorone/baton-docusign/pkg/client/clmtest" + cfg "github.com/conductorone/baton-docusign/pkg/config" + "github.com/conductorone/baton-sdk/pkg/cli" "golang.org/x/oauth2" ) @@ -176,3 +178,46 @@ func TestNewWithTokenSource_StoresIncludeClm(t *testing.T) { } } } + +// TestNew_IncludeClmDerivation pins New()'s opts.WillSyncResourceType(clm*) disjunction +// (connector.go) directly — CI's own BATON_SYNC_RESOURCE_TYPES allowlist depends on this +// exact logic to keep includeClm=false, and none of the other tests here exercise it: a +// renamed clm_* resource type ID or a term dropped from the disjunction would silently +// stop being caught by anything else in this file. Routes through opts.TokenSource +// (client.NewClient makes zero network I/O at construction, see client.go), so this +// needs no mock server and no refresh token. +func TestNew_IncludeClmDerivation(t *testing.T) { + ctx := context.Background() + docusignCfg := &cfg.Docusign{DocusignClientId: "client-id", DocusignClientSecret: "client-secret"} + tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok"}) + + tests := []struct { + name string + syncResourceTypeIDs []string + wantIncludeClm bool + }{ + {"no filter (opts.SyncResourceTypeIDs empty): syncs everything, including CLM", nil, true}, + {"CI's actual allowlist: no clm_* type present", []string{"user", "group", "permission_profile", "signing_group"}, false}, + {"clm_member present", []string{"user", clmMemberResourceType.Id}, true}, + {"clm_role present", []string{"user", clmRoleResourceType.Id}, true}, + {"clm_group present", []string{"user", clmGroupResourceType.Id}, true}, + {"clm_permission_set present", []string{"user", clmPermissionSetResourceType.Id}, true}, + {"clm_folder present", []string{"user", clmFolderResourceType.Id}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &cli.ConnectorOpts{TokenSource: tokenSource, SyncResourceTypeIDs: tt.syncResourceTypeIDs} + built, _, err := New(ctx, docusignCfg, opts) + if err != nil { + t.Fatalf("New: %v", err) + } + cb, ok := built.(*Connector) + if !ok { + t.Fatalf("New returned %T, expected *Connector", built) + } + if cb.includeClm != tt.wantIncludeClm { + t.Errorf("SyncResourceTypeIDs=%v: expected includeClm=%v, got %v", tt.syncResourceTypeIDs, tt.wantIncludeClm, cb.includeClm) + } + }) + } +} From 8bd0005e2091021d71ae8f4db457659ed6e8a696 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 17:36:15 -0300 Subject: [PATCH 50/54] fix: derive TestNew_IncludeClmDerivation's non-CLM allowlist from alwaysRegisteredTypeIDs The "CI's actual allowlist" case hardcoded the same 4 IDs ci.yaml's BATON_SYNC_RESOURCE_TYPES declares independently, so the two could drift apart with no test failing. Deriving it from alwaysRegisteredTypeIDs (the connector's own source of truth) minus clm_* entries means registering a new non-CLM resource type there surfaces here too. --- pkg/connector/connector_test.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 3a097f89..14854cc1 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -3,6 +3,7 @@ package connector import ( "context" "net/http" + "strings" "testing" "github.com/conductorone/baton-docusign/pkg/client/clmtest" @@ -179,6 +180,23 @@ func TestNewWithTokenSource_StoresIncludeClm(t *testing.T) { } } +// nonClmAllowlist derives the "no CLM types opted in" test case from +// alwaysRegisteredTypeIDs — the connector's own source of truth for always-registered +// resource types — instead of a second hardcoded literal, so registering a new non-CLM +// resource type there surfaces here too rather than the two lists silently drifting +// apart. signing_group is added separately since it's intentionally NOT in +// alwaysRegisteredTypeIDs (conditionally registered, see that var's doc) but is present +// in CI's real BATON_SYNC_RESOURCE_TYPES allowlist (ci.yaml). +func nonClmAllowlist() []string { + ids := []string{"signing_group"} + for _, id := range alwaysRegisteredTypeIDs { + if !strings.HasPrefix(id, "clm_") { + ids = append(ids, id) + } + } + return ids +} + // TestNew_IncludeClmDerivation pins New()'s opts.WillSyncResourceType(clm*) disjunction // (connector.go) directly — CI's own BATON_SYNC_RESOURCE_TYPES allowlist depends on this // exact logic to keep includeClm=false, and none of the other tests here exercise it: a @@ -197,7 +215,7 @@ func TestNew_IncludeClmDerivation(t *testing.T) { wantIncludeClm bool }{ {"no filter (opts.SyncResourceTypeIDs empty): syncs everything, including CLM", nil, true}, - {"CI's actual allowlist: no clm_* type present", []string{"user", "group", "permission_profile", "signing_group"}, false}, + {"CI's actual allowlist: no clm_* type present", nonClmAllowlist(), false}, {"clm_member present", []string{"user", clmMemberResourceType.Id}, true}, {"clm_role present", []string{"user", clmRoleResourceType.Id}, true}, {"clm_group present", []string{"user", clmGroupResourceType.Id}, true}, From 15264e587c45216d97eefde073f00c975abe7f68 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 17:44:43 -0300 Subject: [PATCH 51/54] fix: enforce nonClmAllowlist() against ci.yaml, not just against itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior fix's doc comment claimed registering a new non-CLM resource type "surfaces here too", but nothing actually failed in that case — the real drift (ci.yaml's BATON_SYNC_RESOURCE_TYPES falling behind) stayed unenforced since nothing read ci.yaml. Narrowed that comment to what it actually guarantees, and added a test that parses the real workflow file and asserts its allowlist matches nonClmAllowlist(). --- go.mod | 2 +- pkg/connector/connector_test.go | 46 +++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 331d4431..9b05d5ed 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( golang.org/x/oauth2 v0.36.0 google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -135,7 +136,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 14854cc1..404620c0 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -3,6 +3,9 @@ package connector import ( "context" "net/http" + "os" + "reflect" + "sort" "strings" "testing" @@ -10,6 +13,7 @@ import ( cfg "github.com/conductorone/baton-docusign/pkg/config" "github.com/conductorone/baton-sdk/pkg/cli" "golang.org/x/oauth2" + "gopkg.in/yaml.v3" ) // alwaysRegisteredTypeIDs are the resource types ResourceSyncers registers on every @@ -182,11 +186,12 @@ func TestNewWithTokenSource_StoresIncludeClm(t *testing.T) { // nonClmAllowlist derives the "no CLM types opted in" test case from // alwaysRegisteredTypeIDs — the connector's own source of truth for always-registered -// resource types — instead of a second hardcoded literal, so registering a new non-CLM -// resource type there surfaces here too rather than the two lists silently drifting -// apart. signing_group is added separately since it's intentionally NOT in -// alwaysRegisteredTypeIDs (conditionally registered, see that var's doc) but is present -// in CI's real BATON_SYNC_RESOURCE_TYPES allowlist (ci.yaml). +// resource types — instead of a second hardcoded literal, so there's only one list to +// keep in sync with reality (this doesn't by itself catch ci.yaml drifting from this +// value; TestNonClmAllowlistMatchesCI below asserts that separately). signing_group is +// added separately since it's intentionally NOT in alwaysRegisteredTypeIDs +// (conditionally registered, see that var's doc) but is present in CI's real +// BATON_SYNC_RESOURCE_TYPES allowlist (ci.yaml). func nonClmAllowlist() []string { ids := []string{"signing_group"} for _, id := range alwaysRegisteredTypeIDs { @@ -197,6 +202,37 @@ func nonClmAllowlist() []string { return ids } +// TestNonClmAllowlistMatchesCI is the actual enforcement ci.yaml's own comment asks +// for: a new non-CLM resource type in alwaysRegisteredTypeIDs (and so in +// nonClmAllowlist()) is worthless as a drift guard unless something also checks +// ci.yaml's BATON_SYNC_RESOURCE_TYPES against it. Parses the real workflow file rather +// than duplicating its value a third time. +func TestNonClmAllowlistMatchesCI(t *testing.T) { + data, err := os.ReadFile("../../.github/workflows/ci.yaml") + if err != nil { + t.Fatalf("reading ci.yaml: %v", err) + } + var workflow struct { + Env map[string]string `yaml:"env"` + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatalf("parsing ci.yaml: %v", err) + } + raw, ok := workflow.Env["BATON_SYNC_RESOURCE_TYPES"] + if !ok { + t.Fatal("ci.yaml's workflow-level env has no BATON_SYNC_RESOURCE_TYPES — did it move to a per-job env block?") + } + + got := strings.Split(raw, ",") + want := nonClmAllowlist() + sort.Strings(got) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Errorf("ci.yaml's BATON_SYNC_RESOURCE_TYPES (%v) doesn't match nonClmAllowlist() (%v) — "+ + "update ci.yaml's allowlist (or alwaysRegisteredTypeIDs) to match", got, want) + } +} + // TestNew_IncludeClmDerivation pins New()'s opts.WillSyncResourceType(clm*) disjunction // (connector.go) directly — CI's own BATON_SYNC_RESOURCE_TYPES allowlist depends on this // exact logic to keep includeClm=false, and none of the other tests here exercise it: a From ad17858fcdb910034fcdd45276f374858363dcc4 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 24 Aug 2026 17:51:32 -0300 Subject: [PATCH 52/54] fix: scope TestNonClmAllowlistMatchesCI's failure to the key it actually guards Decoding the workflow-level env block into map[string]string made this test brittle to unrelated CI edits: yaml.v3 returns a TypeError for any non-string scalar, so adding e.g. BATON_FOO: true elsewhere in that block would fail the whole decode instead of the allowlist assertion. Decode into map[string]any and type-assert only BATON_SYNC_RESOURCE_TYPES. --- pkg/connector/connector_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 404620c0..0db4b2a5 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -212,16 +212,24 @@ func TestNonClmAllowlistMatchesCI(t *testing.T) { if err != nil { t.Fatalf("reading ci.yaml: %v", err) } + // map[string]any, not map[string]string: an unrelated non-string workflow-level env + // var (e.g. BATON_FOO: true) would otherwise fail the whole decode with a + // yaml.TypeError, failing this test somewhere unrelated to the one key it actually + // guards. var workflow struct { - Env map[string]string `yaml:"env"` + Env map[string]any `yaml:"env"` } if err := yaml.Unmarshal(data, &workflow); err != nil { t.Fatalf("parsing ci.yaml: %v", err) } - raw, ok := workflow.Env["BATON_SYNC_RESOURCE_TYPES"] + value, ok := workflow.Env["BATON_SYNC_RESOURCE_TYPES"] if !ok { t.Fatal("ci.yaml's workflow-level env has no BATON_SYNC_RESOURCE_TYPES — did it move to a per-job env block?") } + raw, ok := value.(string) + if !ok { + t.Fatalf("ci.yaml's BATON_SYNC_RESOURCE_TYPES decoded as %T, expected a string", value) + } got := strings.Split(raw, ",") want := nonClmAllowlist() From ce2c22e94f1cbf39bc6dc43a7238a8c10013074d Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 25 Aug 2026 17:29:57 -0300 Subject: [PATCH 53/54] docs: trim history comments per review Drop the includeClm/Validate() decision-record section from connector.go (the team already knows it) and shorten ci.yaml's concurrency-block comment to the essential why. --- .github/workflows/ci.yaml | 12 ++++-------- pkg/connector/connector.go | 24 ------------------------ 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 37c87a67..b3ecae4a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,14 +5,10 @@ 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, so two overlapping workflow runs could cancel each -# other's pending jobs mid-chain. Declaring it once here instead makes the whole -# needs-chained run (all three jobs) queue/cancel as one unit against the shared group. +# Workflow-level, not per-job: all three jobs share the DocuSign demo account, and a +# per-job block only protects a *running* job — GitHub Actions still cancels a *pending* +# job in the same group when a newer run queues. Declaring it once here queues/cancels +# all three jobs as one unit. concurrency: group: docusign-demo-account cancel-in-progress: false diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index cd446d75..26b1ccf6 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -147,30 +147,6 @@ func (d *Connector) Metadata(_ context.Context) (*v2.ConnectorMetadata, error) { // pay for, or fail on, a CLM discovery call it doesn't need. This gate is separate from // resource-type registration (see this file's includeClm field doc) and replaces each // CLM builder's own List() checking readiness independently. -// -// # Known limitation, reviewed and accepted (not an open finding) -// -// includeClm is opts.WillSyncResourceType(...), sourced solely from the local -// --sync-resource-types/BATON_SYNC_RESOURCE_TYPES flag (baton-sdk pkg/cli/commands.go). -// ConductorOne's real per-task CLM opt-in is delivered on a separate path — -// pkg/tasks/c1api/full_sync.go's Task_SyncFullTask.GetSyncFull().GetSyncResourceTypeIds() -// feeds the syncer directly (pkg/sync/syncer.go) and never reaches ConnectorOpts. With no -// local flag set, includeClm is true on every ConductorOne-hosted and self-hosted run -// alike, regardless of whether that account opted into any clm_* type — so an -// eSignature-only account fails this entire sync below via EnsureClmReady, not just CLM. -// This has been raised and investigated multiple times (PR #63/#64 review history) with -// the same file:line evidence chain each time; the finding is accurate every time it -// resurfaces, and it is NOT scoped to self-hosted deployment specifically — it affects -// ConductorOne-hosted accounts too. -// -// The fix (reverting to each CLM builder's own List()-time check, the design this -// replaced) was deliberately not taken. This isn't a bet that the affected deployment -// mode is rare: it's a design position, confirmed with the team, that a connector should -// fail its entire sync when it can't determine whether an enabled resource type is -// actually reachable, rather than guess and risk silently syncing partial/zero data for -// a misconfigured account. An account whose CLM signal this connector can't trust is, by -// that standard, exactly the case that should fail loud. Re-flagging this exact gap is -// not new information; revisiting that design position itself would be. func (d *Connector) Validate(ctx context.Context) (annotations.Annotations, error) { if err := d.client.EnsureReady(ctx); err != nil { return nil, fmt.Errorf("baton-docusign: eSignature credential check failed: %w", err) From d9ad380484b2dd64f8d60c259f24c3ef7a3cb4fa Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 25 Aug 2026 17:49:29 -0300 Subject: [PATCH 54/54] docs: drop dead cross-reference to Validate()'s trimmed doc comment ce2c22e removed the "Known limitation" section this pointed at; the README paragraph already carries the full rationale on its own. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f671571d..859fd50c 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,9 @@ resource type IDs you actually want (e.g. `user,group,permission_profile`) to ex account run this way. One check does NOT see that platform filter in either deployment mode: `Connector.Validate()`'s -upfront CLM-readiness check (see its doc comment in `pkg/connector/connector.go`) runs once, -before any resource type's `List()` and before the platform filter is applied to anything — -a known, reviewed, and deliberately accepted gap, not an oversight. +upfront CLM-readiness check runs once, before any resource type's `List()` and before the +platform filter is applied to anything — a known, reviewed, and deliberately accepted gap, +not an oversight. CLM permission sets sync for visibility only — DocuSign's CLM API has no endpoint to assign or unassign a permission set, so they cannot be granted or revoked through this