From d950095cf22ad41ca1ac9db2b5bb5e7ee038791d Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 10:44:12 -0300 Subject: [PATCH 01/50] feat: add clm_workflow_queue resource type (Pylon #11836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 4a from the CXH-1975 QE validation notes: syncs DocuSign CLM's WorkflowQueue object — the API's own term for what the CLM admin console reportedly calls "Task Groups", the surface the customer (The Trade Desk) explicitly asked for. That naming equivalence is an unconfirmed assumption, not a documented fact; the resource type is named after the API term (clm_workflow_queue), not the UI term, until someone with access to a real CLM admin console can confirm it. The API has no list-all endpoint for workflow queues and no reverse lookup from a queue to its members — only a per-member read (GET .../members/{id}/workflowqueues). So List() discovers the distinct set by scanning every clm_member once and deduping, and — as a side effect of that same scan — builds a queueID -> []memberID index in the SDK's session cache (newly enabled via connectorrunner.WithSessionStoreEnabled in main.go, previously unused in this connector) so Grants() can read a queue's membership back out directly instead of re-scanning every member per queue, which would turn one O(members) traversal into O(queues * members) — a real cost given the open rate-limit issue on this connector for the same customer (CXP-704). Read-only: the API documents work-item assign/unassign, not queue-membership grant/revoke, so there's no Grant/Revoke here, matching clm_permission_set's precedent for a CLM object with no write endpoint. Like every other CLM endpoint in this connector, the response shapes are documented-but-unexercised — no live CLM tenant was available to confirm them. Co-Authored-By: Claude Sonnet 5 --- README.md | 23 ++- cmd/baton-docusign/main.go | 5 + docs/connector.mdx | 3 +- docs/doc-info.md | 5 +- pkg/client/clm_client.go | 60 +++++++ pkg/client/clm_client_test.go | 31 ++++ pkg/client/clm_models.go | 19 ++ pkg/client/clmtest/handlers.go | 22 +++ pkg/client/clmtest/seed.go | 21 +++ pkg/client/clmtest/server.go | 23 ++- pkg/connector/clm_workflow_queues.go | 193 ++++++++++++++++++++ pkg/connector/clm_workflow_queues_test.go | 208 ++++++++++++++++++++++ pkg/connector/connector.go | 1 + pkg/connector/connector_test.go | 1 + pkg/connector/resource_types.go | 14 ++ 15 files changed, 615 insertions(+), 14 deletions(-) create mode 100644 pkg/connector/clm_workflow_queues.go create mode 100644 pkg/connector/clm_workflow_queues_test.go diff --git a/README.md b/README.md index 196ab938..14147613 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Check out [Baton](https://github.com/conductorone/baton) to learn more about the - Groups - Signing Groups - Permission Profiles -- CLM Members, Roles, Groups, Folders, Folder Security, and Permission Sets (requires a DocuSign CLM subscription — see [CLM Support](#clm-support)) +- CLM Members, Roles, Groups, Folders, Folder Security, Permission Sets, and Workflow Queues (requires a DocuSign CLM subscription — see [CLM Support](#clm-support)) ### Provisioning Support @@ -25,6 +25,7 @@ Check out [Baton](https://github.com/conductorone/baton) to learn more about the - CLM group membership (grant/revoke, requires a CLM subscription) - CLM folder security (grant/revoke, requires a CLM subscription) - CLM permission sets are synced for visibility only — the CLM API has no assignment endpoint, so they cannot be granted or revoked +- CLM workflow queue membership is synced for visibility only — the CLM API supports work-item assign/unassign, not queue-membership grant/revoke, so it cannot be granted or revoked here ## Connector Credentials @@ -101,9 +102,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, permission sets, and workflow queues sync alongside the +standard eSignature resources, with no config flag to enable — accounts that don't have +CLM simply sync no CLM resources. Requirements: @@ -115,7 +116,7 @@ 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 +The 6 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 @@ -125,6 +126,18 @@ CLM permission sets sync for visibility only — DocuSign's CLM API has no endpo assign or unassign a permission set, so they cannot be granted or revoked through this connector. +CLM workflow queues (`clm_workflow_queue`) map to what the CLM admin console reportedly +calls "Task Groups" — that equivalence is an unconfirmed assumption, not a documented +fact, since no live CLM admin console was available to check it against. The CLM API has +no list-all endpoint for workflow queues and no reverse lookup from a queue to its +members, so this connector discovers them by scanning every `clm_member`'s own workflow +queues and deduping — one API call per member, on top of the member sync itself. This +adds meaningful request volume on large accounts; see the open rate-limit issue tracked +as CXP-704 before enabling this on an account already seeing rate-limit errors. Workflow +queue membership syncs for visibility only — the API supports work-item assign/unassign, +not queue-membership grant/revoke, so it cannot be granted or revoked through this +connector. + The CLM Object API's base URL is resolved via a separate account discovery call (`GET /api/v2/{accountId}/account` on `auth.springcm.com`/`authuat.springcm.com`, authenticated with the same access token), confirmed via DocuSign's CLM API 101 diff --git a/cmd/baton-docusign/main.go b/cmd/baton-docusign/main.go index d26bd622..e77cb5f8 100644 --- a/cmd/baton-docusign/main.go +++ b/cmd/baton-docusign/main.go @@ -9,6 +9,7 @@ import ( "github.com/conductorone/baton-sdk/pkg/cli" "github.com/conductorone/baton-sdk/pkg/config" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/connectorrunner" ) var version = "dev" @@ -32,5 +33,9 @@ func main() { version, cfg.ConfigurationSchema, connectorFn, + // clm_workflow_queue's List() needs to cache a member->queue-membership index + // across the whole sync (built once during the member scan, read once per + // queue in Grants()) — see pkg/connector/clm_workflow_queues.go's doc. + connectorrunner.WithSessionStoreEnabled(), ) } diff --git a/docs/connector.mdx b/docs/connector.mdx index f9568456..edb88823 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -21,6 +21,7 @@ sidebarTitle: "Docusign" | CLM members, roles & groups** | | Groups only | | CLM folders & folder security** | | | | CLM permission sets** | | | +| CLM workflow queues** | | | The Docusign connector supports [automatic account provisioning and deprovisioning](/product/admin/account-provisioning). @@ -28,7 +29,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 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 and workflow queues sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign a permission set, and no endpoint to grant or revoke workflow queue membership (only work-item assign/unassign, which isn't synced here). 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/docs/doc-info.md b/docs/doc-info.md index d9048da8..12893cd5 100644 --- a/docs/doc-info.md +++ b/docs/doc-info.md @@ -10,7 +10,7 @@ — Groups — Signing Groups — Permissions Profile - — CLM Members, Roles, Groups, Folders, Folder Security, and Permission Sets (accounts with a DocuSign CLM subscription) + — CLM Members, Roles, Groups, Folders, Folder Security, Permission Sets, and Workflow Queues (accounts with a DocuSign CLM subscription) 2. **Can the connector provision any resources? If so, which ones?** @@ -50,6 +50,7 @@ - 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. - CLM members are synced as their own resource type rather than merged into the existing eSignature "Users" resource, since the two could not be confirmed to represent the same identity. + - CLM workflow queues also sync for visibility only — the API supports work-item assign/unassign, not queue-membership grant/revoke. There's no list-all endpoint for queues, so the connector discovers them by checking every CLM member's own queue membership — one extra API call per member on top of the member sync itself. --- @@ -171,7 +172,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 (`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, Permission Sets, and Workflow Queues automatically — there is no flag to set. 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/client/clm_client.go b/pkg/client/clm_client.go index b2bc7da7..9142ee4d 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -112,6 +112,7 @@ const ( clmGetMemberGroups = "/v2/%s/members/%s/groups" clmPatchPutMember = "/v2/%s/members/%s" clmGetPermissionSet = "/v2/%s/permissionsets" + clmGetMemberQueues = "/v2/%s/members/%s/workflowqueues" ) // ensureClmInitialized resolves the CLM Object API base URL, separately from @@ -622,3 +623,62 @@ func (c *Client) ListPermissionSets(ctx context.Context, options PageOptions) ([ } return page.Items, nextToken, anno, nil } + +// GetMemberWorkflowQueues lists the workflow queues a CLM member belongs to. Like +// GetMemberGroups, this fetches the member's complete set rather than exposing a page +// token: clm_workflow_queue's List() (pkg/connector/clm_workflow_queues.go) needs every +// queue a member is in to build its member->queues index, not one page at a time. +// Confirmed read-only intent per the API's documented surface: there is no reverse +// lookup (queue to members) and no membership grant/revoke endpoint, only work-item +// assign/unassign — which this connector doesn't sync (see clm_workflow_queues.go). +func (c *Client) GetMemberWorkflowQueues(ctx context.Context, memberID string) ([]ClmWorkflowQueue, annotations.Annotations, error) { + // maxMemberQueuePages mirrors GetMemberGroups' identical safety bound — see its + // comment for the rationale. + const maxMemberQueuePages = 1000 + + var all []ClmWorkflowQueue + var anno annotations.Annotations + pageToken := "" + + for i := 0; i < maxMemberQueuePages; i++ { + page, nextPageToken, pageAnno, err := c.getMemberWorkflowQueuesPage(ctx, memberID, PageOptions{PageToken: pageToken}) + if err != nil { + return nil, anno, err + } + anno = append(anno, pageAnno...) + all = append(all, page...) + + if nextPageToken == "" { + return all, anno, nil + } + if nextPageToken == pageToken { + return nil, anno, fmt.Errorf("baton-docusign: CLM API returned a non-advancing pagination token while listing workflow queues for member %s", memberID) + } + pageToken = nextPageToken + } + + return nil, anno, fmt.Errorf("baton-docusign: exceeded %d pages while listing workflow queues for member %s", maxMemberQueuePages, memberID) +} + +func (c *Client) getMemberWorkflowQueuesPage(ctx context.Context, memberID string, options PageOptions) ([]ClmWorkflowQueue, string, annotations.Annotations, error) { + if err := c.ensureClmReady(ctx); err != nil { + return nil, "", nil, err + } + + queuesURL, requestedPage, err := c.prepareClmPagedRequest(clmGetMemberQueues, options, memberID) + if err != nil { + return nil, "", nil, err + } + + var page ClmWorkflowQueuePage + anno, err := c.doClmRequest(ctx, http.MethodGet, queuesURL, nil, &page) + if err != nil { + return nil, "", nil, fmt.Errorf("baton-docusign: failed to get workflow queues for CLM member %s: %w", memberID, err) + } + + nextToken, err := getClmNextToken(requestedPage, len(page.Items), page.Next != "", page.Total) + if err != nil { + return nil, "", anno, err + } + return page.Items, nextToken, anno, nil +} diff --git a/pkg/client/clm_client_test.go b/pkg/client/clm_client_test.go index 70da9684..0d80714c 100644 --- a/pkg/client/clm_client_test.go +++ b/pkg/client/clm_client_test.go @@ -279,3 +279,34 @@ func TestListPermissionSets_Pagination(t *testing.T) { t.Fatalf("expected 5 permission sets across all pages, got %d", len(all)) } } + +func TestGetMemberWorkflowQueues(t *testing.T) { + _, c := clmtest.NewServer(t) + ctx := context.Background() + + t.Run("member in two queues", func(t *testing.T) { + queues, _, err := c.GetMemberWorkflowQueues(ctx, "member-bob") + if err != nil { + t.Fatalf("GetMemberWorkflowQueues: %v", err) + } + if len(queues) != 2 { + t.Fatalf("expected member-bob to be in 2 workflow queues, got %d: %+v", len(queues), queues) + } + }) + + t.Run("member in no queues", func(t *testing.T) { + queues, _, err := c.GetMemberWorkflowQueues(ctx, "member-carol") + if err != nil { + t.Fatalf("GetMemberWorkflowQueues: %v", err) + } + if len(queues) != 0 { + t.Fatalf("expected member-carol to be in 0 workflow queues, got %d: %+v", len(queues), queues) + } + }) + + t.Run("unknown member returns an error", func(t *testing.T) { + if _, _, err := c.GetMemberWorkflowQueues(ctx, "member-does-not-exist"); err == nil { + t.Error("expected an error for an unknown member ID, got nil") + } + }) +} diff --git a/pkg/client/clm_models.go b/pkg/client/clm_models.go index aae60104..7a66441a 100644 --- a/pkg/client/clm_models.go +++ b/pkg/client/clm_models.go @@ -256,3 +256,22 @@ var ClmRoles = []ClmRole{ {Name: "UserAdministrator"}, {Name: "SuperAdministrator"}, } + +// ClmWorkflowQueue represents a CLM WorkflowQueue object (the API's own term for what +// the CLM admin console reportedly calls "Task Groups" — that equivalence is an +// unconfirmed assumption, not a documented fact; see +// pkg/connector/clm_workflow_queues.go's doc). Modeled on the Member's workflow queues +// endpoint's documented response shape; like every other CLM model in this package, the +// exact field set is documented-but-unexercised — no live CLM tenant was available to +// confirm it against a real response. +type ClmWorkflowQueue struct { + Href string `json:"Href"` + Name string `json:"Name"` +} + +// ClmWorkflowQueuePage is the paginated collection of ClmWorkflowQueue, returned by a +// member's workflow-queues endpoint. +type ClmWorkflowQueuePage struct { + ClmPage + Items []ClmWorkflowQueue `json:"Items"` +} diff --git a/pkg/client/clmtest/handlers.go b/pkg/client/clmtest/handlers.go index 935579b4..16e49f18 100644 --- a/pkg/client/clmtest/handlers.go +++ b/pkg/client/clmtest/handlers.go @@ -190,6 +190,28 @@ func (s *Server) handleMemberGroups(w http.ResponseWriter, r *http.Request) { writeJSON(w, client.ClmGroupPage{ClmPage: meta, Items: items}) } +// handleMemberWorkflowQueues serves GET .../members/{id}/workflowqueues — documented +// but unexercised, no live CLM tenant confirmed this shape. Seed-only, no write +// endpoint: unlike memberGroups, memberWorkflowQueues is never mutated by a handler. +func (s *Server) handleMemberWorkflowQueues(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + id := r.PathValue("id") + if _, ok := s.members[id]; !ok { + writeNotFound(w) + return + } + + queueIDs := s.memberWorkflowQueues[id] + page, meta := pageSlice(r, queueIDs) + items := make([]client.ClmWorkflowQueue, 0, len(page)) + for _, qid := range page { + items = append(items, *s.workflowQueues[qid]) + } + writeJSON(w, client.ClmWorkflowQueuePage{ClmPage: meta, Items: items}) +} + // Doc URL: https://developers.docusign.com/docs/clm-api/reference/objects/members/patch/ // Additive/merge: adds any group in the request the member isn't already in. func (s *Server) handlePatchMember(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/client/clmtest/seed.go b/pkg/client/clmtest/seed.go index cee585c9..46bd7229 100644 --- a/pkg/client/clmtest/seed.go +++ b/pkg/client/clmtest/seed.go @@ -144,4 +144,25 @@ func seed(s *Server) { contractsFolder.Href = s.FolderHref("folder-contracts") s.folders["folder-contracts"] = contractsFolder s.folderOrder = append(s.folderOrder, "folder-contracts") + + // Workflow queues: no list-all endpoint exists for this object (see + // pkg/connector/clm_workflow_queues.go), so they're only discoverable by scanning + // members — seeded here purely via memberWorkflowQueues, never via a queueOrder + // slice. member-bob is in both (tests dedup across members when building the + // distinct-queue set); member-alice is in only one; carol/dave/eve/frank are in + // none (tests that a member scan handles "no queues" without emitting anything for + // them, and that clm_workflow_queue's List() doesn't invent queues from members + // that have none). + queueOnboardingID := "queue-onboarding" + queueEscalationsID := "queue-escalations" + s.workflowQueues[queueOnboardingID] = &client.ClmWorkflowQueue{ + Name: "Onboarding", + Href: s.WorkflowQueueHref(queueOnboardingID), + } + s.workflowQueues[queueEscalationsID] = &client.ClmWorkflowQueue{ + Name: "Escalations", + Href: s.WorkflowQueueHref(queueEscalationsID), + } + s.memberWorkflowQueues["member-alice"] = []string{queueOnboardingID} + s.memberWorkflowQueues[memberBobID] = []string{queueOnboardingID, queueEscalationsID} } diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index e90f940b..67166eee 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -37,6 +37,7 @@ // PATCH /v2/{accountId}/members/{id} — PatchMemberGroups (additive) // PUT /v2/{accountId}/members/{id} — PutMemberGroups (full-replace) // GET /v2/{accountId}/permissionsets — ListPermissionSets +// GET /v2/{accountId}/members/{id}/workflowqueues — GetMemberWorkflowQueues (paginated) package clmtest import ( @@ -112,6 +113,9 @@ type Server struct { permissionSets map[string]*client.ClmPermissionSet permissionSetOrder []string + workflowQueues map[string]*client.ClmWorkflowQueue + memberWorkflowQueues map[string][]string // memberID -> workflow queue IDs, seed-only (no write endpoint) + memberGroupsRequests int // count of GET .../members/{id}/groups calls, for pagination assertions } @@ -143,6 +147,10 @@ func (s *Server) MemberHref(id string) string { return fmt.Sprintf("%s/v2/%s/members/%s", s.baseURL, AccountID, id) } +func (s *Server) WorkflowQueueHref(id string) string { + return fmt.Sprintf("%s/v2/%s/workflowqueues/%s", s.baseURL, AccountID, id) +} + // MemberGroups returns the current (test-visible) group membership for a member, for // assertions after a Grant/Revoke round trip. func (s *Server) MemberGroups(memberID string) []string { @@ -180,12 +188,14 @@ func (s *Server) FolderSecurity(folderID string) client.ClmFolderSecurity { // RunStandalone so both construct exactly the same seeded state. func newState() *Server { return &Server{ - folders: make(map[string]*client.ClmFolder), - groups: make(map[string]*client.ClmGroup), - groupMembers: make(map[string][]string), - members: make(map[string]*client.ClmMember), - memberGroups: make(map[string][]string), - permissionSets: make(map[string]*client.ClmPermissionSet), + folders: make(map[string]*client.ClmFolder), + groups: make(map[string]*client.ClmGroup), + groupMembers: make(map[string][]string), + members: make(map[string]*client.ClmMember), + memberGroups: make(map[string][]string), + permissionSets: make(map[string]*client.ClmPermissionSet), + workflowQueues: make(map[string]*client.ClmWorkflowQueue), + memberWorkflowQueues: make(map[string][]string), } } @@ -202,6 +212,7 @@ func newMux(s *Server) *http.ServeMux { mux.HandleFunc("GET /v2/{accountId}/groups/{id}/groupmembers", s.requireAuth(s.handleGroupMembers)) mux.HandleFunc("GET /v2/{accountId}/members", s.requireAuth(s.handleListMembers)) mux.HandleFunc("GET /v2/{accountId}/members/{id}/groups", s.requireAuth(s.handleMemberGroups)) + mux.HandleFunc("GET /v2/{accountId}/members/{id}/workflowqueues", s.requireAuth(s.handleMemberWorkflowQueues)) mux.HandleFunc("PATCH /v2/{accountId}/members/{id}", s.requireAuth(s.handlePatchMember)) mux.HandleFunc("PUT /v2/{accountId}/members/{id}", s.requireAuth(s.handlePutMember)) mux.HandleFunc("GET /v2/{accountId}/permissionsets", s.requireAuth(s.handleListPermissionSets)) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go new file mode 100644 index 00000000..6734dc5e --- /dev/null +++ b/pkg/connector/clm_workflow_queues.go @@ -0,0 +1,193 @@ +package connector + +import ( + "context" + "fmt" + + "github.com/conductorone/baton-docusign/pkg/client" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/session" + "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" +) + +var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmWorkflowQueueBuilder)(nil) + +// entitlementClmWorkflowQueueMember is the single entitlement every CLM workflow queue +// shares — see clmGroupBuilder's entitlementClmGroupMember for the identical pattern. +const entitlementClmWorkflowQueueMember = "member" + +// clmSessionKeyQueueMembers builds the session-cache key clm_workflow_queue's List() +// writes to and Grants() reads from — see clmWorkflowQueueBuilder's doc. +func clmSessionKeyQueueMembers(queueID string) string { + return "clm_workflow_queue_members:" + queueID +} + +// clmWorkflowQueueBuilder syncs CLM WorkflowQueues — the API's own term for what the +// CLM admin console reportedly calls "Task Groups" (the surface the customer asked +// for in Pylon #11836). That equivalence is an unconfirmed assumption: no DocuSign +// document states it, and confirming it needs eyes on a real CLM admin console — see +// resource_types.go. +// +// The CLM API has no list-all endpoint for workflow queues and no reverse lookup from a +// queue to its members — the only documented read path is per-member (GET +// .../members/{id}/workflowqueues). So both List() and Grants() are built around a +// single full member scan, not the direct list-all-then-page-per-resource shape every +// other builder in this connector uses: +// +// - List() (called exactly once — see below) pages through every clm_member via +// client.ListMembers, and for each one calls client.GetMemberWorkflowQueues. It +// collects the distinct queues seen (by ID) as the resources to return, and — as a +// side effect of the same scan — builds a queueID -> []memberID index and writes it +// to the SDK's session cache (attr.Session), one entry per queue. +// - Grants(ctx, queueResource, attr) reads that queue's member list straight back out +// of the session cache instead of re-scanning every member per queue, which would +// turn one expensive O(members) traversal into O(queues * members) — a real +// concern on a connector that already has an open rate-limit bug for this same +// customer (CXP-704). +// +// This only works because the session cache persists for the whole sync (List() runs +// before Grants() for every resource of a type) and is shared across resource types +// within one sync — see cmd/baton-docusign/main.go's +// connectorrunner.WithSessionStoreEnabled(). +// +// List() returns every queue it finds in a single page rather than exposing Baton's own +// pagination: the full member scan has to complete before a single queue can be safely +// returned anyway (there's no way to know page 1 of "queues" is complete without having +// scanned every member), so there's nothing to page over — same choice clm_role makes +// for its own small, fully-enumerable set. +// +// Read-only: the API documents work-item assign/unassign, not queue-membership +// grant/revoke, so there's no Grant/Revoke on this builder — matching +// clm_permission_set's precedent for a CLM object with no write endpoint. +// +// Like every other CLM model in this connector, the endpoint shapes here are +// documented-but-unexercised — no live CLM tenant was available to confirm them. +type clmWorkflowQueueBuilder struct { + resourceType *v2.ResourceType + client *client.Client +} + +func (b *clmWorkflowQueueBuilder) ResourceType(_ context.Context) *v2.ResourceType { + return clmWorkflowQueueResourceType +} + +func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { + queuesByID := make(map[string]client.ClmWorkflowQueue) + membersByQueueID := make(map[string][]string) + var allAnnos annotations.Annotations + + memberPageToken := "" + for { + members, nextMemberPageToken, memberAnnos, err := b.client.ListMembers(ctx, client.PageOptions{PageToken: memberPageToken}) + if err != nil { + if memberPageToken == "" && isOptInFeatureUnavailableError(err) { + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_workflow_queue sync", zap.Error(err)) + return nil, &rs.SyncOpResults{}, nil + } + return nil, nil, err + } + allAnnos = append(allAnnos, memberAnnos...) + + for _, member := range members { + memberID := clmIDFromHref(member.Href) + queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) + if err != nil { + return nil, nil, fmt.Errorf("getting workflow queues for CLM member %s: %w", memberID, err) + } + allAnnos = append(allAnnos, queueAnnos...) + + for _, q := range queues { + queueID := clmIDFromHref(q.Href) + queuesByID[queueID] = q + membersByQueueID[queueID] = append(membersByQueueID[queueID], memberID) + } + } + + if nextMemberPageToken == "" { + break + } + memberPageToken = nextMemberPageToken + } + + for queueID, memberIDs := range membersByQueueID { + if err := session.SetJSON(ctx, attr.Session, clmSessionKeyQueueMembers(queueID), memberIDs); err != nil { + return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue %s membership: %w", queueID, err) + } + } + + var resources []*v2.Resource + for _, q := range queuesByID { + queueResource, err := parseIntoClmWorkflowQueueResource(&q) + if err != nil { + return nil, nil, err + } + resources = append(resources, queueResource) + } + + return resources, &rs.SyncOpResults{Annotations: allAnnos}, nil +} + +// Entitlements returns nil — the SDK does not call this when StaticEntitlementSyncerV2 +// is implemented (see StaticEntitlements below). +func (b *clmWorkflowQueueBuilder) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + return nil, nil, nil +} + +// StaticEntitlements declares the single "member" entitlement every CLM workflow queue +// shares, stamped by the SDK onto every synced clm_workflow_queue resource. +func (b *clmWorkflowQueueBuilder) StaticEntitlements(_ context.Context, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + ent := v2.Entitlement_builder{ + Slug: entitlementClmWorkflowQueueMember, + DisplayName: "Member", + Description: "Member of this CLM workflow queue", + Purpose: v2.Entitlement_PURPOSE_VALUE_ASSIGNMENT, + GrantableTo: []*v2.ResourceType{clmMemberResourceType}, + }.Build() + return []*v2.Entitlement{ent}, nil, nil +} + +// Grants reads this queue's membership straight out of the session cache List() +// populated — see this builder's doc for why it doesn't re-scan members here. +func (b *clmWorkflowQueueBuilder) Grants(ctx context.Context, queueResource *v2.Resource, attr rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + memberIDs, found, err := session.GetJSON[[]string](ctx, attr.Session, clmSessionKeyQueueMembers(queueResource.Id.Resource)) + if err != nil { + return nil, nil, fmt.Errorf("baton-docusign: failed to read cached CLM workflow queue %s membership: %w", queueResource.Id.Resource, err) + } + if !found { + // Expected only if List() didn't actually run first in this sync (shouldn't + // happen — see this builder's doc) or the session store is disabled. Log loudly + // and emit zero grants rather than falling back to a per-queue member re-scan, + // which would be the O(queues * members) cost this design exists to avoid. + ctxzap.Extract(ctx).Warn("baton-docusign: no cached membership found for CLM workflow queue; emitting zero grants", + zap.String("queue_id", queueResource.Id.Resource)) + return nil, nil, nil + } + + grants := make([]*v2.Grant, 0, len(memberIDs)) + for _, memberID := range memberIDs { + memberResourceId := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: memberID} + grants = append(grants, grant.NewGrant(queueResource, entitlementClmWorkflowQueueMember, memberResourceId)) + } + return grants, nil, nil +} + +func newClmWorkflowQueueBuilder(c *client.Client) *clmWorkflowQueueBuilder { + return &clmWorkflowQueueBuilder{ + resourceType: clmWorkflowQueueResourceType, + client: c, + } +} + +func parseIntoClmWorkflowQueueResource(q *client.ClmWorkflowQueue) (*v2.Resource, error) { + return rs.NewGroupResource( + q.Name, + clmWorkflowQueueResourceType, + clmIDFromHref(q.Href), + nil, + ) +} diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go new file mode 100644 index 00000000..4db1f960 --- /dev/null +++ b/pkg/connector/clm_workflow_queues_test.go @@ -0,0 +1,208 @@ +package connector + +import ( + "context" + "sync" + "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/conductorone/baton-sdk/pkg/types/sessions" +) + +// fakeSessionStore is a minimal in-memory sessions.SessionStore for tests — the real +// implementations either require a live gRPC session server or otter cache wiring not +// worth pulling into a unit test. Ignores the SyncID/prefix bag entirely: a single test +// only ever needs one sync's worth of isolation. +type fakeSessionStore struct { + mu sync.Mutex + data map[string][]byte +} + +var _ sessions.SessionStore = (*fakeSessionStore)(nil) + +func newFakeSessionStore() *fakeSessionStore { + return &fakeSessionStore{data: make(map[string][]byte)} +} + +func (f *fakeSessionStore) Get(_ context.Context, key string, _ ...sessions.SessionStoreOption) ([]byte, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.data[key] + return v, ok, nil +} + +func (f *fakeSessionStore) GetMany(_ context.Context, keys []string, _ ...sessions.SessionStoreOption) (map[string][]byte, []string, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make(map[string][]byte) + var missing []string + for _, k := range keys { + if v, ok := f.data[k]; ok { + out[k] = v + } else { + missing = append(missing, k) + } + } + return out, missing, nil +} + +func (f *fakeSessionStore) Set(_ context.Context, key string, value []byte, _ ...sessions.SessionStoreOption) error { + f.mu.Lock() + defer f.mu.Unlock() + f.data[key] = value + return nil +} + +func (f *fakeSessionStore) SetMany(_ context.Context, values map[string][]byte, _ ...sessions.SessionStoreOption) error { + f.mu.Lock() + defer f.mu.Unlock() + for k, v := range values { + f.data[k] = v + } + return nil +} + +func (f *fakeSessionStore) Delete(_ context.Context, key string, _ ...sessions.SessionStoreOption) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.data, key) + return nil +} + +func (f *fakeSessionStore) Clear(_ context.Context, _ ...sessions.SessionStoreOption) error { + f.mu.Lock() + defer f.mu.Unlock() + f.data = make(map[string][]byte) + return nil +} + +func (f *fakeSessionStore) GetAll(_ context.Context, _ string, _ ...sessions.SessionStoreOption) (map[string][]byte, string, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make(map[string][]byte, len(f.data)) + for k, v := range f.data { + out[k] = v + } + return out, "", nil +} + +func TestClmWorkflowQueueBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { + s, _ := clmtest.NewServer(t) + badClient := s.NewClientWithToken("wrong-token") + b := newClmWorkflowQueueBuilder(badClient) + ctx := context.Background() + + resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + 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 TestClmWorkflowQueueBuilder_StaticEntitlements(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + ents, _, err := b.StaticEntitlements(ctx, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("StaticEntitlements: %v", err) + } + if len(ents) != 1 || ents[0].Slug != entitlementClmWorkflowQueueMember { + t.Fatalf("expected a single %q entitlement, got %+v", entitlementClmWorkflowQueueMember, ents) + } +} + +// TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan is the core regression +// test for this builder's whole design: there is no list-all endpoint for workflow +// queues (see clmWorkflowQueueBuilder's doc), so List() has to discover the distinct +// set by scanning every member's own workflow-queue membership and deduping. The seed +// data (clmtest/seed.go) puts member-alice in one queue and member-bob in two, with one +// queue (Onboarding) shared between them — this confirms both the discovery and the +// dedup-by-ID across members. +func TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + if err != nil { + t.Fatalf("List: %v", err) + } + if res == nil { + t.Fatal("expected a non-nil SyncOpResults") + } + if len(resources) != 2 { + t.Fatalf("expected 2 distinct workflow queues (Onboarding, Escalations), got %d: %+v", len(resources), resources) + } + + names := make(map[string]bool) + for _, r := range resources { + names[r.DisplayName] = true + } + if !names["Onboarding"] || !names["Escalations"] { + t.Errorf("expected both Onboarding and Escalations among the discovered queues, got %v", names) + } +} + +// TestClmWorkflowQueueBuilder_Grants_ReadsFromCache confirms the other half of this +// builder's design: Grants() must NOT re-scan every member per queue (that would turn +// one O(members) traversal into O(queues * members) — see the builder's doc) — it reads +// the member list List() already cached for this exact queue. +func TestClmWorkflowQueueBuilder_Grants_ReadsFromCache(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} + + resources, _, err := b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("List: %v", err) + } + + byName := make(map[string]*v2.Resource) + for _, r := range resources { + byName[r.DisplayName] = r + } + + grants, _, err := b.Grants(ctx, byName["Onboarding"], attr) + if err != nil { + t.Fatalf("Grants(Onboarding): %v", err) + } + if len(grants) != 2 { + t.Fatalf("expected 2 members (alice, bob) in Onboarding, got %d: %+v", len(grants), grants) + } + + grants, _, err = b.Grants(ctx, byName["Escalations"], attr) + if err != nil { + t.Fatalf("Grants(Escalations): %v", err) + } + if len(grants) != 1 { + t.Fatalf("expected 1 member (bob) in Escalations, got %d: %+v", len(grants), grants) + } +} + +// TestClmWorkflowQueueBuilder_Grants_CacheMiss confirms Grants() degrades to zero +// grants (not an error, and not a fallback member re-scan) when the cache has nothing +// for a queue — e.g. if it's ever called without List() having populated it first. +func TestClmWorkflowQueueBuilder_Grants_CacheMiss(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + queueResource := &v2.Resource{Id: &v2.ResourceId{ResourceType: clmWorkflowQueueResourceType.Id, Resource: "queue-onboarding"}} + grants, _, err := b.Grants(ctx, queueResource, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + if err != nil { + t.Fatalf("expected a cache miss to be tolerated, not an error: %v", err) + } + if len(grants) != 0 { + t.Errorf("expected zero grants on a cache miss, got %d: %+v", len(grants), grants) + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 67a20fdf..993b2948 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -74,6 +74,7 @@ func (d *Connector) ResourceSyncers(_ context.Context) []connectorbuilder.Resour newClmGroupBuilder(d.client), newClmPermissionSetBuilder(d.client), newClmFolderBuilder(d.client), + newClmWorkflowQueueBuilder(d.client), } // Only include signing groups if opted in diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 6abf82b3..eca68cec 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -23,6 +23,7 @@ var alwaysRegisteredTypeIDs = []string{ "clm_group", "clm_permission_set", "clm_folder", + "clm_workflow_queue", } func registeredTypeIDs(ctx context.Context, d *Connector) map[string]bool { diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index c276eb40..278df82d 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -90,4 +90,18 @@ var ( DisplayName: "CLM Folder", Annotations: annotations.New(&v2.SkipEntitlements{}, &v2.OptInRequired{}), } + + // clmWorkflowQueueResourceType represents the CLM API's WorkflowQueue object — see + // pkg/connector/clm_workflow_queues.go for why this is read-only (no membership + // grant/revoke endpoint exists, only work-item assign/unassign, which this connector + // doesn't sync) and for the unconfirmed "is this the same thing as the CLM admin + // console's 'Task Groups'?" naming question. Uses StaticEntitlementSyncerV2 for the + // same reason clm_group does: every queue shares the same single "member" + // entitlement. + clmWorkflowQueueResourceType = &v2.ResourceType{ + Id: "clm_workflow_queue", + DisplayName: "CLM Workflow Queue", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, + Annotations: annotations.New(&v2.SkipEntitlements{}, &v2.OptInRequired{}), + } ) From 2ede60c836fadd9a78787cb1c588a0608bd7cd19 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 12:31:44 -0300 Subject: [PATCH 02/50] fix: address deep-code-review findings on clm_workflow_queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract List()'s member-scan discovery into discoverClmWorkflowQueueMembership, a pure API-domain helper (no v2.Resource, no session cache) that List() just calls and converts. Matches every sibling CLM builder's simpler "discover, then build resources" shape instead of mixing five concerns in one function. - Merge queuesByID/membersByQueueID into one clmWorkflowQueueMembershipEntry map — the two always shared the same key set, so two maps and two trailing loops collapse into one of each with no behavior change. - Factor GetMemberGroups' and GetMemberWorkflowQueues' near-identical page-to-completion loops into a shared clmPageToCompletion[T] generic helper, so the bound/non-advancing-token guard only needs fixing once if it's ever wrong. - Add the missing baton-docusign: prefix on the per-member fetch error in the new discovery helper. Not changed, on purpose: Grants()'s Warn-level log on a session-cache miss (one precedent already exists in this package — singing_groups.go — and the code's own comment explains why Warn fits this specific "shouldn't happen" case; judged not worth changing). Also not changed: Grants() still returns a queue's full membership unpaginated — flagged in review as a real but pre-existing pattern (clm_folders.go's Grants() has the identical, and worse, shape already on main) rather than something unique to this PR; left for a separate, broader fix. Co-Authored-By: Claude Sonnet 5 --- pkg/client/clm_client.go | 78 +++++++++++------------- pkg/connector/clm_workflow_queues.go | 90 ++++++++++++++++++++-------- 2 files changed, 99 insertions(+), 69 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 9142ee4d..696d1bb6 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -482,25 +482,27 @@ func (c *Client) ListMembers(ctx context.Context, options PageOptions) ([]ClmMem // particular does a full-replace Put using this result, so a truncated list here would // silently drop the member's memberships in every group beyond the first page. // -// Intentional carve-out from the usual client-layer rule against looping through pages -// internally (that's normally the connector layer's job, driving one page per call): -// this isn't a sync List — it's a read-before-write for provisioning, where the caller -// fundamentally needs the complete set to safely do a full-replace Put, not a page at a -// time. The loop is bounded (maxMemberGroupPages) and guards against a non-advancing -// token, so it can't hang even if the underlying assumption about the API is wrong. -func (c *Client) GetMemberGroups(ctx context.Context, memberID string) ([]ClmGroup, annotations.Annotations, error) { - // maxMemberGroupPages bounds this loop in case the CLM API ever echoes a - // non-advancing Offset/Limit, which would make getClmNextToken compute the same - // "next" token forever. A member with more pages of groups than this is - // implausible; if it ever happens, fail loudly instead of hanging. - const maxMemberGroupPages = 1000 - - var all []ClmGroup +// clmMaxMemberSubResourcePages bounds every "fetch a member's complete X" loop below +// (GetMemberGroups, GetMemberWorkflowQueues) in case the CLM API ever echoes a +// non-advancing Offset/Limit, which would make getClmNextToken compute the same "next" +// token forever. A member with more pages of groups/queues than this is implausible; if +// it ever happens, fail loudly instead of hanging. +const clmMaxMemberSubResourcePages = 1000 + +// clmPageToCompletion pages through fetchPage until it returns an empty nextPageToken, +// accumulating every item — the shared shape behind every "get a member's complete X" +// client method (GetMemberGroups, GetMemberWorkflowQueues), which each need the whole +// set at once rather than one page per caller-visible call. See GetMemberGroups' doc for +// why looping internally is an intentional carve-out from the usual client-layer rule. +// Bounded by maxPages and guards against a non-advancing token, so it can't hang even if +// the underlying assumption about the API is wrong. +func clmPageToCompletion[T any](fetchPage func(pageToken string) ([]T, string, annotations.Annotations, error), maxPages int, label string) ([]T, annotations.Annotations, error) { + var all []T var anno annotations.Annotations pageToken := "" - for i := 0; i < maxMemberGroupPages; i++ { - page, nextPageToken, pageAnno, err := c.getMemberGroupsPage(ctx, memberID, PageOptions{PageToken: pageToken}) + for i := 0; i < maxPages; i++ { + page, nextPageToken, pageAnno, err := fetchPage(pageToken) if err != nil { return nil, anno, err } @@ -511,12 +513,23 @@ func (c *Client) GetMemberGroups(ctx context.Context, memberID string) ([]ClmGro return all, anno, nil } if nextPageToken == pageToken { - return nil, anno, fmt.Errorf("baton-docusign: CLM API returned a non-advancing pagination token while listing groups for member %s", memberID) + return nil, anno, fmt.Errorf("baton-docusign: CLM API returned a non-advancing pagination token while listing %s", label) } pageToken = nextPageToken } - return nil, anno, fmt.Errorf("baton-docusign: exceeded %d pages while listing groups for member %s", maxMemberGroupPages, memberID) + return nil, anno, fmt.Errorf("baton-docusign: exceeded %d pages while listing %s", maxPages, label) +} + +// Intentional carve-out from the usual client-layer rule against looping through pages +// internally (that's normally the connector layer's job, driving one page per call): +// this isn't a sync List — it's a read-before-write for provisioning, where the caller +// fundamentally needs the complete set to safely do a full-replace Put, not a page at a +// time. See clmPageToCompletion for the shared bounded/non-advancing-token-guarded loop. +func (c *Client) GetMemberGroups(ctx context.Context, memberID string) ([]ClmGroup, annotations.Annotations, error) { + return clmPageToCompletion(func(pageToken string) ([]ClmGroup, string, annotations.Annotations, error) { + return c.getMemberGroupsPage(ctx, memberID, PageOptions{PageToken: pageToken}) + }, clmMaxMemberSubResourcePages, fmt.Sprintf("groups for member %s", memberID)) } // getMemberGroupsPage fetches a single page of a member's group memberships. Both of @@ -632,32 +645,9 @@ func (c *Client) ListPermissionSets(ctx context.Context, options PageOptions) ([ // lookup (queue to members) and no membership grant/revoke endpoint, only work-item // assign/unassign — which this connector doesn't sync (see clm_workflow_queues.go). func (c *Client) GetMemberWorkflowQueues(ctx context.Context, memberID string) ([]ClmWorkflowQueue, annotations.Annotations, error) { - // maxMemberQueuePages mirrors GetMemberGroups' identical safety bound — see its - // comment for the rationale. - const maxMemberQueuePages = 1000 - - var all []ClmWorkflowQueue - var anno annotations.Annotations - pageToken := "" - - for i := 0; i < maxMemberQueuePages; i++ { - page, nextPageToken, pageAnno, err := c.getMemberWorkflowQueuesPage(ctx, memberID, PageOptions{PageToken: pageToken}) - if err != nil { - return nil, anno, err - } - anno = append(anno, pageAnno...) - all = append(all, page...) - - if nextPageToken == "" { - return all, anno, nil - } - if nextPageToken == pageToken { - return nil, anno, fmt.Errorf("baton-docusign: CLM API returned a non-advancing pagination token while listing workflow queues for member %s", memberID) - } - pageToken = nextPageToken - } - - return nil, anno, fmt.Errorf("baton-docusign: exceeded %d pages while listing workflow queues for member %s", maxMemberQueuePages, memberID) + return clmPageToCompletion(func(pageToken string) ([]ClmWorkflowQueue, string, annotations.Annotations, error) { + return c.getMemberWorkflowQueuesPage(ctx, memberID, PageOptions{PageToken: pageToken}) + }, clmMaxMemberSubResourcePages, fmt.Sprintf("workflow queues for member %s", memberID)) } func (c *Client) getMemberWorkflowQueuesPage(ctx context.Context, memberID string, options PageOptions) ([]ClmWorkflowQueue, string, annotations.Annotations, error) { diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 6734dc5e..79962067 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -2,6 +2,7 @@ package connector import ( "context" + "errors" "fmt" "github.com/conductorone/baton-docusign/pkg/client" @@ -15,6 +16,13 @@ import ( "go.uber.org/zap" ) +// errClmWorkflowQueuesUnavailable is a sentinel discoverClmWorkflowQueueMembership wraps +// its return error with when the very first ListMembers call in the scan fails with +// isOptInFeatureUnavailableError — lets List() distinguish "skip this resource type +// gracefully" from a real failure via errors.Is, without losing the underlying error for +// logging (see List()'s use of it). +var errClmWorkflowQueuesUnavailable = errors.New("baton-docusign: CLM is not available for this account or token") + var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmWorkflowQueueBuilder)(nil) // entitlementClmWorkflowQueueMember is the single entitlement every CLM workflow queue @@ -77,8 +85,54 @@ func (b *clmWorkflowQueueBuilder) ResourceType(_ context.Context) *v2.ResourceTy } func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { - queuesByID := make(map[string]client.ClmWorkflowQueue) - membersByQueueID := make(map[string][]string) + membership, allAnnos, err := b.discoverClmWorkflowQueueMembership(ctx) + if err != nil { + if errors.Is(err, errClmWorkflowQueuesUnavailable) { + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_workflow_queue sync", zap.Error(err)) + return nil, &rs.SyncOpResults{}, nil + } + return nil, nil, err + } + + resources := make([]*v2.Resource, 0, len(membership)) + for queueID, entry := range membership { + if err := session.SetJSON(ctx, attr.Session, clmSessionKeyQueueMembers(queueID), entry.members); err != nil { + return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue %s membership: %w", queueID, err) + } + + queueResource, err := parseIntoClmWorkflowQueueResource(&entry.queue) + if err != nil { + return nil, nil, err + } + resources = append(resources, queueResource) + } + + return resources, &rs.SyncOpResults{Annotations: allAnnos}, nil +} + +// clmWorkflowQueueMembershipEntry pairs a discovered queue with the member IDs found +// to belong to it — the two pieces of data discoverClmWorkflowQueueMembership's member +// scan produces for every queue, kept together under one key instead of two parallel +// maps that would otherwise always share the same key set. +type clmWorkflowQueueMembershipEntry struct { + queue client.ClmWorkflowQueue + members []string +} + +// discoverClmWorkflowQueueMembership is the member-scan discovery algorithm this +// builder's whole design is built around (see the type doc above): the CLM API has no +// list-all endpoint for workflow queues and no reverse lookup from a queue to its +// members, so this pages through every clm_member and, for each, calls +// client.GetMemberWorkflowQueues to learn which queues they're in. Pure API-domain +// discovery — no v2.Resource, no session cache — kept separate from List() so this +// algorithm is testable and readable on its own, and List() stays a plain "discover, +// then build resources" shape like every sibling CLM builder's List(). +// +// Returns a nil map and an error wrapping errClmWorkflowQueuesUnavailable if the very +// first ListMembers call fails with isOptInFeatureUnavailableError — List() checks for +// that sentinel via errors.Is to decide whether to skip this resource type gracefully. +func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context.Context) (map[string]*clmWorkflowQueueMembershipEntry, annotations.Annotations, error) { + membership := make(map[string]*clmWorkflowQueueMembershipEntry) var allAnnos annotations.Annotations memberPageToken := "" @@ -86,8 +140,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at members, nextMemberPageToken, memberAnnos, err := b.client.ListMembers(ctx, client.PageOptions{PageToken: memberPageToken}) if err != nil { if memberPageToken == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_workflow_queue sync", zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil + return nil, nil, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) } return nil, nil, err } @@ -97,39 +150,26 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at memberID := clmIDFromHref(member.Href) queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) if err != nil { - return nil, nil, fmt.Errorf("getting workflow queues for CLM member %s: %w", memberID, err) + return nil, nil, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) } allAnnos = append(allAnnos, queueAnnos...) for _, q := range queues { queueID := clmIDFromHref(q.Href) - queuesByID[queueID] = q - membersByQueueID[queueID] = append(membersByQueueID[queueID], memberID) + entry, ok := membership[queueID] + if !ok { + entry = &clmWorkflowQueueMembershipEntry{queue: q} + membership[queueID] = entry + } + entry.members = append(entry.members, memberID) } } if nextMemberPageToken == "" { - break + return membership, allAnnos, nil } memberPageToken = nextMemberPageToken } - - for queueID, memberIDs := range membersByQueueID { - if err := session.SetJSON(ctx, attr.Session, clmSessionKeyQueueMembers(queueID), memberIDs); err != nil { - return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue %s membership: %w", queueID, err) - } - } - - var resources []*v2.Resource - for _, q := range queuesByID { - queueResource, err := parseIntoClmWorkflowQueueResource(&q) - if err != nil { - return nil, nil, err - } - resources = append(resources, queueResource) - } - - return resources, &rs.SyncOpResults{Annotations: allAnnos}, nil } // Entitlements returns nil — the SDK does not call this when StaticEntitlementSyncerV2 From 3319a9c01dd2409fe34e0287d2e1f7c9300e4234 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 13:26:39 -0300 Subject: [PATCH 03/50] fix: address current bot review findings on PR #67 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tolerate isOptInFeatureUnavailableError per-member in the workflow-queue member scan (a deleted member or a scope gap on one member's endpoint no longer aborts the whole clm_workflow_queue sync). - Skip clm_workflow_queue gracefully (not a hard sync-wide error) when the session cache write fails, matching every other CLM builder's degrade pattern — WithSessionStoreEnabled only opts in to a store existing; the parent process can still fall back to NoOpSessionStore at runtime. - Fail Grants() loudly on a cache miss instead of emitting zero grants, which is indistinguishable from a queue's membership being genuinely emptied out. - Guard against an empty Href collapsing every queue with no Href into one bogus merged resource. - Dedupe repeated RateLimitDescription annotations down to the latest one. - Fix the package doc's endpoint inventory and a doc comment that landed on the wrong declaration after the clmPageToCompletion refactor. - Add pagination coverage for GetMemberWorkflowQueues. Co-Authored-By: Claude Sonnet 5 --- pkg/client/clm_client.go | 15 +++--- pkg/client/clm_client_test.go | 24 +++++++++ pkg/client/clmtest/handlers.go | 1 + pkg/client/clmtest/server.go | 37 ++++++++++++- pkg/connector/clm_workflow_queues.go | 66 +++++++++++++++++++---- pkg/connector/clm_workflow_queues_test.go | 54 +++++++++++++++++-- 6 files changed, 175 insertions(+), 22 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 696d1bb6..8fe80d2c 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -24,6 +24,7 @@ // Members (CLM's principal object): // - GET /v2/{accountId}/members - List members (GetMembers) // - GET /v2/{accountId}/members/{id}/groups - Groups a member belongs to +// - GET /v2/{accountId}/members/{id}/workflowqueues - Workflow queues a member belongs to // - PATCH /v2/{accountId}/members/{id} - Add member to new groups (additive/merge) // - PUT /v2/{accountId}/members/{id} - Replace member's groups (adds new, removes unspecified) // @@ -475,13 +476,6 @@ func (c *Client) ListMembers(ctx context.Context, options PageOptions) ([]ClmMem return page.Items, nextToken, anno, nil } -// GetMemberGroups gets the FULL current list of groups a member belongs to — required -// before Grant/Revoke, since both are read-modify-write against this list (Patch is -// additive/merge, Put is full-replace). This method pages to completion internally: -// callers need the complete list, not one page of it — Revoke in -// particular does a full-replace Put using this result, so a truncated list here would -// silently drop the member's memberships in every group beyond the first page. -// // clmMaxMemberSubResourcePages bounds every "fetch a member's complete X" loop below // (GetMemberGroups, GetMemberWorkflowQueues) in case the CLM API ever echoes a // non-advancing Offset/Limit, which would make getClmNextToken compute the same "next" @@ -521,6 +515,13 @@ func clmPageToCompletion[T any](fetchPage func(pageToken string) ([]T, string, a return nil, anno, fmt.Errorf("baton-docusign: exceeded %d pages while listing %s", maxPages, label) } +// GetMemberGroups gets the FULL current list of groups a member belongs to — required +// before Grant/Revoke, since both are read-modify-write against this list (Patch is +// additive/merge, Put is full-replace). This method pages to completion internally: +// callers need the complete list, not one page of it — Revoke in particular does a +// full-replace Put using this result, so a truncated list here would silently drop the +// member's memberships in every group beyond the first page. +// // Intentional carve-out from the usual client-layer rule against looping through pages // internally (that's normally the connector layer's job, driving one page per call): // this isn't a sync List — it's a read-before-write for provisioning, where the caller diff --git a/pkg/client/clm_client_test.go b/pkg/client/clm_client_test.go index 0d80714c..8f6e7410 100644 --- a/pkg/client/clm_client_test.go +++ b/pkg/client/clm_client_test.go @@ -310,3 +310,27 @@ func TestGetMemberWorkflowQueues(t *testing.T) { } }) } + +func TestGetMemberWorkflowQueues_PaginatesAcrossPages(t *testing.T) { + // Regression test mirroring TestGetMemberGroups_PaginatesAcrossPages: confirms + // GetMemberWorkflowQueues' shared clmPageToCompletion loop actually issues multiple + // requests for a member with more queues than fit on one page, rather than silently + // returning a truncated first page. member-mallory is added via + // AddBulkWorkflowQueueMember (not the default seed) specifically so its 105 queues + // don't perturb the default seed's "2 distinct queues" / "6 members" assertions used + // elsewhere. + srv, c := clmtest.NewServer(t) + ctx := context.Background() + srv.AddBulkWorkflowQueueMember("member-mallory", 105) + + queues, _, err := c.GetMemberWorkflowQueues(ctx, "member-mallory") + if err != nil { + t.Fatalf("GetMemberWorkflowQueues: %v", err) + } + if len(queues) != 105 { + t.Fatalf("expected all 105 of member-mallory's workflow queues (paginated internally), got %d", len(queues)) + } + if got := srv.MemberWorkflowQueuesRequestCount(); got < 2 { + t.Fatalf("expected GetMemberWorkflowQueues to issue at least 2 HTTP requests to page through 105 queues, but the mock server only saw %d — pagination is not actually happening", got) + } +} diff --git a/pkg/client/clmtest/handlers.go b/pkg/client/clmtest/handlers.go index 16e49f18..353f7912 100644 --- a/pkg/client/clmtest/handlers.go +++ b/pkg/client/clmtest/handlers.go @@ -196,6 +196,7 @@ func (s *Server) handleMemberGroups(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMemberWorkflowQueues(w http.ResponseWriter, r *http.Request) { s.mu.Lock() defer s.mu.Unlock() + s.memberWorkflowQueuesRequests++ id := r.PathValue("id") if _, ok := s.members[id]; !ok { diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index 67166eee..e513ea1d 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -116,7 +116,8 @@ type Server struct { workflowQueues map[string]*client.ClmWorkflowQueue memberWorkflowQueues map[string][]string // memberID -> workflow queue IDs, seed-only (no write endpoint) - memberGroupsRequests int // count of GET .../members/{id}/groups calls, for pagination assertions + memberGroupsRequests int // count of GET .../members/{id}/groups calls, for pagination assertions + memberWorkflowQueuesRequests int // count of GET .../members/{id}/workflowqueues calls, for pagination assertions } // MemberGroupsRequestCount returns how many times GET .../members/{id}/groups has been @@ -129,6 +130,40 @@ func (s *Server) MemberGroupsRequestCount() int { return s.memberGroupsRequests } +// MemberWorkflowQueuesRequestCount is MemberGroupsRequestCount's equivalent for GET +// .../members/{id}/workflowqueues. +func (s *Server) MemberWorkflowQueuesRequestCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.memberWorkflowQueuesRequests +} + +// AddBulkWorkflowQueueMember adds a new member (not part of the default seed) who +// belongs to queueCount newly created, distinct workflow queues — for pagination tests +// that need a member with more workflow-queue memberships than fit on one page, without +// perturbing the default seed's member count or distinct-queue count that other tests +// (both here and in pkg/connector) assert on. Call after NewServer returns. +func (s *Server) AddBulkWorkflowQueueMember(memberID string, queueCount int) { + s.mu.Lock() + defer s.mu.Unlock() + + member := &client.ClmMember{Email: memberID + "@example.com", UserName: memberID} + member.Href = s.MemberHref(memberID) + s.members[memberID] = member + s.memberOrder = append(s.memberOrder, memberID) + + queueIDs := make([]string, 0, queueCount) + for i := 1; i <= queueCount; i++ { + qid := fmt.Sprintf("queue-bulk-%03d", i) + s.workflowQueues[qid] = &client.ClmWorkflowQueue{ + Name: fmt.Sprintf("Bulk Queue %03d", i), + Href: s.WorkflowQueueHref(qid), + } + queueIDs = append(queueIDs, qid) + } + s.memberWorkflowQueues[memberID] = queueIDs +} + // URL returns the mock server's base URL — also what handleClmAccountDiscovery // returns as the CLM API base URL. func (s *Server) URL() string { return s.baseURL } diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 79962067..e3856c66 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -14,6 +14,7 @@ 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/protobuf/types/known/anypb" ) // errClmWorkflowQueuesUnavailable is a sentinel discoverClmWorkflowQueueMembership wraps @@ -97,7 +98,17 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at resources := make([]*v2.Resource, 0, len(membership)) for queueID, entry := range membership { if err := session.SetJSON(ctx, attr.Session, clmSessionKeyQueueMembers(queueID), entry.members); err != nil { - return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue %s membership: %w", queueID, err) + // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) + // only tells the SDK to accept a store connection — whether one actually + // exists still depends on the parent process wiring a listen port, and it + // falls back to NoOpSessionStore (every Set call fails) whenever it doesn't. + // This resource type's Grants() cannot function without it, but that's not + // true of the rest of the sync — a hard error here would fail every other + // resource type too. Skip gracefully instead, same as an unavailable CLM + // subscription. + ctxzap.Extract(ctx).Warn("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", + zap.String("queue_id", queueID), zap.Error(err)) + return nil, &rs.SyncOpResults{}, nil } queueResource, err := parseIntoClmWorkflowQueueResource(&entry.queue) @@ -107,7 +118,28 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at resources = append(resources, queueResource) } - return resources, &rs.SyncOpResults{Annotations: allAnnos}, nil + return resources, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil +} + +// dedupeRateLimitAnnotations keeps every non-rate-limit annotation as-is but collapses +// all RateLimitDescription entries down to the last one — discoverClmWorkflowQueueMembership +// appends one member-page annotation set plus one per-member queue-fetch annotation set +// per iteration, so a large account accumulates thousands of near-identical rate-limit +// snapshots in a single List() response; only the most recent one is meaningful. +func dedupeRateLimitAnnotations(annos annotations.Annotations) annotations.Annotations { + var out annotations.Annotations + var lastRateLimit *anypb.Any + for _, a := range annos { + if a.MessageIs(&v2.RateLimitDescription{}) { + lastRateLimit = a + continue + } + out = append(out, a) + } + if lastRateLimit != nil { + out = append(out, lastRateLimit) + } + return out } // clmWorkflowQueueMembershipEntry pairs a discovered queue with the member IDs found @@ -150,12 +182,27 @@ func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context memberID := clmIDFromHref(member.Href) queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) if err != nil { + // Tolerate the same errors ListMembers' first page does: a member deleted + // between that call and this one 404s, and an account/token that can list + // members but lacks the scope or subscription for this endpoint would + // otherwise 403/404 on the very first member and fail this whole resource + // type. One bad member shouldn't do that — skip it and keep scanning. + if isOptInFeatureUnavailableError(err) { + ctxzap.Extract(ctx).Warn("baton-docusign: failed to get CLM workflow queues for member, skipping", + zap.String("member_id", memberID), zap.Error(err)) + continue + } return nil, nil, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) } allAnnos = append(allAnnos, queueAnnos...) for _, q := range queues { queueID := clmIDFromHref(q.Href) + if queueID == "" { + ctxzap.Extract(ctx).Warn("baton-docusign: CLM workflow queue has an empty Href, skipping", + zap.String("member_id", memberID), zap.String("queue_name", q.Name)) + continue + } entry, ok := membership[queueID] if !ok { entry = &clmWorkflowQueueMembershipEntry{queue: q} @@ -199,13 +246,14 @@ func (b *clmWorkflowQueueBuilder) Grants(ctx context.Context, queueResource *v2. return nil, nil, fmt.Errorf("baton-docusign: failed to read cached CLM workflow queue %s membership: %w", queueResource.Id.Resource, err) } if !found { - // Expected only if List() didn't actually run first in this sync (shouldn't - // happen — see this builder's doc) or the session store is disabled. Log loudly - // and emit zero grants rather than falling back to a per-queue member re-scan, - // which would be the O(queues * members) cost this design exists to avoid. - ctxzap.Extract(ctx).Warn("baton-docusign: no cached membership found for CLM workflow queue; emitting zero grants", - zap.String("queue_id", queueResource.Id.Resource)) - return nil, nil, nil + // Shouldn't happen — see this builder's doc (List() always runs before Grants() + // for every resource of a type, and now skips this whole resource type gracefully + // rather than returning partial results whenever it can't populate the cache). + // Fail loudly instead of falling back to a per-queue member re-scan (the + // O(queues * members) cost this design exists to avoid) or silently emitting zero + // grants, which C1 can't distinguish from this queue's membership having been + // genuinely emptied out. + return nil, nil, fmt.Errorf("baton-docusign: no cached membership found for CLM workflow queue %s", queueResource.Id.Resource) } grants := make([]*v2.Grant, 0, len(memberIDs)) diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 4db1f960..d19ae832 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -2,6 +2,7 @@ package connector import ( "context" + "errors" "sync" "testing" @@ -88,6 +89,47 @@ func (f *fakeSessionStore) GetAll(_ context.Context, _ string, _ ...sessions.Ses return out, "", nil } +// failingSessionStore wraps fakeSessionStore but every Set/SetMany call fails — stands +// in for the SDK's real NoOpSessionStore (returned whenever the parent process hasn't +// wired a session-store listen port; see session.NoOpSessionStore), which every write +// to it fails the exact same way. +type failingSessionStore struct { + *fakeSessionStore +} + +func (f *failingSessionStore) Set(_ context.Context, _ string, _ []byte, _ ...sessions.SessionStoreOption) error { + return errClmSessionStoreDisabledForTest +} + +func (f *failingSessionStore) SetMany(_ context.Context, _ map[string][]byte, _ ...sessions.SessionStoreOption) error { + return errClmSessionStoreDisabledForTest +} + +var errClmSessionStoreDisabledForTest = errors.New("session store disabled (test double)") + +// TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreWriteFailure confirms +// List() degrades to a graceful skip (not a hard error) when it can't write to the +// session cache — e.g. because the parent process didn't wire a session-store listen +// port and the SDK fell back to NoOpSessionStore. A hard error here would fail the +// entire sync, not just this resource type, since every other CLM builder's List() also +// runs unconditionally in the same sync. +func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreWriteFailure(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: &failingSessionStore{fakeSessionStore: newFakeSessionStore()}}) + if err != nil { + t.Fatalf("expected a session-store write failure to be tolerated, not an error: %v", err) + } + if len(resources) != 0 { + t.Errorf("expected zero resources when the session store can't be written to, got %d", len(resources)) + } + if res == nil { + t.Errorf("expected a non-nil SyncOpResults, got %+v", res) + } +} + func TestClmWorkflowQueueBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") @@ -189,9 +231,11 @@ func TestClmWorkflowQueueBuilder_Grants_ReadsFromCache(t *testing.T) { } } -// TestClmWorkflowQueueBuilder_Grants_CacheMiss confirms Grants() degrades to zero -// grants (not an error, and not a fallback member re-scan) when the cache has nothing -// for a queue — e.g. if it's ever called without List() having populated it first. +// TestClmWorkflowQueueBuilder_Grants_CacheMiss confirms Grants() fails loudly (not a +// silent zero-grants degrade, and not a fallback member re-scan) when the cache has +// nothing for a queue — e.g. if it's ever called without List() having populated it +// first. Zero grants would be indistinguishable from this queue's membership having +// been genuinely emptied out, which is worse than failing the sync. func TestClmWorkflowQueueBuilder_Grants_CacheMiss(t *testing.T) { _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) @@ -199,8 +243,8 @@ func TestClmWorkflowQueueBuilder_Grants_CacheMiss(t *testing.T) { queueResource := &v2.Resource{Id: &v2.ResourceId{ResourceType: clmWorkflowQueueResourceType.Id, Resource: "queue-onboarding"}} grants, _, err := b.Grants(ctx, queueResource, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - if err != nil { - t.Fatalf("expected a cache miss to be tolerated, not an error: %v", err) + if err == nil { + t.Fatal("expected a cache miss to fail loudly, got nil error") } if len(grants) != 0 { t.Errorf("expected zero grants on a cache miss, got %d: %+v", len(grants), grants) From 39745ecde358876776230b5450146b252650da58 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 15:33:39 -0300 Subject: [PATCH 04/50] fix: address incremental bot review findings on PR #67 - Narrow the per-member workflow-queue-fetch tolerance to codes.NotFound only (an isolated deleted-member case); Unauthenticated/PermissionDenied/ FailedPrecondition now bail the whole resource type via errClmWorkflowQueuesUnavailable instead of being silently swallowed per-member, which could otherwise produce a partial membership set that C1 reads as revoked access. - Apply logarithmic sampling (1, 10, 100, then every 1000) to the two per-member/per-queue Warn logs so a large account doesn't flood the log. - Preserve accumulated RateLimitDescription annotations on List()'s graceful-skip paths instead of discarding them. - Track the last RateLimitDescription by index instead of value in dedupeRateLimitAnnotations, dropping the only direct google.golang.org/protobuf import in the repo (go.mod still marked it // indirect). --- pkg/connector/clm_workflow_queues.go | 56 +++++++++++++++++----------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index e3856c66..436cc47c 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -14,7 +14,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/protobuf/types/known/anypb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // errClmWorkflowQueuesUnavailable is a sentinel discoverClmWorkflowQueueMembership wraps @@ -90,7 +91,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at if err != nil { if errors.Is(err, errClmWorkflowQueuesUnavailable) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_workflow_queue sync", zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil + return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } return nil, nil, err } @@ -108,7 +109,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // subscription. ctxzap.Extract(ctx).Warn("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.String("queue_id", queueID), zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil + return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } queueResource, err := parseIntoClmWorkflowQueueResource(&entry.queue) @@ -128,16 +129,16 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // snapshots in a single List() response; only the most recent one is meaningful. func dedupeRateLimitAnnotations(annos annotations.Annotations) annotations.Annotations { var out annotations.Annotations - var lastRateLimit *anypb.Any - for _, a := range annos { + lastRateLimitIdx := -1 + for i, a := range annos { if a.MessageIs(&v2.RateLimitDescription{}) { - lastRateLimit = a + lastRateLimitIdx = i continue } out = append(out, a) } - if lastRateLimit != nil { - out = append(out, lastRateLimit) + if lastRateLimitIdx >= 0 { + out = append(out, annos[lastRateLimitIdx]) } return out } @@ -166,15 +167,16 @@ type clmWorkflowQueueMembershipEntry struct { func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context.Context) (map[string]*clmWorkflowQueueMembershipEntry, annotations.Annotations, error) { membership := make(map[string]*clmWorkflowQueueMembershipEntry) var allAnnos annotations.Annotations + var skippedMembers, skippedQueues int memberPageToken := "" for { members, nextMemberPageToken, memberAnnos, err := b.client.ListMembers(ctx, client.PageOptions{PageToken: memberPageToken}) if err != nil { if memberPageToken == "" && isOptInFeatureUnavailableError(err) { - return nil, nil, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) + return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) } - return nil, nil, err + return nil, allAnnos, err } allAnnos = append(allAnnos, memberAnnos...) @@ -182,25 +184,37 @@ func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context memberID := clmIDFromHref(member.Href) queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) if err != nil { - // Tolerate the same errors ListMembers' first page does: a member deleted - // between that call and this one 404s, and an account/token that can list - // members but lacks the scope or subscription for this endpoint would - // otherwise 403/404 on the very first member and fail this whole resource - // type. One bad member shouldn't do that — skip it and keep scanning. - if isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Warn("baton-docusign: failed to get CLM workflow queues for member, skipping", - zap.String("member_id", memberID), zap.Error(err)) + // Only a bare NotFound is tolerated per-member — the isolated "member + // deleted between ListMembers and this call" case. Unauthenticated/ + // PermissionDenied/FailedPrecondition (the other codes + // isOptInFeatureUnavailableError covers) signal an account/token-wide + // problem, same as ListMembers' own first-page check above — tolerating + // those per-member would silently accept a partial membership set + // (missing queues, missing members) instead of skipping the whole + // resource type the way a systemic failure should. + if status.Code(err) == codes.NotFound { + skippedMembers++ + if n := skippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { + ctxzap.Extract(ctx).Warn("baton-docusign: CLM member not found while scanning workflow queues, skipping", + zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) + } continue } - return nil, nil, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) + if isOptInFeatureUnavailableError(err) { + return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) + } + return nil, allAnnos, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) } allAnnos = append(allAnnos, queueAnnos...) for _, q := range queues { queueID := clmIDFromHref(q.Href) if queueID == "" { - ctxzap.Extract(ctx).Warn("baton-docusign: CLM workflow queue has an empty Href, skipping", - zap.String("member_id", memberID), zap.String("queue_name", q.Name)) + skippedQueues++ + if n := skippedQueues; n == 1 || n == 10 || n == 100 || n%1000 == 0 { + ctxzap.Extract(ctx).Warn("baton-docusign: CLM workflow queue has an empty Href, skipping", + zap.String("member_id", memberID), zap.String("queue_name", q.Name), zap.Int("total_occurrences", n)) + } continue } entry, ok := membership[queueID] From 3efcf69847a2852162fb65d252332d5f1887fed5 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 17:07:01 -0300 Subject: [PATCH 05/50] fix: don't discard already-discovered queues on a later member's failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: discoverClmWorkflowQueueMembership escalated ANY per-member isOptInFeatureUnavailableError to "CLM unavailable, return zero resources" regardless of scan position. A PermissionDenied/Unauthenticated on member N (token expiring or scope revoked mid-scan) after earlier members had already contributed real queues would silently discard every already-discovered queue as if the whole feature were unavailable — the same false-deletion risk ListMembers' own memberPageToken == "" narrowing exists to avoid. Now only escalates to errClmWorkflowQueuesUnavailable while membership is still empty; once queues have been found, CLM is clearly available, so a later failure fails loud instead. Adds Server.ForceMemberWorkflowQueuesStatus to clmtest to test all three per-member outcomes: NotFound skip-and-continue, PermissionDenied on the first member (graceful skip), PermissionDenied after some discovery (hard failure). --- pkg/client/clmtest/handlers.go | 5 ++ pkg/client/clmtest/server.go | 30 +++++++--- pkg/connector/clm_workflow_queues.go | 11 +++- pkg/connector/clm_workflow_queues_test.go | 69 +++++++++++++++++++++++ 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/pkg/client/clmtest/handlers.go b/pkg/client/clmtest/handlers.go index 353f7912..15d25b34 100644 --- a/pkg/client/clmtest/handlers.go +++ b/pkg/client/clmtest/handlers.go @@ -199,6 +199,11 @@ func (s *Server) handleMemberWorkflowQueues(w http.ResponseWriter, r *http.Reque s.memberWorkflowQueuesRequests++ id := r.PathValue("id") + if forcedStatus, ok := s.forcedMemberWorkflowQueuesStatus[id]; ok { + w.WriteHeader(forcedStatus) + _ = json.NewEncoder(w).Encode(client.ClmErrorResponse{}) + return + } if _, ok := s.members[id]; !ok { writeNotFound(w) return diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index e513ea1d..c28f49e3 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -118,6 +118,19 @@ type Server struct { memberGroupsRequests int // count of GET .../members/{id}/groups calls, for pagination assertions memberWorkflowQueuesRequests int // count of GET .../members/{id}/workflowqueues calls, for pagination assertions + + forcedMemberWorkflowQueuesStatus map[string]int // memberID -> forced HTTP status, for tests +} + +// ForceMemberWorkflowQueuesStatus makes GET .../members/{id}/workflowqueues fail with +// the given HTTP status for exactly this memberID — for tests that need a specific +// gRPC code (e.g. PermissionDenied/Unauthenticated) out of one particular member's +// call, distinct from the unknown-member 404 handleMemberWorkflowQueues already +// produces for an ID absent from the seed. Call after NewServer returns. +func (s *Server) ForceMemberWorkflowQueuesStatus(memberID string, status int) { + s.mu.Lock() + defer s.mu.Unlock() + s.forcedMemberWorkflowQueuesStatus[memberID] = status } // MemberGroupsRequestCount returns how many times GET .../members/{id}/groups has been @@ -223,14 +236,15 @@ func (s *Server) FolderSecurity(folderID string) client.ClmFolderSecurity { // RunStandalone so both construct exactly the same seeded state. func newState() *Server { return &Server{ - folders: make(map[string]*client.ClmFolder), - groups: make(map[string]*client.ClmGroup), - groupMembers: make(map[string][]string), - members: make(map[string]*client.ClmMember), - memberGroups: make(map[string][]string), - permissionSets: make(map[string]*client.ClmPermissionSet), - workflowQueues: make(map[string]*client.ClmWorkflowQueue), - memberWorkflowQueues: make(map[string][]string), + folders: make(map[string]*client.ClmFolder), + groups: make(map[string]*client.ClmGroup), + groupMembers: make(map[string][]string), + members: make(map[string]*client.ClmMember), + memberGroups: make(map[string][]string), + permissionSets: make(map[string]*client.ClmPermissionSet), + workflowQueues: make(map[string]*client.ClmWorkflowQueue), + memberWorkflowQueues: make(map[string][]string), + forcedMemberWorkflowQueuesStatus: make(map[string]int), } } diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 436cc47c..1ca46d8c 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -200,7 +200,16 @@ func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context } continue } - if isOptInFeatureUnavailableError(err) { + if isOptInFeatureUnavailableError(err) && len(membership) == 0 { + // Same reasoning as ListMembers' own memberPageToken == "" check + // above: nothing has been discovered yet, so a failure this early + // plausibly means "no CLM" (or no workflowqueues scope) for the whole + // account — safe to skip gracefully. Once queues have already been + // found, CLM is clearly available, so a later failure here (an + // expiring token, a scope revoked mid-scan) is a real, isolated + // problem, not an unavailability signal — failing loud beats + // discarding every already-discovered queue as if the whole feature + // were unavailable. return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) } return nil, allAnnos, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index d19ae832..e348a2ae 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -194,6 +194,75 @@ func TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan(t *testing.T) } } +// TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan confirms a single member +// 404ing (deleted between ListMembers and this call) is skipped, not a sync-wide +// failure — the member scan continues and still discovers every other member's queues. +func TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan(t *testing.T) { + srv, c := clmtest.NewServer(t) + // member-carol is a real seeded member (clmtest/seed.go) with zero queues of its + // own — forcing 404 here confirms the skip doesn't disturb discovery of the other + // members' queues (Onboarding/Escalations), not just that the scan doesn't crash. + srv.ForceMemberWorkflowQueuesStatus("member-carol", 404) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + if err != nil { + t.Fatalf("expected a 404 on one member to be tolerated, got error: %v", err) + } + if len(resources) != 2 { + t.Fatalf("expected the other members' 2 queues to still be discovered, got %d: %+v", len(resources), resources) + } +} + +// TestClmWorkflowQueueBuilder_List_SkipsGracefullyWhenFirstMemberDenied confirms the +// account-wide-unavailability escalation (errClmWorkflowQueuesUnavailable) still fires +// when nothing has been discovered yet — member-alice is first in scan order +// (clmtest/seed.go's memberOrder) and forcing PermissionDenied there means zero queues +// exist in membership at the point of failure. +func TestClmWorkflowQueueBuilder_List_SkipsGracefullyWhenFirstMemberDenied(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + if err != nil { + t.Fatalf("expected List to tolerate a PermissionDenied with nothing discovered yet, got error: %v", err) + } + if len(resources) != 0 { + t.Errorf("expected zero resources, got %d: %+v", len(resources), resources) + } + if res == nil { + t.Errorf("expected a non-nil SyncOpResults, got %+v", res) + } +} + +// TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenLaterMemberDenied is a regression +// test: discoverClmWorkflowQueueMembership previously escalated ANY +// isOptInFeatureUnavailableError to "CLM unavailable, return zero resources", +// regardless of scan position — so a PermissionDenied on member N (a token expiring or +// a scope revoked mid-scan) after earlier members had already contributed real queues +// would silently discard every already-discovered queue as if the whole feature were +// unavailable, the same false-deletion risk ListMembers' own memberPageToken == "" +// narrowing exists to avoid. member-bob is scanned after member-alice (whose Onboarding +// queue is already in membership by the time bob's call fails), so this must now fail +// loud instead of returning zero resources with a nil error. +func TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenLaterMemberDenied(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + if err == nil { + t.Fatal("expected a PermissionDenied after queues were already discovered to fail loudly, got nil error") + } + if len(resources) != 0 { + t.Errorf("expected zero resources on a hard failure, got %d: %+v", len(resources), resources) + } +} + // TestClmWorkflowQueueBuilder_Grants_ReadsFromCache confirms the other half of this // builder's design: Grants() must NOT re-scan every member per queue (that would turn // one O(members) traversal into O(queues * members) — see the builder's doc) — it reads From 4cd2db2db299e5df1c8cb1268a494d4eb8f51559 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 17:24:45 -0300 Subject: [PATCH 06/50] fix: gate escalation on a member ever succeeding, not on membership size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit len(membership) == 0 was a proxy for "nothing has succeeded yet", but it actually means "no queues discovered yet" — the two diverge whenever leading members legitimately have zero queues. On an account where the first N members all return 200 with empty Items, a token expiring on member N+1 left membership empty, so the fix still escalated to "CLM unavailable, zero resources" even though those N calls already proved CLM was reachable. Tracks succeededAtLeastOnce (set after any successful GetMemberWorkflowQueues call, independent of how many queues it found) and gates the escalation on that instead. --- pkg/connector/clm_workflow_queues.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 1ca46d8c..0f5dbeee 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -168,6 +168,7 @@ func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context membership := make(map[string]*clmWorkflowQueueMembershipEntry) var allAnnos annotations.Annotations var skippedMembers, skippedQueues int + var succeededAtLeastOnce bool memberPageToken := "" for { @@ -200,20 +201,22 @@ func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context } continue } - if isOptInFeatureUnavailableError(err) && len(membership) == 0 { + if isOptInFeatureUnavailableError(err) && !succeededAtLeastOnce { // Same reasoning as ListMembers' own memberPageToken == "" check - // above: nothing has been discovered yet, so a failure this early - // plausibly means "no CLM" (or no workflowqueues scope) for the whole - // account — safe to skip gracefully. Once queues have already been - // found, CLM is clearly available, so a later failure here (an - // expiring token, a scope revoked mid-scan) is a real, isolated - // problem, not an unavailability signal — failing loud beats + // above: gated on whether any GetMemberWorkflowQueues call has + // actually succeeded yet, not on len(membership) — a member can + // legitimately succeed with zero queues, so membership staying empty + // doesn't mean nothing has been confirmed working. Once at least one + // call has succeeded, CLM is clearly available, so a later failure + // here (an expiring token, a scope revoked mid-scan) is a real, + // isolated problem, not an unavailability signal — failing loud beats // discarding every already-discovered queue as if the whole feature // were unavailable. return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) } return nil, allAnnos, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) } + succeededAtLeastOnce = true allAnnos = append(allAnnos, queueAnnos...) for _, q := range queues { From 58c44e2f17b3ff1bec91e7c83eb10f5a53083fbd Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 21:07:19 -0300 Subject: [PATCH 07/50] fix: escalate NotFound too when nothing has succeeded yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocuSign's own CLM API docs (Response and Error Codes) confirm NotFound isn't unique to "this object doesn't exist" — it's the SAME response CLM returns for "this exists but you don't have access rights", specifically so a 403 never leaks whether the object exists: "If the user does not have permissions to see the object or the object does not exist, a 404 response code is returned." So a systemic 404 on GET .../members/{id}/workflowqueues (the endpoint not enabled for this account) was previously indistinguishable from an isolated "member deleted mid-scan" 404, and the per-member handling always treated NotFound as isolated — paying one wasted request per member, every sync, before ever concluding the account can't use this endpoint at all (a real cost given CXP-704's open rate-limit issue). Now escalates ANY tolerated code, NotFound included, to the account-wide skip while nothing has succeeded yet (succeededAtLeastOnce == false). Once at least one call has succeeded, a NotFound is far more likely the isolated case and is still skipped-and-continued per-member as before. --- pkg/connector/clm_workflow_queues.go | 58 +++++++++++++---------- pkg/connector/clm_workflow_queues_test.go | 32 ++++++++++++- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 0f5dbeee..620877cd 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -185,34 +185,42 @@ func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context memberID := clmIDFromHref(member.Href) queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) if err != nil { - // Only a bare NotFound is tolerated per-member — the isolated "member - // deleted between ListMembers and this call" case. Unauthenticated/ - // PermissionDenied/FailedPrecondition (the other codes - // isOptInFeatureUnavailableError covers) signal an account/token-wide - // problem, same as ListMembers' own first-page check above — tolerating - // those per-member would silently accept a partial membership set - // (missing queues, missing members) instead of skipping the whole - // resource type the way a systemic failure should. - if status.Code(err) == codes.NotFound { - skippedMembers++ - if n := skippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Warn("baton-docusign: CLM member not found while scanning workflow queues, skipping", - zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) + if isOptInFeatureUnavailableError(err) { + if !succeededAtLeastOnce { + // Nothing has proven this endpoint works for this account yet. + // DocuSign's own CLM API docs (Response and Error Codes) confirm + // NotFound isn't unique to "this object doesn't exist" — it's the + // SAME response CLM returns for "this exists but you don't have + // access rights", specifically so a 403 never leaks whether the + // object exists ("If the user does not have permissions to see + // the object or the object does not exist, a 404 response code is + // returned"). So a NotFound here is just as plausible a signal + // that workflow queues aren't available for this whole account as + // PermissionDenied/Unauthenticated are — treat it the same way and + // escalate to skipping the whole resource type, rather than paying + // one wasted request per member before ever concluding that (a + // real concern given CXP-704's open rate-limit issue). + return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) } - continue - } - if isOptInFeatureUnavailableError(err) && !succeededAtLeastOnce { - // Same reasoning as ListMembers' own memberPageToken == "" check - // above: gated on whether any GetMemberWorkflowQueues call has - // actually succeeded yet, not on len(membership) — a member can - // legitimately succeed with zero queues, so membership staying empty - // doesn't mean nothing has been confirmed working. Once at least one - // call has succeeded, CLM is clearly available, so a later failure - // here (an expiring token, a scope revoked mid-scan) is a real, - // isolated problem, not an unavailability signal — failing loud beats + if status.Code(err) == codes.NotFound { + // Once at least one call has already succeeded, this endpoint is + // clearly available for this account, so a NotFound from here on + // is far more likely the isolated "member deleted between + // ListMembers and this call" case than a systemic one — skip just + // this member and keep scanning. + skippedMembers++ + if n := skippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { + ctxzap.Extract(ctx).Warn("baton-docusign: CLM member not found while scanning workflow queues, skipping", + zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) + } + continue + } + // A non-NotFound tolerated code (PermissionDenied/Unauthenticated/ + // FailedPrecondition) after other members already succeeded is a + // real, isolated problem (an expiring token, a scope revoked + // mid-scan), not an unavailability signal — failing loud beats // discarding every already-discovered queue as if the whole feature // were unavailable. - return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) } return nil, allAnnos, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) } diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index e348a2ae..ac955efc 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -196,7 +196,11 @@ func TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan(t *testing.T) // TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan confirms a single member // 404ing (deleted between ListMembers and this call) is skipped, not a sync-wide -// failure — the member scan continues and still discovers every other member's queues. +// failure, once at least one other member has already proven the endpoint works — +// member-carol is scanned after member-alice (who succeeds and contributes a queue), +// so this exercises the "isolated NotFound" branch specifically, not the "nothing has +// succeeded yet" escalation TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnFirst404 +// covers. func TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan(t *testing.T) { srv, c := clmtest.NewServer(t) // member-carol is a real seeded member (clmtest/seed.go) with zero queues of its @@ -215,6 +219,32 @@ func TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan(t *testing.T) { } } +// TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnFirst404 is a regression test: +// DocuSign's own CLM API docs (Response and Error Codes) confirm NotFound is the SAME +// response CLM returns for "no access rights" as for "object doesn't exist" — a 403 +// never leaks whether the object exists. So a 404 on the very first member (nothing +// discovered yet) must escalate to the account-wide-unavailability skip exactly like +// 401/403 do, not be treated as an isolated "this one member is gone" case — otherwise +// an account systemically lacking workflow-queues access would pay one wasted request +// per member, every sync, before ever concluding that (CXP-704). +func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnFirst404(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-alice", 404) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + if err != nil { + t.Fatalf("expected List to tolerate a 404 on the first member with nothing discovered yet, got error: %v", err) + } + if len(resources) != 0 { + t.Errorf("expected zero resources, got %d: %+v", len(resources), resources) + } + if res == nil { + t.Errorf("expected a non-nil SyncOpResults, got %+v", res) + } +} + // TestClmWorkflowQueueBuilder_List_SkipsGracefullyWhenFirstMemberDenied confirms the // account-wide-unavailability escalation (errClmWorkflowQueuesUnavailable) still fires // when nothing has been discovered yet — member-alice is first in scan order From d41585d9dd2b47331492a3e1a599847ce6eda545 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 22:13:37 -0300 Subject: [PATCH 08/50] fix: require consecutive failures before escalating to account-unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression in the previous commit: escalating on a SINGLE tolerated failure (including NotFound) reintroduced the exact false-deletion race the isolated-NotFound skip exists to avoid — a member genuinely deleted between ListMembers and this call would wipe the whole resource type if it happened to be first in scan order. A systemic failure (the endpoint disabled for this account) 404s/403s on EVERY member; an isolated deletion race hits exactly one. Now requires clmWorkflowQueueUnavailableThreshold (3) consecutive tolerated failures with nothing yet succeeded before concluding "account-wide unavailable" — caps the wasted-request cost CXP-704 cares about without letting one unlucky ordering empty the resource type. Also adds a regression test pinning that succeededAtLeastOnce is set by a zero-queue success, not only one that contributes to membership. --- pkg/connector/clm_workflow_queues.go | 30 +++++++-- pkg/connector/clm_workflow_queues_test.go | 75 +++++++++++++++-------- 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 620877cd..fed26499 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -25,6 +25,13 @@ import ( // logging (see List()'s use of it). var errClmWorkflowQueuesUnavailable = errors.New("baton-docusign: CLM is not available for this account or token") +// clmWorkflowQueueUnavailableThreshold is how many CONSECUTIVE tolerated per-member +// failures (with nothing yet successfully scanned) discoverClmWorkflowQueueMembership +// requires before concluding the whole account can't use this endpoint — see the +// escalation branch's doc for why a single failure isn't enough. An arbitrary but +// deliberately small judgment call: no live CLM tenant to derive it from empirically. +const clmWorkflowQueueUnavailableThreshold = 3 + var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmWorkflowQueueBuilder)(nil) // entitlementClmWorkflowQueueMember is the single entitlement every CLM workflow queue @@ -169,6 +176,7 @@ func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context var allAnnos annotations.Annotations var skippedMembers, skippedQueues int var succeededAtLeastOnce bool + var consecutiveUnavailableFailures int memberPageToken := "" for { @@ -196,11 +204,23 @@ func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context // the object or the object does not exist, a 404 response code is // returned"). So a NotFound here is just as plausible a signal // that workflow queues aren't available for this whole account as - // PermissionDenied/Unauthenticated are — treat it the same way and - // escalate to skipping the whole resource type, rather than paying - // one wasted request per member before ever concluding that (a - // real concern given CXP-704's open rate-limit issue). - return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) + // PermissionDenied/Unauthenticated are — treat it the same way. + // + // But escalating on a SINGLE failure reintroduces a different + // false-deletion race: a member that was genuinely deleted between + // ListMembers and this call (the case the isolated-NotFound skip + // below exists for) would wipe the whole resource type if it just + // happens to be first in scan order. A systemic failure (the + // endpoint disabled for this account) 404s/403s on EVERY member, + // while an isolated deletion race hits exactly one — so require a + // few consecutive failures before concluding "systemic", capping + // the wasted-request cost CXP-704 cares about without letting one + // unlucky ordering empty the resource type. + consecutiveUnavailableFailures++ + if consecutiveUnavailableFailures >= clmWorkflowQueueUnavailableThreshold { + return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) + } + continue } if status.Code(err) == codes.NotFound { // Once at least one call has already succeeded, this endpoint is diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index ac955efc..262647d3 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -199,8 +199,8 @@ func TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan(t *testing.T) // failure, once at least one other member has already proven the endpoint works — // member-carol is scanned after member-alice (who succeeds and contributes a queue), // so this exercises the "isolated NotFound" branch specifically, not the "nothing has -// succeeded yet" escalation TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnFirst404 -// covers. +// succeeded yet" escalation +// TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures covers. func TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan(t *testing.T) { srv, c := clmtest.NewServer(t) // member-carol is a real seeded member (clmtest/seed.go) with zero queues of its @@ -219,46 +219,46 @@ func TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan(t *testing.T) { } } -// TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnFirst404 is a regression test: -// DocuSign's own CLM API docs (Response and Error Codes) confirm NotFound is the SAME -// response CLM returns for "no access rights" as for "object doesn't exist" — a 403 -// never leaks whether the object exists. So a 404 on the very first member (nothing -// discovered yet) must escalate to the account-wide-unavailability skip exactly like -// 401/403 do, not be treated as an isolated "this one member is gone" case — otherwise -// an account systemically lacking workflow-queues access would pay one wasted request -// per member, every sync, before ever concluding that (CXP-704). -func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnFirst404(t *testing.T) { +// TestClmWorkflowQueueBuilder_List_TeleratesBelowThresholdFailures is a regression +// test: escalating to the account-wide-unavailability skip on a SINGLE tolerated +// failure (the previous behavior) reintroduced the exact false-deletion race the +// isolated-NotFound skip exists to avoid — a member genuinely deleted between +// ListMembers and this call would wipe the whole resource type if it happened to be +// first in scan order. Forcing a 404 on only member-alice (first in scan order, below +// clmWorkflowQueueUnavailableThreshold) must NOT escalate: member-bob's real queues +// still get discovered normally. +func TestClmWorkflowQueueBuilder_List_ToleratesBelowThresholdFailures(t *testing.T) { srv, c := clmtest.NewServer(t) srv.ForceMemberWorkflowQueuesStatus("member-alice", 404) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) if err != nil { - t.Fatalf("expected List to tolerate a 404 on the first member with nothing discovered yet, got error: %v", err) - } - if len(resources) != 0 { - t.Errorf("expected zero resources, got %d: %+v", len(resources), resources) + t.Fatalf("expected a single below-threshold failure to be tolerated, got error: %v", err) } - if res == nil { - t.Errorf("expected a non-nil SyncOpResults, got %+v", res) + if len(resources) != 2 { + t.Fatalf("expected member-bob's 2 real queues to still be discovered, got %d: %+v", len(resources), resources) } } -// TestClmWorkflowQueueBuilder_List_SkipsGracefullyWhenFirstMemberDenied confirms the -// account-wide-unavailability escalation (errClmWorkflowQueuesUnavailable) still fires -// when nothing has been discovered yet — member-alice is first in scan order -// (clmtest/seed.go's memberOrder) and forcing PermissionDenied there means zero queues -// exist in membership at the point of failure. -func TestClmWorkflowQueueBuilder_List_SkipsGracefullyWhenFirstMemberDenied(t *testing.T) { +// TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures confirms +// the account-wide-unavailability escalation still fires once +// clmWorkflowQueueUnavailableThreshold consecutive members fail with nothing +// discovered yet — member-alice, member-bob, and member-carol are first in scan order +// (clmtest/seed.go's memberOrder), so forcing all three to fail reaches the threshold +// before any of them can succeed. +func TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures(t *testing.T) { srv, c := clmtest.NewServer(t) srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) + srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) + srv.ForceMemberWorkflowQueuesStatus("member-carol", 403) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) if err != nil { - t.Fatalf("expected List to tolerate a PermissionDenied with nothing discovered yet, got error: %v", err) + t.Fatalf("expected List to tolerate %d consecutive failures with nothing discovered yet, got error: %v", clmWorkflowQueueUnavailableThreshold, err) } if len(resources) != 0 { t.Errorf("expected zero resources, got %d: %+v", len(resources), resources) @@ -293,6 +293,31 @@ func TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenLaterMemberDenied(t *testin } } +// TestClmWorkflowQueueBuilder_List_ZeroQueueSuccessCountsAsSucceeded is a regression +// test for succeededAtLeastOnce's exact semantics: it must be set by ANY successful +// GetMemberWorkflowQueues call, including one that finds zero queues, not just one that +// contributes to membership. member-alice and member-bob (the only two seeded members +// with real queues) are both forced to fail — below clmWorkflowQueueUnavailableThreshold, +// so neither escalates — and member-carol (zero queues, clmtest/seed.go) succeeds next, +// which must count as proof the endpoint works. member-dave failing afterward must then +// fail loud, not be treated as still-pre-success. +func TestClmWorkflowQueueBuilder_List_ZeroQueueSuccessCountsAsSucceeded(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) + srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) + srv.ForceMemberWorkflowQueuesStatus("member-dave", 403) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + if err == nil { + t.Fatal("expected member-dave's failure (after member-carol's zero-queue success) to fail loudly, got nil error") + } + if len(resources) != 0 { + t.Errorf("expected zero resources on a hard failure, got %d: %+v", len(resources), resources) + } +} + // TestClmWorkflowQueueBuilder_Grants_ReadsFromCache confirms the other half of this // builder's design: Grants() must NOT re-scan every member per queue (that would turn // one O(members) traversal into O(queues * members) — see the builder's doc) — it reads From 0f344305befd53f249e52bf2a6511df84f34d285 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 22:26:28 -0300 Subject: [PATCH 09/50] doc: fix typo and stale sentinel doc after the consecutive-threshold fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a typo (TeleratesBelowThresholdFailures -> Tolerates...) and updates errClmWorkflowQueuesUnavailable/discoverClmWorkflowQueueMembership's doc comments, which still described the sentinel as firing only on the very first ListMembers call failing — the consecutive-threshold escalation added in the previous commit is a second way it fires. --- pkg/connector/clm_workflow_queues.go | 16 ++++++++++------ pkg/connector/clm_workflow_queues_test.go | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index fed26499..e041bd15 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -19,10 +19,12 @@ import ( ) // errClmWorkflowQueuesUnavailable is a sentinel discoverClmWorkflowQueueMembership wraps -// its return error with when the very first ListMembers call in the scan fails with -// isOptInFeatureUnavailableError — lets List() distinguish "skip this resource type -// gracefully" from a real failure via errors.Is, without losing the underlying error for -// logging (see List()'s use of it). +// its return error with when the account-wide-unavailability signal fires — either the +// very first ListMembers call failing with isOptInFeatureUnavailableError, or +// clmWorkflowQueueUnavailableThreshold consecutive per-member GetMemberWorkflowQueues +// calls doing the same with nothing yet successfully scanned. Lets List() distinguish +// "skip this resource type gracefully" from a real failure via errors.Is, without losing +// the underlying error for logging (see List()'s use of it). var errClmWorkflowQueuesUnavailable = errors.New("baton-docusign: CLM is not available for this account or token") // clmWorkflowQueueUnavailableThreshold is how many CONSECUTIVE tolerated per-member @@ -169,8 +171,10 @@ type clmWorkflowQueueMembershipEntry struct { // then build resources" shape like every sibling CLM builder's List(). // // Returns a nil map and an error wrapping errClmWorkflowQueuesUnavailable if the very -// first ListMembers call fails with isOptInFeatureUnavailableError — List() checks for -// that sentinel via errors.Is to decide whether to skip this resource type gracefully. +// first ListMembers call fails with isOptInFeatureUnavailableError, or if +// clmWorkflowQueueUnavailableThreshold consecutive per-member GetMemberWorkflowQueues +// calls do the same before any member has succeeded — List() checks for that sentinel +// via errors.Is to decide whether to skip this resource type gracefully. func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context.Context) (map[string]*clmWorkflowQueueMembershipEntry, annotations.Annotations, error) { membership := make(map[string]*clmWorkflowQueueMembershipEntry) var allAnnos annotations.Annotations diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 262647d3..a28f3ecb 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -219,7 +219,7 @@ func TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan(t *testing.T) { } } -// TestClmWorkflowQueueBuilder_List_TeleratesBelowThresholdFailures is a regression +// TestClmWorkflowQueueBuilder_List_ToleratesBelowThresholdFailures is a regression // test: escalating to the account-wide-unavailability skip on a SINGLE tolerated // failure (the previous behavior) reintroduced the exact false-deletion race the // isolated-NotFound skip exists to avoid — a member genuinely deleted between From 919222442a7a8c47d972e95b70805fe684624ca1 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Mon, 10 Aug 2026 22:28:35 -0300 Subject: [PATCH 10/50] fix: log the below-threshold member skip, matching the post-success one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The below-threshold continue (a tolerated failure with nothing succeeded yet, not enough to escalate) silently dropped that member's queue membership with no log line, unlike the post-success isolated-NotFound skip a few lines below it — so up to clmWorkflowQueueUnavailableThreshold-1 members could vanish from every queue's member list each sync with nothing in the logs to explain it. Same sampled Warn as the existing skip. --- pkg/connector/clm_workflow_queues.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index e041bd15..0d93d117 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -224,6 +224,14 @@ func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context if consecutiveUnavailableFailures >= clmWorkflowQueueUnavailableThreshold { return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) } + // Below the threshold: same visibility as the post-success + // isolated-NotFound skip below — this member's queue membership + // (if any) is silently missing from this sync otherwise. + skippedMembers++ + if n := skippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { + ctxzap.Extract(ctx).Warn("baton-docusign: failed to get CLM workflow queues for member, skipping", + zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) + } continue } if status.Code(err) == codes.NotFound { From afac0cc98b3b48e5c2c8efc2cb8ea8b9b4e3264b Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 19:56:22 -0300 Subject: [PATCH 11/50] refactor: chunk clm_workflow_queue's member scan across List() calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (luisina-santos, investigated via hypothesis decomposition — 5/5 confirmed): this connector has an explicit documented convention against looping through pages internally within a List() call (see .claude/skills/connector/review-connector.md's CRITICAL rule, and GetMemberGroups' own "intentional carve-out from the usual client-layer rule" doc) — but List() ran the entire member scan (every ListMembers page, plus a GetMemberWorkflowQueues call per member) inside a single call. That violation was real, but not required by the CLM API's shape: it has no list-all-queues endpoint, which necessitates SOME member scan, but the SDK's session store (already used to pass List()'s discoveries to Grants()) fully supports persisting partial scan progress across separate List() invocations too — so chunking was an achievable refactor, not a fundamental limitation. List() now drains exactly one ListMembers page per SDK-driven call, matching every sibling builder's shape. clmWorkflowQueueDiscoveryState persists the accumulating queueID->membership index and the escalation-threshold counters across chunks via the session cache — the counters specifically must span chunks, not reset per chunk, or 2 failures in one chunk plus 1 more in the next would never reach the threshold the same 3 consecutive failures in one call already do. Only the last page (the queue set can't be confirmed complete before then) finalizes the per-queue membership caches and emits resources; every earlier chunk returns zero resources plus a NextPageToken. Also (same review): batches the per-queue membership-cache writes into one session.SetManyJSON call instead of one SetJSON call per queue, and updates dedupeRateLimitAnnotations' doc to reflect that chunking already bounds per-response annotation volume to one page's members instead of the whole account. New tests cover the chunked shape directly: zero resources + non-empty NextPageToken on every page but the last, and the escalation counter correctly crossing the threshold on the first member of a later chunk after sitting below it through an earlier chunk (verified via mutation test: reverting to non-persisted state fails the new cross-chunk test). --- pkg/connector/clm_workflow_queues.go | 398 ++++++++++++---------- pkg/connector/clm_workflow_queues_test.go | 107 ++++++ 2 files changed, 316 insertions(+), 189 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 0d93d117..6f82b90a 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -2,7 +2,6 @@ package connector import ( "context" - "errors" "fmt" "github.com/conductorone/baton-docusign/pkg/client" @@ -18,22 +17,33 @@ import ( "google.golang.org/grpc/status" ) -// errClmWorkflowQueuesUnavailable is a sentinel discoverClmWorkflowQueueMembership wraps -// its return error with when the account-wide-unavailability signal fires — either the -// very first ListMembers call failing with isOptInFeatureUnavailableError, or -// clmWorkflowQueueUnavailableThreshold consecutive per-member GetMemberWorkflowQueues -// calls doing the same with nothing yet successfully scanned. Lets List() distinguish -// "skip this resource type gracefully" from a real failure via errors.Is, without losing -// the underlying error for logging (see List()'s use of it). -var errClmWorkflowQueuesUnavailable = errors.New("baton-docusign: CLM is not available for this account or token") - // clmWorkflowQueueUnavailableThreshold is how many CONSECUTIVE tolerated per-member -// failures (with nothing yet successfully scanned) discoverClmWorkflowQueueMembership -// requires before concluding the whole account can't use this endpoint — see the -// escalation branch's doc for why a single failure isn't enough. An arbitrary but -// deliberately small judgment call: no live CLM tenant to derive it from empirically. +// failures (with nothing yet successfully scanned) List() requires, across however many +// chunked calls it takes to see them, before concluding the whole account can't use this +// endpoint — see the escalation branch's doc for why a single failure isn't enough. An +// arbitrary but deliberately small judgment call: no live CLM tenant to derive it from +// empirically. const clmWorkflowQueueUnavailableThreshold = 3 +// clmSessionKeyWorkflowQueueDiscoveryState is where List() persists its accumulating +// member-scan state between its own successive SDK-driven calls — see List()'s doc for +// why the scan is chunked this way instead of running to completion inside one call. +const clmSessionKeyWorkflowQueueDiscoveryState = "clm_workflow_queue_discovery_state" + +// clmWorkflowQueueDiscoveryState is List()'s accumulator: the queueID->membership index +// the scan builds up, plus the escalation-threshold counters that must span every chunk +// of the scan, not reset per chunk (three tolerated failures on members split across two +// separate List() calls must still escalate, exactly as three in one call would). +// Exported fields: this round-trips through session.SetJSON/GetJSON (encoding/json can't +// see unexported fields). +type clmWorkflowQueueDiscoveryState struct { + Membership map[string]*clmWorkflowQueueMembershipEntry `json:"membership"` + SucceededAtLeastOnce bool `json:"succeeded_at_least_once"` + ConsecutiveUnavailableFailures int `json:"consecutive_unavailable_failures"` + SkippedMembers int `json:"skipped_members"` + SkippedQueues int `json:"skipped_queues"` +} + var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmWorkflowQueueBuilder)(nil) // entitlementClmWorkflowQueueMember is the single entitlement every CLM workflow queue @@ -46,39 +56,38 @@ func clmSessionKeyQueueMembers(queueID string) string { return "clm_workflow_queue_members:" + queueID } -// clmWorkflowQueueBuilder syncs CLM WorkflowQueues — the API's own term for what the -// CLM admin console reportedly calls "Task Groups" (the surface the customer asked -// for in Pylon #11836). That equivalence is an unconfirmed assumption: no DocuSign -// document states it, and confirming it needs eyes on a real CLM admin console — see -// resource_types.go. +// clmWorkflowQueueBuilder syncs CLM WorkflowQueues — the API's own term for what the CLM +// admin console reportedly calls "Task Groups". That equivalence is an unconfirmed +// assumption: no DocuSign document states it, and confirming it needs eyes on a real CLM +// admin console — see resource_types.go. // // The CLM API has no list-all endpoint for workflow queues and no reverse lookup from a // queue to its members — the only documented read path is per-member (GET // .../members/{id}/workflowqueues). So both List() and Grants() are built around a -// single full member scan, not the direct list-all-then-page-per-resource shape every -// other builder in this connector uses: +// member scan, not the direct list-all-then-page-per-resource shape every other builder +// in this connector uses: // -// - List() (called exactly once — see below) pages through every clm_member via -// client.ListMembers, and for each one calls client.GetMemberWorkflowQueues. It -// collects the distinct queues seen (by ID) as the resources to return, and — as a -// side effect of the same scan — builds a queueID -> []memberID index and writes it -// to the SDK's session cache (attr.Session), one entry per queue. +// - List() chunks the scan one ListMembers page per SDK-driven call — the usual +// one-page-per-call shape every sibling builder in this connector uses, unlike an +// earlier version of this file that ran the entire member scan (every ListMembers +// page, plus a GetMemberWorkflowQueues call per member) inside a single call. It +// persists its accumulating queueID -> []memberID index and escalation-threshold +// counters (clmWorkflowQueueDiscoveryState) to the SDK's session cache between +// chunks, since a plain Go value doesn't survive across separate List() invocations. +// Only on the LAST member page — the queue set can't be confirmed complete before +// then (there's no list-all endpoint to check it against) — does it write the final +// per-queue membership caches Grants() reads and emit the discovered queues as +// resources; every earlier chunk returns zero resources plus a NextPageToken. // - Grants(ctx, queueResource, attr) reads that queue's member list straight back out // of the session cache instead of re-scanning every member per queue, which would -// turn one expensive O(members) traversal into O(queues * members) — a real -// concern on a connector that already has an open rate-limit bug for this same -// customer (CXP-704). +// turn one expensive O(members) traversal into O(queues * members) — a real concern +// on a connector that already has an open rate-limit bug for this same customer +// (CXP-704). // -// This only works because the session cache persists for the whole sync (List() runs -// before Grants() for every resource of a type) and is shared across resource types -// within one sync — see cmd/baton-docusign/main.go's -// connectorrunner.WithSessionStoreEnabled(). -// -// List() returns every queue it finds in a single page rather than exposing Baton's own -// pagination: the full member scan has to complete before a single queue can be safely -// returned anyway (there's no way to know page 1 of "queues" is complete without having -// scanned every member), so there's nothing to page over — same choice clm_role makes -// for its own small, fully-enumerable set. +// This only works because the session cache persists for the whole sync (across all of +// List()'s own chunked calls, and through to when Grants() runs for every resource of +// this type afterward) and is shared across resource types within one sync — see +// cmd/baton-docusign/main.go's connectorrunner.WithSessionStoreEnabled(). // // Read-only: the API documents work-item assign/unassign, not queue-membership // grant/revoke, so there's no Grant/Revoke on this builder — matching @@ -95,33 +104,164 @@ func (b *clmWorkflowQueueBuilder) ResourceType(_ context.Context) *v2.ResourceTy return clmWorkflowQueueResourceType } +// List drains one ListMembers page per call — see clmWorkflowQueueBuilder's doc for why +// the member scan is chunked this way, and clmWorkflowQueueDiscoveryState for what gets +// persisted across chunks. Escalation/error-tolerance semantics for +// GetMemberWorkflowQueues failures are unchanged from a single-call scan: a tolerated +// code (PermissionDenied/Unauthenticated/NotFound/FailedPrecondition) before anything has +// succeeded counts toward clmWorkflowQueueUnavailableThreshold regardless of which chunk +// it lands in; a NotFound after something has already succeeded is an isolated skip; any +// other tolerated code after success fails loud. func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { - membership, allAnnos, err := b.discoverClmWorkflowQueueMembership(ctx) + bag, pageToken, err := parsePageToken(attr.PageToken.Token, &v2.ResourceId{ResourceType: clmWorkflowQueueResourceType.Id}) + if err != nil { + return nil, nil, err + } + + state, _, err := session.GetJSON[clmWorkflowQueueDiscoveryState](ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState) + if err != nil { + return nil, nil, fmt.Errorf("baton-docusign: failed to read CLM workflow queue discovery state: %w", err) + } + if state.Membership == nil { + state.Membership = make(map[string]*clmWorkflowQueueMembershipEntry) + } + + members, nextMemberPageToken, allAnnos, err := b.client.ListMembers(ctx, client.PageOptions{ + PageSize: attr.PageToken.Size, + PageToken: pageToken, + }) if err != nil { - if errors.Is(err, errClmWorkflowQueuesUnavailable) { + if pageToken == "" && isOptInFeatureUnavailableError(err) { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } return nil, nil, err } - resources := make([]*v2.Resource, 0, len(membership)) - for queueID, entry := range membership { - if err := session.SetJSON(ctx, attr.Session, clmSessionKeyQueueMembers(queueID), entry.members); err != nil { - // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) - // only tells the SDK to accept a store connection — whether one actually - // exists still depends on the parent process wiring a listen port, and it - // falls back to NoOpSessionStore (every Set call fails) whenever it doesn't. - // This resource type's Grants() cannot function without it, but that's not - // true of the rest of the sync — a hard error here would fail every other - // resource type too. Skip gracefully instead, same as an unavailable CLM - // subscription. - ctxzap.Extract(ctx).Warn("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", - zap.String("queue_id", queueID), zap.Error(err)) - return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil + for _, member := range members { + memberID := clmIDFromHref(member.Href) + queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) + if err != nil { + if isOptInFeatureUnavailableError(err) { + if !state.SucceededAtLeastOnce { + // Nothing has proven this endpoint works for this account yet. + // DocuSign's own CLM API docs (Response and Error Codes) confirm + // NotFound isn't unique to "this object doesn't exist" — it's the + // SAME response CLM returns for "this exists but you don't have + // access rights", specifically so a 403 never leaks whether the + // object exists ("If the user does not have permissions to see + // the object or the object does not exist, a 404 response code is + // returned"). So a NotFound here is just as plausible a signal + // that workflow queues aren't available for this whole account as + // PermissionDenied/Unauthenticated are — treat it the same way. + // + // But escalating on a SINGLE failure reintroduces a different + // false-deletion race: a member that was genuinely deleted between + // ListMembers and this call (the case the isolated-NotFound skip + // below exists for) would wipe the whole resource type if it just + // happens to be first in scan order. A systemic failure (the + // endpoint disabled for this account) 404s/403s on EVERY member, + // while an isolated deletion race hits exactly one — so require a + // few consecutive failures (spanning as many chunks as it takes) + // before concluding "systemic", capping the wasted-request cost + // CXP-704 cares about without letting one unlucky ordering empty + // the resource type. + state.ConsecutiveUnavailableFailures++ + if state.ConsecutiveUnavailableFailures >= clmWorkflowQueueUnavailableThreshold { + ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_workflow_queue sync", zap.Error(err)) + return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil + } + // Below the threshold: same visibility as the post-success + // isolated-NotFound skip below — this member's queue membership + // (if any) is silently missing from this sync otherwise. + state.SkippedMembers++ + if n := state.SkippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { + ctxzap.Extract(ctx).Warn("baton-docusign: failed to get CLM workflow queues for member, skipping", + zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) + } + continue + } + if status.Code(err) == codes.NotFound { + // Once at least one call has already succeeded, this endpoint is + // clearly available for this account, so a NotFound from here on + // is far more likely the isolated "member deleted between + // ListMembers and this call" case than a systemic one — skip just + // this member and keep scanning. + state.SkippedMembers++ + if n := state.SkippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { + ctxzap.Extract(ctx).Warn("baton-docusign: CLM member not found while scanning workflow queues, skipping", + zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) + } + continue + } + // A non-NotFound tolerated code (PermissionDenied/Unauthenticated/ + // FailedPrecondition) after other members already succeeded is a + // real, isolated problem (an expiring token, a scope revoked + // mid-scan), not an unavailability signal — failing loud beats + // discarding every already-discovered queue as if the whole feature + // were unavailable. + } + return nil, nil, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) } + state.SucceededAtLeastOnce = true + allAnnos = append(allAnnos, queueAnnos...) + + for _, q := range queues { + queueID := clmIDFromHref(q.Href) + if queueID == "" { + state.SkippedQueues++ + if n := state.SkippedQueues; n == 1 || n == 10 || n == 100 || n%1000 == 0 { + ctxzap.Extract(ctx).Warn("baton-docusign: CLM workflow queue has an empty Href, skipping", + zap.String("member_id", memberID), zap.String("queue_name", q.Name), zap.Int("total_occurrences", n)) + } + continue + } + entry, ok := state.Membership[queueID] + if !ok { + entry = &clmWorkflowQueueMembershipEntry{Queue: q} + state.Membership[queueID] = entry + } + entry.Members = append(entry.Members, memberID) + } + } + + if err := session.SetJSON(ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState, state); err != nil { + // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) + // only tells the SDK to accept a store connection — whether one actually + // exists still depends on the parent process wiring a listen port, and it + // falls back to NoOpSessionStore (every Set call fails) whenever it doesn't. + // This resource type's Grants() cannot function without it, but that's not + // true of the rest of the sync — a hard error here would fail every other + // resource type too. Skip gracefully instead, same as an unavailable CLM + // subscription. + ctxzap.Extract(ctx).Warn("baton-docusign: failed to cache CLM workflow queue discovery progress, skipping clm_workflow_queue sync", zap.Error(err)) + return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil + } - queueResource, err := parseIntoClmWorkflowQueueResource(&entry.queue) + if nextMemberPageToken != "" { + outToken, err := bag.NextToken(nextMemberPageToken) + if err != nil { + return nil, nil, err + } + return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos), NextPageToken: outToken}, nil + } + + // Last member page: the queue set is only guaranteed complete now (see this + // builder's doc) — finalize the per-queue membership caches Grants() reads (one + // SetManyJSON call instead of one SetJSON call per queue) and emit every discovered + // queue as a resource. + membersByKey := make(map[string][]string, len(state.Membership)) + for queueID, entry := range state.Membership { + membersByKey[clmSessionKeyQueueMembers(queueID)] = entry.Members + } + if err := session.SetManyJSON(ctx, attr.Session, membersByKey); err != nil { + ctxzap.Extract(ctx).Warn("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) + return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil + } + + resources := make([]*v2.Resource, 0, len(state.Membership)) + for _, entry := range state.Membership { + queueResource, err := parseIntoClmWorkflowQueueResource(&entry.Queue) if err != nil { return nil, nil, err } @@ -132,10 +272,12 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at } // dedupeRateLimitAnnotations keeps every non-rate-limit annotation as-is but collapses -// all RateLimitDescription entries down to the last one — discoverClmWorkflowQueueMembership -// appends one member-page annotation set plus one per-member queue-fetch annotation set -// per iteration, so a large account accumulates thousands of near-identical rate-limit -// snapshots in a single List() response; only the most recent one is meaningful. +// all RateLimitDescription entries down to the last one — List() appends one +// GetMemberWorkflowQueues annotation set per member processed in its current chunk, so a +// response can carry several near-identical rate-limit snapshots; only the most recent +// one is meaningful. Chunking (one ListMembers page per call) already bounds this to one +// page's worth of members rather than the whole account, but a page can still be in the +// hundreds, so this stays worth doing. func dedupeRateLimitAnnotations(annos annotations.Annotations) annotations.Annotations { var out annotations.Annotations lastRateLimitIdx := -1 @@ -153,136 +295,14 @@ func dedupeRateLimitAnnotations(annos annotations.Annotations) annotations.Annot } // clmWorkflowQueueMembershipEntry pairs a discovered queue with the member IDs found -// to belong to it — the two pieces of data discoverClmWorkflowQueueMembership's member -// scan produces for every queue, kept together under one key instead of two parallel -// maps that would otherwise always share the same key set. +// to belong to it — the two pieces of data the member scan produces for every queue, +// kept together under one key instead of two parallel maps that would otherwise always +// share the same key set. Exported fields: this struct round-trips through +// session.SetJSON/GetJSON as part of clmWorkflowQueueDiscoveryState (encoding/json +// can't see unexported fields). type clmWorkflowQueueMembershipEntry struct { - queue client.ClmWorkflowQueue - members []string -} - -// discoverClmWorkflowQueueMembership is the member-scan discovery algorithm this -// builder's whole design is built around (see the type doc above): the CLM API has no -// list-all endpoint for workflow queues and no reverse lookup from a queue to its -// members, so this pages through every clm_member and, for each, calls -// client.GetMemberWorkflowQueues to learn which queues they're in. Pure API-domain -// discovery — no v2.Resource, no session cache — kept separate from List() so this -// algorithm is testable and readable on its own, and List() stays a plain "discover, -// then build resources" shape like every sibling CLM builder's List(). -// -// Returns a nil map and an error wrapping errClmWorkflowQueuesUnavailable if the very -// first ListMembers call fails with isOptInFeatureUnavailableError, or if -// clmWorkflowQueueUnavailableThreshold consecutive per-member GetMemberWorkflowQueues -// calls do the same before any member has succeeded — List() checks for that sentinel -// via errors.Is to decide whether to skip this resource type gracefully. -func (b *clmWorkflowQueueBuilder) discoverClmWorkflowQueueMembership(ctx context.Context) (map[string]*clmWorkflowQueueMembershipEntry, annotations.Annotations, error) { - membership := make(map[string]*clmWorkflowQueueMembershipEntry) - var allAnnos annotations.Annotations - var skippedMembers, skippedQueues int - var succeededAtLeastOnce bool - var consecutiveUnavailableFailures int - - memberPageToken := "" - for { - members, nextMemberPageToken, memberAnnos, err := b.client.ListMembers(ctx, client.PageOptions{PageToken: memberPageToken}) - if err != nil { - if memberPageToken == "" && isOptInFeatureUnavailableError(err) { - return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) - } - return nil, allAnnos, err - } - allAnnos = append(allAnnos, memberAnnos...) - - for _, member := range members { - memberID := clmIDFromHref(member.Href) - queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) - if err != nil { - if isOptInFeatureUnavailableError(err) { - if !succeededAtLeastOnce { - // Nothing has proven this endpoint works for this account yet. - // DocuSign's own CLM API docs (Response and Error Codes) confirm - // NotFound isn't unique to "this object doesn't exist" — it's the - // SAME response CLM returns for "this exists but you don't have - // access rights", specifically so a 403 never leaks whether the - // object exists ("If the user does not have permissions to see - // the object or the object does not exist, a 404 response code is - // returned"). So a NotFound here is just as plausible a signal - // that workflow queues aren't available for this whole account as - // PermissionDenied/Unauthenticated are — treat it the same way. - // - // But escalating on a SINGLE failure reintroduces a different - // false-deletion race: a member that was genuinely deleted between - // ListMembers and this call (the case the isolated-NotFound skip - // below exists for) would wipe the whole resource type if it just - // happens to be first in scan order. A systemic failure (the - // endpoint disabled for this account) 404s/403s on EVERY member, - // while an isolated deletion race hits exactly one — so require a - // few consecutive failures before concluding "systemic", capping - // the wasted-request cost CXP-704 cares about without letting one - // unlucky ordering empty the resource type. - consecutiveUnavailableFailures++ - if consecutiveUnavailableFailures >= clmWorkflowQueueUnavailableThreshold { - return nil, allAnnos, fmt.Errorf("%w: %w", errClmWorkflowQueuesUnavailable, err) - } - // Below the threshold: same visibility as the post-success - // isolated-NotFound skip below — this member's queue membership - // (if any) is silently missing from this sync otherwise. - skippedMembers++ - if n := skippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Warn("baton-docusign: failed to get CLM workflow queues for member, skipping", - zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) - } - continue - } - if status.Code(err) == codes.NotFound { - // Once at least one call has already succeeded, this endpoint is - // clearly available for this account, so a NotFound from here on - // is far more likely the isolated "member deleted between - // ListMembers and this call" case than a systemic one — skip just - // this member and keep scanning. - skippedMembers++ - if n := skippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Warn("baton-docusign: CLM member not found while scanning workflow queues, skipping", - zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) - } - continue - } - // A non-NotFound tolerated code (PermissionDenied/Unauthenticated/ - // FailedPrecondition) after other members already succeeded is a - // real, isolated problem (an expiring token, a scope revoked - // mid-scan), not an unavailability signal — failing loud beats - // discarding every already-discovered queue as if the whole feature - // were unavailable. - } - return nil, allAnnos, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) - } - succeededAtLeastOnce = true - allAnnos = append(allAnnos, queueAnnos...) - - for _, q := range queues { - queueID := clmIDFromHref(q.Href) - if queueID == "" { - skippedQueues++ - if n := skippedQueues; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Warn("baton-docusign: CLM workflow queue has an empty Href, skipping", - zap.String("member_id", memberID), zap.String("queue_name", q.Name), zap.Int("total_occurrences", n)) - } - continue - } - entry, ok := membership[queueID] - if !ok { - entry = &clmWorkflowQueueMembershipEntry{queue: q} - membership[queueID] = entry - } - entry.members = append(entry.members, memberID) - } - } - - if nextMemberPageToken == "" { - return membership, allAnnos, nil - } - memberPageToken = nextMemberPageToken - } + Queue client.ClmWorkflowQueue `json:"queue"` + Members []string `json:"members"` } // Entitlements returns nil — the SDK does not call this when StaticEntitlementSyncerV2 diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index a28f3ecb..960c447e 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -8,6 +8,7 @@ import ( "github.com/conductorone/baton-docusign/pkg/client/clmtest" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/pagination" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/conductorone/baton-sdk/pkg/types/sessions" ) @@ -374,3 +375,109 @@ func TestClmWorkflowQueueBuilder_Grants_CacheMiss(t *testing.T) { t.Errorf("expected zero grants on a cache miss, got %d: %+v", len(grants), grants) } } + +// TestClmWorkflowQueueBuilder_List_ChunksAcrossPages is the core regression test for +// this builder's chunked design (a review finding: an earlier version ran the entire +// member scan inside one List() call, violating this connector's usual one-page-per-call +// convention). Forcing PageSize 2 against the 6 seeded members (alice, bob, carol, dave, +// eve, frank) produces 3 ListMembers pages, so 3 separate List() calls are required. +// Every call but the last must return zero resources with a non-empty NextPageToken — +// the queue set can't be confirmed complete before the member scan is — and the final +// call must return the same 2 distinct queues (Onboarding, Escalations) the single-call +// design already proved correct in TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan. +func TestClmWorkflowQueueBuilder_List_ChunksAcrossPages(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} + + var resources []*v2.Resource + pageToken := "" + for i := 0; i < 10; i++ { + attr.PageToken = pagination.Token{Size: 2, Token: pageToken} + res, syncRes, err := b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("List page %d: %v", i, err) + } + if syncRes.NextPageToken == "" { + resources = res + break + } + if len(res) != 0 { + t.Fatalf("page %d: expected zero resources before the member scan completes, got %d: %+v", i, len(res), res) + } + pageToken = syncRes.NextPageToken + } + + if len(resources) != 2 { + t.Fatalf("expected 2 distinct workflow queues (Onboarding, Escalations) on the final page, got %d: %+v", len(resources), resources) + } + names := make(map[string]bool) + for _, r := range resources { + names[r.DisplayName] = true + } + if !names["Onboarding"] || !names["Escalations"] { + t.Errorf("expected both Onboarding and Escalations among the discovered queues, got %v", names) + } + + // Grants() must work off the same session store exactly as the single-call design. + byName := make(map[string]*v2.Resource) + for _, r := range resources { + byName[r.DisplayName] = r + } + grants, _, err := b.Grants(ctx, byName["Onboarding"], attr) + if err != nil { + t.Fatalf("Grants(Onboarding): %v", err) + } + if len(grants) != 2 { + t.Fatalf("expected 2 members (alice, bob) in Onboarding, got %d: %+v", len(grants), grants) + } +} + +// TestClmWorkflowQueueBuilder_List_EscalationThresholdSpansChunks is a regression test +// for the specific risk chunking introduces: the escalation-threshold counters +// (clmWorkflowQueueDiscoveryState) must persist and keep accumulating ACROSS separate +// List() calls, not reset per chunk — otherwise 2 consecutive failures in one chunk +// followed by 1 more in the next chunk would never reach clmWorkflowQueueUnavailableThreshold +// (3), even though the same 3 consecutive failures in a single unchunked call already do +// (per TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures). Forces +// alice and bob (chunk 1, PageSize 2) and carol (chunk 2) to all fail — the first chunk +// alone only sees 2 failures (below threshold, must NOT escalate yet), and the second +// chunk's first member pushes the running total to 3 and must escalate there. +func TestClmWorkflowQueueBuilder_List_EscalationThresholdSpansChunks(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) + srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) + srv.ForceMemberWorkflowQueuesStatus("member-carol", 403) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} + + // Chunk 1: alice, bob — 2 failures, below threshold, must continue (non-empty + // NextPageToken) with zero resources, not escalate yet. + attr.PageToken = pagination.Token{Size: 2, Token: ""} + resources, syncRes, err := b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("chunk 1: expected 2 below-threshold failures to be tolerated, got error: %v", err) + } + if len(resources) != 0 { + t.Fatalf("chunk 1: expected zero resources, got %d: %+v", len(resources), resources) + } + if syncRes.NextPageToken == "" { + t.Fatal("chunk 1: expected a non-empty NextPageToken — the member scan isn't done and shouldn't have escalated yet") + } + + // Chunk 2: carol is first — this is the 3rd CONSECUTIVE failure counting the two + // from chunk 1, so it must escalate here, before ever reaching dave. + attr.PageToken = pagination.Token{Size: 2, Token: syncRes.NextPageToken} + resources, syncRes, err = b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("chunk 2: expected the threshold-crossing failure to skip gracefully, got error: %v", err) + } + if len(resources) != 0 { + t.Errorf("chunk 2: expected zero resources after escalating, got %d: %+v", len(resources), resources) + } + if syncRes.NextPageToken != "" { + t.Error("chunk 2: expected an empty NextPageToken — escalation should end the sync for this resource type, not request another chunk") + } +} From 0c3c19e06a80537368ed54e2f93336a3f039df62 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 20:05:50 -0300 Subject: [PATCH 12/50] fix: session-store-write-failure logs to Debug, not Warn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer never sees Warn-level logs, and skipping this resource type gracefully on a session-store write failure is the same class of tolerated condition every other branch in this file already logs at Debug/Info — there's no reason these two specifically should be the exception. --- pkg/connector/clm_workflow_queues.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 6f82b90a..a51ce759 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -234,7 +234,11 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // true of the rest of the sync — a hard error here would fail every other // resource type too. Skip gracefully instead, same as an unavailable CLM // subscription. - ctxzap.Extract(ctx).Warn("baton-docusign: failed to cache CLM workflow queue discovery progress, skipping clm_workflow_queue sync", zap.Error(err)) + // Debug, not Warn: a customer never sees Warn-level logs, and this is the same + // class of "skip gracefully" condition every other tolerated-error branch in this + // file already logs at Debug/Info — there's no reason this one specific failure + // mode should be the exception. + ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue discovery progress, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } @@ -255,7 +259,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at membersByKey[clmSessionKeyQueueMembers(queueID)] = entry.Members } if err := session.SetManyJSON(ctx, attr.Session, membersByKey); err != nil { - ctxzap.Extract(ctx).Warn("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) + ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } From d19d7116114a40cefc11c5188c13df20e3299a44 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 20:26:07 -0300 Subject: [PATCH 13/50] fix: drop inaccurate justification in Debug-log comment The comment claimed every other tolerated-error branch in this file already logs at Debug/Info, but the per-member/per-queue skip branches still use Warn. State the actual reasoning instead. --- pkg/connector/clm_workflow_queues.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index a51ce759..6e2a89e0 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -234,10 +234,10 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // true of the rest of the sync — a hard error here would fail every other // resource type too. Skip gracefully instead, same as an unavailable CLM // subscription. - // Debug, not Warn: a customer never sees Warn-level logs, and this is the same - // class of "skip gracefully" condition every other tolerated-error branch in this - // file already logs at Debug/Info — there's no reason this one specific failure - // mode should be the exception. + // Debug, not Warn: a customer never sees Warn-level logs, and a session-store + // write failure here is an infra/config issue (store not wired), not something + // actionable by the customer — unlike the per-member/per-queue Warn logs below, + // which flag a specific member or queue that may need investigation. ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue discovery progress, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } From b6f7c83b1428752ea06407f2dcb645ec6514f774 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 20:36:23 -0300 Subject: [PATCH 14/50] fix: drop ticket/review-process references from code comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explaining a log-level choice or referencing "a review finding"/ticket number belongs in the PR discussion, not the source — it rots and doesn't help a future reader. Keep only the durable technical reasoning. --- pkg/connector/clm_workflow_queues.go | 15 +++++---------- pkg/connector/clm_workflow_queues_test.go | 9 +++++---- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 6e2a89e0..21bc0c2a 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -80,9 +80,8 @@ func clmSessionKeyQueueMembers(queueID string) string { // resources; every earlier chunk returns zero resources plus a NextPageToken. // - Grants(ctx, queueResource, attr) reads that queue's member list straight back out // of the session cache instead of re-scanning every member per queue, which would -// turn one expensive O(members) traversal into O(queues * members) — a real concern -// on a connector that already has an open rate-limit bug for this same customer -// (CXP-704). +// turn one expensive O(members) traversal into O(queues * members) API calls against +// an already rate-limited endpoint. // // This only works because the session cache persists for the whole sync (across all of // List()'s own chunked calls, and through to when Grants() runs for every resource of @@ -163,9 +162,9 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // endpoint disabled for this account) 404s/403s on EVERY member, // while an isolated deletion race hits exactly one — so require a // few consecutive failures (spanning as many chunks as it takes) - // before concluding "systemic", capping the wasted-request cost - // CXP-704 cares about without letting one unlucky ordering empty - // the resource type. + // before concluding "systemic", capping wasted requests against an + // already rate-limited endpoint without letting one unlucky + // ordering empty the resource type. state.ConsecutiveUnavailableFailures++ if state.ConsecutiveUnavailableFailures >= clmWorkflowQueueUnavailableThreshold { ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_workflow_queue sync", zap.Error(err)) @@ -234,10 +233,6 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // true of the rest of the sync — a hard error here would fail every other // resource type too. Skip gracefully instead, same as an unavailable CLM // subscription. - // Debug, not Warn: a customer never sees Warn-level logs, and a session-store - // write failure here is an infra/config issue (store not wired), not something - // actionable by the customer — unlike the per-member/per-queue Warn logs below, - // which flag a specific member or queue that may need investigation. ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue discovery progress, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 960c447e..e5bc24ca 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -377,10 +377,11 @@ func TestClmWorkflowQueueBuilder_Grants_CacheMiss(t *testing.T) { } // TestClmWorkflowQueueBuilder_List_ChunksAcrossPages is the core regression test for -// this builder's chunked design (a review finding: an earlier version ran the entire -// member scan inside one List() call, violating this connector's usual one-page-per-call -// convention). Forcing PageSize 2 against the 6 seeded members (alice, bob, carol, dave, -// eve, frank) produces 3 ListMembers pages, so 3 separate List() calls are required. +// this builder's chunked design: List() must page one ListMembers page per call, like +// every other builder in this connector, instead of running the entire member scan +// inside a single call. Forcing PageSize 2 against the 6 seeded members (alice, bob, +// carol, dave, eve, frank) produces 3 ListMembers pages, so 3 separate List() calls are +// required. // Every call but the last must return zero resources with a non-empty NextPageToken — // the queue set can't be confirmed complete before the member scan is — and the final // call must return the same 2 distinct queues (Onboarding, Escalations) the single-call From df9bfbfe6166d42f7d53bae425368ade9570d603 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 20:52:25 -0300 Subject: [PATCH 15/50] fix: address remaining review findings on clm_workflow_queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session-store read failure (session.GetJSON) at the top of List() hard-errored the whole sync instead of degrading gracefully, unlike the existing write-failure path — NoOpSessionStore fails every call, read or write, whenever the parent process hasn't wired a session store. Skip gracefully on read failure too. - Persist queue membership incrementally per chunk (merge into each touched queue's own session key via GetManyJSON/SetManyJSON) instead of accumulating one all-queues blob that gets rewritten in full every chunk — bounds both session-store write size and total traffic to O(members) instead of O(members^2) over a full scan. - Fixed a pre-existing bug in the fakeSessionStore test double: GetMany's second return value means "retry these keys", not "these keys don't exist" — reporting absent keys there made session.GetManyJSON loop forever and error out, which only surfaced once real code exercised GetMany for the first time. - Test coverage: session-read-failure graceful skip, and merging membership for the same queue across two separate chunks (verified via mutation testing that the merge test actually catches an overwrite-instead-of-append regression). --- pkg/connector/clm_workflow_queues.go | 123 +++++++++++++--------- pkg/connector/clm_workflow_queues_test.go | 100 ++++++++++++++++-- 2 files changed, 161 insertions(+), 62 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 21bc0c2a..c61dc20f 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -30,18 +30,19 @@ const clmWorkflowQueueUnavailableThreshold = 3 // why the scan is chunked this way instead of running to completion inside one call. const clmSessionKeyWorkflowQueueDiscoveryState = "clm_workflow_queue_discovery_state" -// clmWorkflowQueueDiscoveryState is List()'s accumulator: the queueID->membership index -// the scan builds up, plus the escalation-threshold counters that must span every chunk -// of the scan, not reset per chunk (three tolerated failures on members split across two -// separate List() calls must still escalate, exactly as three in one call would). -// Exported fields: this round-trips through session.SetJSON/GetJSON (encoding/json can't -// see unexported fields). +// clmWorkflowQueueDiscoveryState is List()'s accumulator: the registry of distinct +// queues the scan has found so far (metadata only — member lists are persisted +// separately and incrementally, see List()'s doc), plus the escalation-threshold +// counters that must span every chunk of the scan, not reset per chunk (three tolerated +// failures on members split across two separate List() calls must still escalate, +// exactly as three in one call would). Exported fields: this round-trips through +// session.SetJSON/GetJSON (encoding/json can't see unexported fields). type clmWorkflowQueueDiscoveryState struct { - Membership map[string]*clmWorkflowQueueMembershipEntry `json:"membership"` - SucceededAtLeastOnce bool `json:"succeeded_at_least_once"` - ConsecutiveUnavailableFailures int `json:"consecutive_unavailable_failures"` - SkippedMembers int `json:"skipped_members"` - SkippedQueues int `json:"skipped_queues"` + Queues map[string]client.ClmWorkflowQueue `json:"queues"` + SucceededAtLeastOnce bool `json:"succeeded_at_least_once"` + ConsecutiveUnavailableFailures int `json:"consecutive_unavailable_failures"` + SkippedMembers int `json:"skipped_members"` + SkippedQueues int `json:"skipped_queues"` } var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmWorkflowQueueBuilder)(nil) @@ -70,13 +71,17 @@ func clmSessionKeyQueueMembers(queueID string) string { // - List() chunks the scan one ListMembers page per SDK-driven call — the usual // one-page-per-call shape every sibling builder in this connector uses, unlike an // earlier version of this file that ran the entire member scan (every ListMembers -// page, plus a GetMemberWorkflowQueues call per member) inside a single call. It -// persists its accumulating queueID -> []memberID index and escalation-threshold -// counters (clmWorkflowQueueDiscoveryState) to the SDK's session cache between -// chunks, since a plain Go value doesn't survive across separate List() invocations. -// Only on the LAST member page — the queue set can't be confirmed complete before -// then (there's no list-all endpoint to check it against) — does it write the final -// per-queue membership caches Grants() reads and emit the discovered queues as +// page, plus a GetMemberWorkflowQueues call per member) inside a single call. Each +// chunk merges its own contribution into every touched queue's own session key +// (clmSessionKeyQueueMembers) rather than accumulating one all-queues blob in +// memory: a single growing value would both rewrite on every chunk (O(members^2) +// session-store traffic over a full scan) and risk crossing the session store's +// per-value size ceiling on a large account. A separate, small +// clmWorkflowQueueDiscoveryState blob — just the distinct queues seen so far plus +// the escalation-threshold counters — persists across chunks instead, since a plain +// Go value doesn't survive across separate List() invocations. Only on the LAST +// member page — the queue set can't be confirmed complete before then (there's no +// list-all endpoint to check it against) — does it emit the discovered queues as // resources; every earlier chunk returns zero resources plus a NextPageToken. // - Grants(ctx, queueResource, attr) reads that queue's member list straight back out // of the session cache instead of re-scanning every member per queue, which would @@ -119,10 +124,18 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at state, _, err := session.GetJSON[clmWorkflowQueueDiscoveryState](ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState) if err != nil { - return nil, nil, fmt.Errorf("baton-docusign: failed to read CLM workflow queue discovery state: %w", err) + // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) only + // tells the SDK to accept a store connection — whether one actually exists still + // depends on the parent process wiring a listen port, and it falls back to + // NoOpSessionStore (every Get call fails too) whenever it doesn't. This resource + // type's Grants() cannot function without the cache, but that's not true of the + // rest of the sync — a hard error here would fail every other resource type too. + // Skip gracefully instead, same as an unavailable CLM subscription. + ctxzap.Extract(ctx).Debug("baton-docusign: failed to read CLM workflow queue discovery state, skipping clm_workflow_queue sync", zap.Error(err)) + return nil, &rs.SyncOpResults{}, nil } - if state.Membership == nil { - state.Membership = make(map[string]*clmWorkflowQueueMembershipEntry) + if state.Queues == nil { + state.Queues = make(map[string]client.ClmWorkflowQueue) } members, nextMemberPageToken, allAnnos, err := b.client.ListMembers(ctx, client.PageOptions{ @@ -137,6 +150,11 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at return nil, nil, err } + // chunkMembersByQueue accumulates only THIS chunk's contribution to each queue's + // membership — merged into that queue's own session key below instead of growing + // state.Queues without bound (see this builder's doc for why). + chunkMembersByQueue := make(map[string][]string) + for _, member := range members { memberID := clmIDFromHref(member.Href) queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) @@ -215,12 +233,34 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at } continue } - entry, ok := state.Membership[queueID] - if !ok { - entry = &clmWorkflowQueueMembershipEntry{Queue: q} - state.Membership[queueID] = entry + if _, ok := state.Queues[queueID]; !ok { + state.Queues[queueID] = q } - entry.Members = append(entry.Members, memberID) + chunkMembersByQueue[queueID] = append(chunkMembersByQueue[queueID], memberID) + } + } + + // Merge this chunk's contribution into each touched queue's own session key rather + // than growing a single all-queues blob every chunk — see this builder's doc for why. + if len(chunkMembersByQueue) > 0 { + keys := make([]string, 0, len(chunkMembersByQueue)) + for queueID := range chunkMembersByQueue { + keys = append(keys, clmSessionKeyQueueMembers(queueID)) + } + existing, err := session.GetManyJSON[[]string](ctx, attr.Session, keys) + if err != nil { + // Same opt-in-session-store reasoning as the discovery-state read above. + ctxzap.Extract(ctx).Debug("baton-docusign: failed to read cached CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) + return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil + } + membersByKey := make(map[string][]string, len(chunkMembersByQueue)) + for queueID, newMembers := range chunkMembersByQueue { + key := clmSessionKeyQueueMembers(queueID) + membersByKey[key] = append(existing[key], newMembers...) + } + if err := session.SetManyJSON(ctx, attr.Session, membersByKey); err != nil { + ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) + return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } } @@ -246,21 +286,11 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at } // Last member page: the queue set is only guaranteed complete now (see this - // builder's doc) — finalize the per-queue membership caches Grants() reads (one - // SetManyJSON call instead of one SetJSON call per queue) and emit every discovered - // queue as a resource. - membersByKey := make(map[string][]string, len(state.Membership)) - for queueID, entry := range state.Membership { - membersByKey[clmSessionKeyQueueMembers(queueID)] = entry.Members - } - if err := session.SetManyJSON(ctx, attr.Session, membersByKey); err != nil { - ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) - return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil - } - - resources := make([]*v2.Resource, 0, len(state.Membership)) - for _, entry := range state.Membership { - queueResource, err := parseIntoClmWorkflowQueueResource(&entry.Queue) + // builder's doc) — every queue's membership was already persisted incrementally + // above, so just emit every discovered queue as a resource. + resources := make([]*v2.Resource, 0, len(state.Queues)) + for _, q := range state.Queues { + queueResource, err := parseIntoClmWorkflowQueueResource(&q) if err != nil { return nil, nil, err } @@ -293,17 +323,6 @@ func dedupeRateLimitAnnotations(annos annotations.Annotations) annotations.Annot return out } -// clmWorkflowQueueMembershipEntry pairs a discovered queue with the member IDs found -// to belong to it — the two pieces of data the member scan produces for every queue, -// kept together under one key instead of two parallel maps that would otherwise always -// share the same key set. Exported fields: this struct round-trips through -// session.SetJSON/GetJSON as part of clmWorkflowQueueDiscoveryState (encoding/json -// can't see unexported fields). -type clmWorkflowQueueMembershipEntry struct { - Queue client.ClmWorkflowQueue `json:"queue"` - Members []string `json:"members"` -} - // Entitlements returns nil — the SDK does not call this when StaticEntitlementSyncerV2 // is implemented (see StaticEntitlements below). func (b *clmWorkflowQueueBuilder) Entitlements(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index e5bc24ca..8bff2d0e 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -35,19 +35,22 @@ func (f *fakeSessionStore) Get(_ context.Context, key string, _ ...sessions.Sess return v, ok, nil } +// GetMany's second return is for keys this call couldn't get to and wants retried — +// session.UnrollGetMany loops passing it straight back in as the next call's key list, +// erroring if it ever stops shrinking. It is NOT "missing/never-written keys": those +// simply aren't present in the returned map, matching every real SessionStore +// implementation (e.g. dotc1z's SQL "WHERE key IN (...)" naturally omits absent rows). +// This fake never has a reason to ask for a retry, so it always returns nil here. func (f *fakeSessionStore) GetMany(_ context.Context, keys []string, _ ...sessions.SessionStoreOption) (map[string][]byte, []string, error) { f.mu.Lock() defer f.mu.Unlock() out := make(map[string][]byte) - var missing []string for _, k := range keys { if v, ok := f.data[k]; ok { out[k] = v - } else { - missing = append(missing, k) } } - return out, missing, nil + return out, nil, nil } func (f *fakeSessionStore) Set(_ context.Context, key string, value []byte, _ ...sessions.SessionStoreOption) error { @@ -90,10 +93,9 @@ func (f *fakeSessionStore) GetAll(_ context.Context, _ string, _ ...sessions.Ses return out, "", nil } -// failingSessionStore wraps fakeSessionStore but every Set/SetMany call fails — stands -// in for the SDK's real NoOpSessionStore (returned whenever the parent process hasn't -// wired a session-store listen port; see session.NoOpSessionStore), which every write -// to it fails the exact same way. +// failingSessionStore wraps fakeSessionStore but every Set/SetMany call fails — isolates +// the write-failure path (e.g. a value that exceeds the store's size limit) from reads, +// which still succeed via the embedded fakeSessionStore. type failingSessionStore struct { *fakeSessionStore } @@ -106,6 +108,22 @@ func (f *failingSessionStore) SetMany(_ context.Context, _ map[string][]byte, _ return errClmSessionStoreDisabledForTest } +// readFailingSessionStore wraps fakeSessionStore but every Get/GetMany call fails — +// stands in for the SDK's real NoOpSessionStore (returned whenever the parent process +// hasn't wired a session-store listen port; see session.NoOpSessionStore), whose reads +// fail the exact same way as its writes. +type readFailingSessionStore struct { + *fakeSessionStore +} + +func (f *readFailingSessionStore) Get(_ context.Context, _ string, _ ...sessions.SessionStoreOption) ([]byte, bool, error) { + return nil, false, errClmSessionStoreDisabledForTest +} + +func (f *readFailingSessionStore) GetMany(_ context.Context, _ []string, _ ...sessions.SessionStoreOption) (map[string][]byte, []string, error) { + return nil, nil, errClmSessionStoreDisabledForTest +} + var errClmSessionStoreDisabledForTest = errors.New("session store disabled (test double)") // TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreWriteFailure confirms @@ -131,6 +149,27 @@ func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreWriteFailure( } } +// TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreReadFailure confirms +// List() degrades to a graceful skip (not a hard error) when it can't read its +// discovery state from the session cache — the same NoOpSessionStore fallback as the +// write-failure case above, just hit on the read that now happens first in every call. +func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreReadFailure(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: &readFailingSessionStore{fakeSessionStore: newFakeSessionStore()}}) + if err != nil { + t.Fatalf("expected a session-store read failure to be tolerated, not an error: %v", err) + } + if len(resources) != 0 { + t.Errorf("expected zero resources when the session store can't be read from, got %d", len(resources)) + } + if res == nil { + t.Errorf("expected a non-nil SyncOpResults, got %+v", res) + } +} + func TestClmWorkflowQueueBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") @@ -270,8 +309,8 @@ func TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures(t } // TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenLaterMemberDenied is a regression -// test: discoverClmWorkflowQueueMembership previously escalated ANY -// isOptInFeatureUnavailableError to "CLM unavailable, return zero resources", +// test: List() previously escalated ANY isOptInFeatureUnavailableError to "CLM +// unavailable, return zero resources", // regardless of scan position — so a PermissionDenied on member N (a token expiring or // a scope revoked mid-scan) after earlier members had already contributed real queues // would silently discard every already-discovered queue as if the whole feature were @@ -435,6 +474,47 @@ func TestClmWorkflowQueueBuilder_List_ChunksAcrossPages(t *testing.T) { } } +// TestClmWorkflowQueueBuilder_List_MergesMembershipAcrossChunks is the regression test +// for the incremental per-queue session write: each chunk merges its own contribution +// into a queue's session key via a Get-then-append-then-Set round trip, and a wrong +// merge (e.g. overwriting instead of appending) would silently drop every earlier +// chunk's members. member-alice and member-bob are both in the Onboarding queue (see +// clmtest/seed.go) but PageSize 1 puts them in separate chunks, so this only passes if +// chunk 2's write actually preserves chunk 1's contribution. +func TestClmWorkflowQueueBuilder_List_MergesMembershipAcrossChunks(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} + + var resources []*v2.Resource + pageToken := "" + for i := 0; i < 10; i++ { + attr.PageToken = pagination.Token{Size: 1, Token: pageToken} + res, syncRes, err := b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("List page %d: %v", i, err) + } + if syncRes.NextPageToken == "" { + resources = res + break + } + pageToken = syncRes.NextPageToken + } + + byName := make(map[string]*v2.Resource) + for _, r := range resources { + byName[r.DisplayName] = r + } + grants, _, err := b.Grants(ctx, byName["Onboarding"], attr) + if err != nil { + t.Fatalf("Grants(Onboarding): %v", err) + } + if len(grants) != 2 { + t.Fatalf("expected both alice (chunk 1) and bob (chunk 2) merged into Onboarding, got %d: %+v", len(grants), grants) + } +} + // TestClmWorkflowQueueBuilder_List_EscalationThresholdSpansChunks is a regression test // for the specific risk chunking introduces: the escalation-threshold counters // (clmWorkflowQueueDiscoveryState) must persist and keep accumulating ACROSS separate From 426f5f9a78844b25dfdde241114a4ed4e07ad7e1 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 20:57:17 -0300 Subject: [PATCH 16/50] fix: satisfy gocritic appendAssign on the membership merge append(existing[key], ...) assigned to a different map key tripped gocritic's aliasing-safety check. Same result, via an accumulator variable so the append target matches its assignment target. --- pkg/connector/clm_workflow_queues.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index c61dc20f..fbdcae77 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -256,7 +256,9 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at membersByKey := make(map[string][]string, len(chunkMembersByQueue)) for queueID, newMembers := range chunkMembersByQueue { key := clmSessionKeyQueueMembers(queueID) - membersByKey[key] = append(existing[key], newMembers...) + merged := existing[key] + merged = append(merged, newMembers...) + membersByKey[key] = merged } if err := session.SetManyJSON(ctx, attr.Session, membersByKey); err != nil { ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) From d1058d0412a10ba713ad5a3cd0e92a04073db1db Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 21:05:18 -0300 Subject: [PATCH 17/50] doc: drop internal ticket reference from README Same reasoning as the code-comment cleanup: an internal tracker ID in user-facing docs rots and isn't actionable for anyone without access to it. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 14147613..09d3e5bf 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,8 @@ fact, since no live CLM admin console was available to check it against. The CLM no list-all endpoint for workflow queues and no reverse lookup from a queue to its members, so this connector discovers them by scanning every `clm_member`'s own workflow queues and deduping — one API call per member, on top of the member sync itself. This -adds meaningful request volume on large accounts; see the open rate-limit issue tracked -as CXP-704 before enabling this on an account already seeing rate-limit errors. Workflow +adds meaningful request volume on large accounts; consider this before enabling it on an +account already seeing rate-limit errors. Workflow queue membership syncs for visibility only — the API supports work-item assign/unassign, not queue-membership grant/revoke, so it cannot be granted or revoked through this connector. From bf91ba66577e2be5a51023a41e02ff8e5c378dfd Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Tue, 11 Aug 2026 21:18:26 -0300 Subject: [PATCH 18/50] doc: fix stranded sentence in README after ticket-reference removal The previous wording pointed at a config flag that doesn't exist for this resource type (it has no opt-in switch) and left a stray one-word line. State the concrete cost instead. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 09d3e5bf..bd355849 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,9 @@ fact, since no live CLM admin console was available to check it against. The CLM no list-all endpoint for workflow queues and no reverse lookup from a queue to its members, so this connector discovers them by scanning every `clm_member`'s own workflow queues and deduping — one API call per member, on top of the member sync itself. This -adds meaningful request volume on large accounts; consider this before enabling it on an -account already seeing rate-limit errors. Workflow -queue membership syncs for visibility only — the API supports work-item assign/unassign, +adds one `GET .../members/{id}/workflowqueues` call per CLM member on every sync, on top +of the member sync itself — meaningful request volume on large accounts. Workflow queue +membership syncs for visibility only — the API supports work-item assign/unassign, not queue-membership grant/revoke, so it cannot be granted or revoked through this connector. From cc23ac134810ce9c1c7812ccb9dade7752897856 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 13:02:33 -0300 Subject: [PATCH 19/50] fix: dedup member merge against a resumed sync's replayed chunk Confirmed via investigation: the SDK throttles durable checkpoints to once every 10s, so a crash/resume within that window can re-issue a List() call whose chunk was already merged into a queue's cached membership. The merge was a blind append with no dedup. Impact was mild either way (grant IDs are deterministic, so duplicates already collapsed downstream), but the fix is cheap. Added a regression test, verified via mutation testing. Also: soften an overclaim in the builder doc (per-queue keys bound the O(members^2) cost per queue, not eliminate it for a queue most members belong to) and fix a duplicated sentence in the README. --- README.md | 6 ++--- pkg/connector/clm_workflow_queues.go | 17 +++++++++++-- pkg/connector/clm_workflow_queues_test.go | 30 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bd355849..f1a7ca91 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,9 @@ calls "Task Groups" — that equivalence is an unconfirmed assumption, not a doc fact, since no live CLM admin console was available to check it against. The CLM API has no list-all endpoint for workflow queues and no reverse lookup from a queue to its members, so this connector discovers them by scanning every `clm_member`'s own workflow -queues and deduping — one API call per member, on top of the member sync itself. This -adds one `GET .../members/{id}/workflowqueues` call per CLM member on every sync, on top -of the member sync itself — meaningful request volume on large accounts. Workflow queue +queues and deduping — one `GET .../members/{id}/workflowqueues` call per CLM member on +every sync, on top of the member sync itself, which is meaningful request volume on +large accounts. Workflow queue membership syncs for visibility only — the API supports work-item assign/unassign, not queue-membership grant/revoke, so it cannot be granted or revoked through this connector. diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index fbdcae77..201cc726 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -76,7 +76,9 @@ func clmSessionKeyQueueMembers(queueID string) string { // (clmSessionKeyQueueMembers) rather than accumulating one all-queues blob in // memory: a single growing value would both rewrite on every chunk (O(members^2) // session-store traffic over a full scan) and risk crossing the session store's -// per-value size ceiling on a large account. A separate, small +// per-value size ceiling on a large account. Bounds this per queue, not per member — +// a queue most members belong to still rewrites its own growing list each chunk. A +// separate, small // clmWorkflowQueueDiscoveryState blob — just the distinct queues seen so far plus // the escalation-threshold counters — persists across chunks instead, since a plain // Go value doesn't survive across separate List() invocations. Only on the LAST @@ -256,8 +258,19 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at membersByKey := make(map[string][]string, len(chunkMembersByQueue)) for queueID, newMembers := range chunkMembersByQueue { key := clmSessionKeyQueueMembers(queueID) + // Dedup: a resumed sync can re-issue an already-processed chunk. + seen := make(map[string]struct{}, len(existing[key])+len(newMembers)) merged := existing[key] - merged = append(merged, newMembers...) + for _, m := range merged { + seen[m] = struct{}{} + } + for _, m := range newMembers { + if _, ok := seen[m]; ok { + continue + } + seen[m] = struct{}{} + merged = append(merged, m) + } membersByKey[key] = merged } if err := session.SetManyJSON(ctx, attr.Session, membersByKey); err != nil { diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 8bff2d0e..88c9c211 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -9,6 +9,7 @@ import ( "github.com/conductorone/baton-docusign/pkg/client/clmtest" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/pagination" + "github.com/conductorone/baton-sdk/pkg/session" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/conductorone/baton-sdk/pkg/types/sessions" ) @@ -515,6 +516,35 @@ func TestClmWorkflowQueueBuilder_List_MergesMembershipAcrossChunks(t *testing.T) } } +// TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDuplicateMembership is a +// regression test: a resumed sync can re-issue a List() call with a PageToken whose +// chunk was already processed and merged. Re-running the first chunk (alice) must not +// double-count alice in Onboarding's cached membership. +func TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDuplicateMembership(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + attr := rs.SyncOpAttrs{Session: newFakeSessionStore(), PageToken: pagination.Token{Size: 1}} + + if _, _, err := b.List(ctx, nil, attr); err != nil { + t.Fatalf("List (first run of chunk 1): %v", err) + } + if _, _, err := b.List(ctx, nil, attr); err != nil { + t.Fatalf("List (replayed chunk 1): %v", err) + } + + memberIDs, found, err := session.GetJSON[[]string](ctx, attr.Session, clmSessionKeyQueueMembers("queue-onboarding")) + if err != nil { + t.Fatalf("GetJSON: %v", err) + } + if !found { + t.Fatal("expected cached membership for queue-onboarding") + } + if len(memberIDs) != 1 || memberIDs[0] != "member-alice" { + t.Errorf("expected exactly one alice entry after the replay, got %v", memberIDs) + } +} + // TestClmWorkflowQueueBuilder_List_EscalationThresholdSpansChunks is a regression test // for the specific risk chunking introduces: the escalation-threshold counters // (clmWorkflowQueueDiscoveryState) must persist and keep accumulating ACROSS separate From 96d31b6b5c64f3d3ad65a3dc18571a88a780958c Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 13:14:23 -0300 Subject: [PATCH 20/50] fix: don't double-count escalation counters on a replayed chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing the membership-merge dedup exposed a more severe gap: the same resumed-sync replay can also double-increment ConsecutiveUnavailableFailures (and the other counters), which could falsely cross clmWorkflowQueueUnavailableThreshold on the replay itself and discard already-discovered queues — the exact false-emptying outcome the threshold exists to prevent. Persist the last-applied input page token in discovery state; on a detected replay, skip the per-member scan and counter updates entirely and just re-fetch the next-page token. Added a regression test, verified via mutation testing. --- pkg/connector/clm_workflow_queues.go | 43 ++++++++++++++++++++++- pkg/connector/clm_workflow_queues_test.go | 33 +++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 201cc726..0d57d784 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -8,6 +8,7 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/pagination" "github.com/conductorone/baton-sdk/pkg/session" "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" @@ -43,6 +44,10 @@ type clmWorkflowQueueDiscoveryState struct { ConsecutiveUnavailableFailures int `json:"consecutive_unavailable_failures"` SkippedMembers int `json:"skipped_members"` SkippedQueues int `json:"skipped_queues"` + // LastAppliedInputToken is the ListMembers page token this state last fully applied + // — lets a replayed chunk (a resumed sync re-issuing an already-applied call) short- + // circuit before double-counting the escalation counters above. See List()'s doc. + LastAppliedInputToken string `json:"last_applied_input_token"` } var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmWorkflowQueueBuilder)(nil) @@ -124,7 +129,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at return nil, nil, err } - state, _, err := session.GetJSON[clmWorkflowQueueDiscoveryState](ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState) + state, found, err := session.GetJSON[clmWorkflowQueueDiscoveryState](ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState) if err != nil { // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) only // tells the SDK to accept a store connection — whether one actually exists still @@ -136,6 +141,9 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at ctxzap.Extract(ctx).Debug("baton-docusign: failed to read CLM workflow queue discovery state, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } + if found && pageToken == state.LastAppliedInputToken { + return b.replayChunk(ctx, bag, pageToken, attr, state) + } if state.Queues == nil { state.Queues = make(map[string]client.ClmWorkflowQueue) } @@ -279,6 +287,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at } } + state.LastAppliedInputToken = pageToken if err := session.SetJSON(ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState, state); err != nil { // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) // only tells the SDK to accept a store connection — whether one actually @@ -315,6 +324,38 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at return resources, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } +// replayChunk handles a resumed sync re-issuing a page this state already applied (see +// LastAppliedInputToken's doc). The per-queue membership merge is dedup-safe against +// this, but the escalation counters aren't, so this re-fetches just the next-page token +// instead of re-running the per-member scan and its counter updates. +func (b *clmWorkflowQueueBuilder) replayChunk( + ctx context.Context, bag *pagination.Bag, pageToken string, attr rs.SyncOpAttrs, state clmWorkflowQueueDiscoveryState, +) ([]*v2.Resource, *rs.SyncOpResults, error) { + _, nextMemberPageToken, allAnnos, err := b.client.ListMembers(ctx, client.PageOptions{ + PageSize: attr.PageToken.Size, + PageToken: pageToken, + }) + if err != nil { + return nil, nil, err + } + if nextMemberPageToken != "" { + outToken, err := bag.NextToken(nextMemberPageToken) + if err != nil { + return nil, nil, err + } + return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos), NextPageToken: outToken}, nil + } + resources := make([]*v2.Resource, 0, len(state.Queues)) + for _, q := range state.Queues { + queueResource, err := parseIntoClmWorkflowQueueResource(&q) + if err != nil { + return nil, nil, err + } + resources = append(resources, queueResource) + } + return resources, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil +} + // dedupeRateLimitAnnotations keeps every non-rate-limit annotation as-is but collapses // all RateLimitDescription entries down to the last one — List() appends one // GetMemberWorkflowQueues annotation set per member processed in its current chunk, so a diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 88c9c211..21595b85 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -592,3 +592,36 @@ func TestClmWorkflowQueueBuilder_List_EscalationThresholdSpansChunks(t *testing. t.Error("chunk 2: expected an empty NextPageToken — escalation should end the sync for this resource type, not request another chunk") } } + +// TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDoubleCountFailures is a +// regression test: a resumed sync can re-issue a List() call with the same PageToken as +// a chunk already applied. Replaying a below-threshold chunk must not double-count its +// failures toward ConsecutiveUnavailableFailures and falsely escalate. +func TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDoubleCountFailures(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) + srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + attr := rs.SyncOpAttrs{Session: newFakeSessionStore(), PageToken: pagination.Token{Size: 2, Token: ""}} + + _, syncRes, err := b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("chunk 1: %v", err) + } + if syncRes.NextPageToken == "" { + t.Fatal("chunk 1: expected a non-empty NextPageToken — 2 failures is below the threshold") + } + + // Replay chunk 1 with the exact same input token. + resources, syncRes, err := b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("replayed chunk 1: %v", err) + } + if len(resources) != 0 { + t.Errorf("replayed chunk 1: expected zero resources, got %d: %+v", len(resources), resources) + } + if syncRes.NextPageToken == "" { + t.Fatal("replayed chunk 1: expected a non-empty NextPageToken — must not have escalated from double-counted failures") + } +} From fb44dbc88a4718157acd70448f5ef4568a18324d Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 13:50:16 -0300 Subject: [PATCH 21/50] fix: track the expected next input token instead of the last applied one Two gaps in the previous fix, both confirmed via review: - It only recognized a replay of the single most recently applied chunk. A resume rolling back more than one chunk (re-issuing an earlier token) went undetected and re-ran that chunk in full, double-counting its failures the same way the original bug did. - replayChunk spent a full ListMembers call just to recompute the next-page token it already knew from the original chunk. Store NextExpectedInputToken (the frontier) instead of the last applied input: any incoming token that doesn't match it is a replay, regardless of how many chunks it's behind, and resuming from the stored frontier needs zero API calls. Added a regression test for the multi-chunk-rollback case, verified via mutation testing. --- pkg/connector/clm_workflow_queues.go | 34 +++++++----------- pkg/connector/clm_workflow_queues_test.go | 42 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 0d57d784..2180e4f0 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -44,10 +44,11 @@ type clmWorkflowQueueDiscoveryState struct { ConsecutiveUnavailableFailures int `json:"consecutive_unavailable_failures"` SkippedMembers int `json:"skipped_members"` SkippedQueues int `json:"skipped_queues"` - // LastAppliedInputToken is the ListMembers page token this state last fully applied - // — lets a replayed chunk (a resumed sync re-issuing an already-applied call) short- - // circuit before double-counting the escalation counters above. See List()'s doc. - LastAppliedInputToken string `json:"last_applied_input_token"` + // NextExpectedInputToken is the ListMembers page token the next call should arrive + // with. Any other incoming token (a resumed sync rolling back one or more chunks) + // is a replay: List() resumes from this frontier instead of re-running an + // already-applied chunk and double-counting the escalation counters above. + NextExpectedInputToken string `json:"next_expected_input_token"` } var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmWorkflowQueueBuilder)(nil) @@ -141,8 +142,8 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at ctxzap.Extract(ctx).Debug("baton-docusign: failed to read CLM workflow queue discovery state, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } - if found && pageToken == state.LastAppliedInputToken { - return b.replayChunk(ctx, bag, pageToken, attr, state) + if found && pageToken != state.NextExpectedInputToken { + return b.replayChunk(bag, state) } if state.Queues == nil { state.Queues = make(map[string]client.ClmWorkflowQueue) @@ -287,7 +288,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at } } - state.LastAppliedInputToken = pageToken + state.NextExpectedInputToken = nextMemberPageToken if err := session.SetJSON(ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState, state); err != nil { // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) // only tells the SDK to accept a store connection — whether one actually @@ -328,22 +329,13 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // LastAppliedInputToken's doc). The per-queue membership merge is dedup-safe against // this, but the escalation counters aren't, so this re-fetches just the next-page token // instead of re-running the per-member scan and its counter updates. -func (b *clmWorkflowQueueBuilder) replayChunk( - ctx context.Context, bag *pagination.Bag, pageToken string, attr rs.SyncOpAttrs, state clmWorkflowQueueDiscoveryState, -) ([]*v2.Resource, *rs.SyncOpResults, error) { - _, nextMemberPageToken, allAnnos, err := b.client.ListMembers(ctx, client.PageOptions{ - PageSize: attr.PageToken.Size, - PageToken: pageToken, - }) - if err != nil { - return nil, nil, err - } - if nextMemberPageToken != "" { - outToken, err := bag.NextToken(nextMemberPageToken) +func (b *clmWorkflowQueueBuilder) replayChunk(bag *pagination.Bag, state clmWorkflowQueueDiscoveryState) ([]*v2.Resource, *rs.SyncOpResults, error) { + if state.NextExpectedInputToken != "" { + outToken, err := bag.NextToken(state.NextExpectedInputToken) if err != nil { return nil, nil, err } - return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos), NextPageToken: outToken}, nil + return nil, &rs.SyncOpResults{NextPageToken: outToken}, nil } resources := make([]*v2.Resource, 0, len(state.Queues)) for _, q := range state.Queues { @@ -353,7 +345,7 @@ func (b *clmWorkflowQueueBuilder) replayChunk( } resources = append(resources, queueResource) } - return resources, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil + return resources, &rs.SyncOpResults{}, nil } // dedupeRateLimitAnnotations keeps every non-rate-limit annotation as-is but collapses diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 21595b85..29ce0f39 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -625,3 +625,45 @@ func TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDoubleCountFailures(t t.Fatal("replayed chunk 1: expected a non-empty NextPageToken — must not have escalated from double-counted failures") } } + +// TestClmWorkflowQueueBuilder_List_ReplayedRollbackByMoreThanOneChunk is a regression +// test: a resume can roll back more than one chunk, replaying an input token that isn't +// the immediately preceding one. Replaying chunk 1's token after chunk 2 already applied +// must resume from the current frontier (chunk 2's own NextPageToken), not re-run either +// chunk and double-count their failures. +func TestClmWorkflowQueueBuilder_List_ReplayedRollbackByMoreThanOneChunk(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) + srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + attr := rs.SyncOpAttrs{Session: newFakeSessionStore(), PageToken: pagination.Token{Size: 1, Token: ""}} + + _, syncRes, err := b.List(ctx, nil, attr) // chunk 1: alice fails (count=1) + if err != nil { + t.Fatalf("chunk 1: %v", err) + } + attr.PageToken.Token = syncRes.NextPageToken + + _, syncRes, err = b.List(ctx, nil, attr) // chunk 2: bob fails (count=2) + if err != nil { + t.Fatalf("chunk 2: %v", err) + } + if syncRes.NextPageToken == "" { + t.Fatal("chunk 2: expected a non-empty NextPageToken — 2 failures is below the threshold") + } + frontier := syncRes.NextPageToken + + // Replay chunk 1's original token — two chunks stale, not just one. + attr.PageToken.Token = "" + resources, syncRes, err := b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("rolled-back replay: %v", err) + } + if len(resources) != 0 { + t.Errorf("rolled-back replay: expected zero resources, got %d: %+v", len(resources), resources) + } + if syncRes.NextPageToken != frontier { + t.Errorf("rolled-back replay: expected to resume from the frontier %q, got %q", frontier, syncRes.NextPageToken) + } +} From 6afb2a1d909328d96c6ee340b4cbbdd68bdfded5 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 12 Aug 2026 14:12:59 -0300 Subject: [PATCH 22/50] fix: detect replay of a scan that completed in a single page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NextExpectedInputToken alone can't distinguish a first call from a replay when the whole scan fits in one page — both leave the token at "". Add ScanComplete to disambiguate, and route that case through replayChunk instead of re-running the per-member scan. --- pkg/connector/clm_workflow_queues.go | 16 ++++++++---- pkg/connector/clm_workflow_queues_test.go | 31 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 2180e4f0..9fe14f76 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -49,6 +49,10 @@ type clmWorkflowQueueDiscoveryState struct { // is a replay: List() resumes from this frontier instead of re-running an // already-applied chunk and double-counting the escalation counters above. NextExpectedInputToken string `json:"next_expected_input_token"` + // ScanComplete distinguishes "the scan finished" from "nothing processed yet" — both + // otherwise look identical (NextExpectedInputToken == ""). Without it, a replay of a + // scan that completed in a single page wouldn't be detected as a replay at all. + ScanComplete bool `json:"scan_complete"` } var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmWorkflowQueueBuilder)(nil) @@ -142,7 +146,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at ctxzap.Extract(ctx).Debug("baton-docusign: failed to read CLM workflow queue discovery state, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } - if found && pageToken != state.NextExpectedInputToken { + if found && (state.ScanComplete || pageToken != state.NextExpectedInputToken) { return b.replayChunk(bag, state) } if state.Queues == nil { @@ -289,6 +293,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at } state.NextExpectedInputToken = nextMemberPageToken + state.ScanComplete = nextMemberPageToken == "" if err := session.SetJSON(ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState, state); err != nil { // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) // only tells the SDK to accept a store connection — whether one actually @@ -325,10 +330,11 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at return resources, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } -// replayChunk handles a resumed sync re-issuing a page this state already applied (see -// LastAppliedInputToken's doc). The per-queue membership merge is dedup-safe against -// this, but the escalation counters aren't, so this re-fetches just the next-page token -// instead of re-running the per-member scan and its counter updates. +// replayChunk handles a resumed sync arriving with an input token that isn't the one +// this state expects next (see NextExpectedInputToken's doc). The per-queue membership +// merge is dedup-safe against a replay, but the escalation counters aren't, so this +// resumes from the persisted frontier instead of re-running the per-member scan and its +// counter updates. func (b *clmWorkflowQueueBuilder) replayChunk(bag *pagination.Bag, state clmWorkflowQueueDiscoveryState) ([]*v2.Resource, *rs.SyncOpResults, error) { if state.NextExpectedInputToken != "" { outToken, err := bag.NextToken(state.NextExpectedInputToken) diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 29ce0f39..e9613ba3 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -667,3 +667,34 @@ func TestClmWorkflowQueueBuilder_List_ReplayedRollbackByMoreThanOneChunk(t *test t.Errorf("rolled-back replay: expected to resume from the frontier %q, got %q", frontier, syncRes.NextPageToken) } } + +// TestClmWorkflowQueueBuilder_List_ReplayOfSinglePageScanIsDetected is a regression +// test: a scan that completes in a single page has NextExpectedInputToken == "" both +// before the first call and after the scan finishes, so token comparison alone can't +// tell a replay of that one chunk from a genuine first call. Without ScanComplete, a +// replay here would re-process member-alice's tolerated failure AFTER +// SucceededAtLeastOnce is already true, hitting the "fails loud" branch instead of the +// first-pass escalation path — turning a harmless replay into a hard sync failure. +func TestClmWorkflowQueueBuilder_List_ReplayOfSinglePageScanIsDetected(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} + + _, syncRes, err := b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("first call: %v", err) + } + if syncRes.NextPageToken != "" { + t.Fatal("first call: expected the scan to complete in a single page") + } + + resources, _, err := b.List(ctx, nil, attr) + if err != nil { + t.Fatalf("replay of the single-page scan: %v", err) + } + if len(resources) != 2 { + t.Errorf("replay: expected the same 2 queues as the first call, got %d: %+v", len(resources), resources) + } +} From d0b92ed75571a40d6a83745d0cdefcad41d9cd8a Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 14:02:13 -0300 Subject: [PATCH 23/50] fix: fail loudly instead of skipping gracefully when clm_workflow_queue is unavailable Per luisina-santos's review feedback: clm_workflow_queue is OptInRequired, and C1's opt-in toggle doesn't validate the account can actually use it before letting a customer enable it. Silently succeeding with zero resources when it can't reach the endpoint hides that misconfiguration instead of surfacing it - the same principle already applied to clm_role, clm_folder, clm_group, clm_member, clm_permission_set, and signing_group on a separate, not-yet-merged branch (clm-role-optin-error-observability-fixes). Changes both places List() concluded "CLM unavailable" and returned success with zero resources: the first-page ListMembers() error path, and the per-member scan's escalation-threshold-reached path. Left the threshold counting logic itself untouched (still requires 3 consecutive failures before concluding "systemic unavailable" rather than an isolated mid-scan member deletion) - only the terminal action changes, from returning nil to returning the error. Also left the isolated NotFound-after-success skip alone: that's an ordinary item-level race during an otherwise-working scan, not an "is CLM available" signal, and forcing it to fail the whole sync would make large/churning accounts fail non-deterministically based on scan-order timing. This CI account has no CLM subscription and these jobs run the connector directly with no resource-type filter, so clm_workflow_queue's new fail-loud behavior broke test-groups/test-signing-groups/test-permission-profiles the same way it did on the other branch - added the same BATON_SYNC_RESOURCE_TYPES fix, and a README note pointing self-hosted/CLI users at it. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yaml | 18 ++++++++ README.md | 10 ++++- pkg/connector/clm_workflow_queues.go | 19 +++++---- pkg/connector/clm_workflow_queues_test.go | 51 +++++++++++------------ 4 files changed, 61 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bc83b7d6..f006be86 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,6 +16,12 @@ jobs: BATON_REFRESH_TOKEN: ${{ secrets.REFRESHTOKEN }} BATON_DEMO: "true" BATON_INCLUDE_SIGNING_GROUPS: "true" + # This CI account has no CLM subscription, and this job runs the connector + # directly with no resource-type filter otherwise. clm_workflow_queue now fails + # the whole sync rather than skipping gracefully when CLM isn't available (see + # pkg/connector/clm_workflow_queues.go's List()) — exclude the CLM types 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 @@ -45,6 +51,12 @@ jobs: BATON_REFRESH_TOKEN: ${{ secrets.REFRESHTOKEN }} BATON_DEMO: "true" BATON_INCLUDE_SIGNING_GROUPS: "true" + # This CI account has no CLM subscription, and this job runs the connector + # directly with no resource-type filter otherwise. clm_workflow_queue now fails + # the whole sync rather than skipping gracefully when CLM isn't available (see + # pkg/connector/clm_workflow_queues.go's List()) — exclude the CLM types 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 @@ -74,6 +86,12 @@ jobs: BATON_REFRESH_TOKEN: ${{ secrets.REFRESHTOKEN }} BATON_DEMO: "true" BATON_INCLUDE_SIGNING_GROUPS: "true" + # This CI account has no CLM subscription, and this job runs the connector + # directly with no resource-type filter otherwise. clm_workflow_queue now fails + # the whole sync rather than skipping gracefully when CLM isn't available (see + # pkg/connector/clm_workflow_queues.go's List()) — exclude the CLM types 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 diff --git a/README.md b/README.md index f1a7ca91..89f0a890 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,15 @@ The 6 CLM resource types are always registered and visible to C1 — this avoids 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. +gracefully rather than erroring the whole sync — **except `clm_workflow_queue`**, which +fails the sync loudly instead (see below): it's `OptInRequired`, and C1's opt-in toggle +for it doesn't validate the underlying DocuSign account first, so an account that opted +in but can't reach CLM is a misconfiguration to surface, not a state to tolerate +silently. `OptInRequired` is a platform-side-only gate — running this connector +directly (self-hosted/CLI, not through C1) attempts `clm_workflow_queue` unconditionally, +so an eSignature-only account run this way needs +`--sync-resource-types`/`BATON_SYNC_RESOURCE_TYPES` to exclude it explicitly (see +`.github/workflows/ci.yaml` for a working example). 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/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 9fe14f76..73f73ab5 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -126,8 +126,9 @@ func (b *clmWorkflowQueueBuilder) ResourceType(_ context.Context) *v2.ResourceTy // GetMemberWorkflowQueues failures are unchanged from a single-call scan: a tolerated // code (PermissionDenied/Unauthenticated/NotFound/FailedPrecondition) before anything has // succeeded counts toward clmWorkflowQueueUnavailableThreshold regardless of which chunk -// it lands in; a NotFound after something has already succeeded is an isolated skip; any -// other tolerated code after success fails loud. +// it lands in, and fails the sync loudly once reached; a NotFound after something has +// already succeeded is an isolated skip (an ordinary mid-scan deletion race, unrelated to +// whether this account can use CLM); any other tolerated code after success fails loud. func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { bag, pageToken, err := parsePageToken(attr.PageToken.Token, &v2.ResourceId{ResourceType: clmWorkflowQueueResourceType.Id}) if err != nil { @@ -158,10 +159,6 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at PageToken: pageToken, }) if err != nil { - if pageToken == "" && isOptInFeatureUnavailableError(err) { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_workflow_queue sync", zap.Error(err)) - return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil - } return nil, nil, err } @@ -197,11 +194,15 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // few consecutive failures (spanning as many chunks as it takes) // before concluding "systemic", capping wasted requests against an // already rate-limited endpoint without letting one unlucky - // ordering empty the resource type. + // ordering fail the sync. Once concluded, fail loud rather than + // skip gracefully: clm_workflow_queue is OptInRequired, and C1's + // opt-in toggle doesn't check the account can actually use it first + // — an account that opted in but can't reach this endpoint is a + // misconfiguration to surface, not a state to tolerate silently. state.ConsecutiveUnavailableFailures++ if state.ConsecutiveUnavailableFailures >= clmWorkflowQueueUnavailableThreshold { - ctxzap.Extract(ctx).Info("baton-docusign: CLM is not available for this account or token, skipping clm_workflow_queue sync", zap.Error(err)) - return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil + return nil, nil, fmt.Errorf("baton-docusign: CLM workflow queues unavailable after %d consecutive member failures: %w", + state.ConsecutiveUnavailableFailures, err) } // Below the threshold: same visibility as the post-success // isolated-NotFound skip below — this member's queue membership diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index e9613ba3..e3b377ee 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -171,21 +171,21 @@ func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreReadFailure(t } } -func TestClmWorkflowQueueBuilder_List_SkipsGracefullyWhenClmUnavailable(t *testing.T) { +func TestClmWorkflowQueueBuilder_List_FailsWhenClmUnavailable(t *testing.T) { + // clm_workflow_queue is OptInRequired, and C1's opt-in toggle doesn't check the + // account can actually use it first — see clm_roles.go's identical rationale. List() + // must fail loudly here rather than silently succeed with zero resources. s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") b := newClmWorkflowQueueBuilder(badClient) ctx := context.Background() - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - 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{Session: newFakeSessionStore()}) + 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 { - t.Errorf("expected a non-nil SyncOpResults, got %+v", res) + t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) } } @@ -283,13 +283,15 @@ func TestClmWorkflowQueueBuilder_List_ToleratesBelowThresholdFailures(t *testing } } -// TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures confirms -// the account-wide-unavailability escalation still fires once +// TestClmWorkflowQueueBuilder_List_FailsAfterConsecutiveFailures confirms the +// account-wide-unavailability escalation still fires once // clmWorkflowQueueUnavailableThreshold consecutive members fail with nothing // discovered yet — member-alice, member-bob, and member-carol are first in scan order // (clmtest/seed.go's memberOrder), so forcing all three to fail reaches the threshold -// before any of them can succeed. -func TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures(t *testing.T) { +// before any of them can succeed. Once reached, List() fails loud (see +// TestClmWorkflowQueueBuilder_List_FailsWhenClmUnavailable's rationale) rather than +// tolerating it. +func TestClmWorkflowQueueBuilder_List_FailsAfterConsecutiveFailures(t *testing.T) { srv, c := clmtest.NewServer(t) srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) @@ -297,15 +299,12 @@ func TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures(t b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - if err != nil { - t.Fatalf("expected List to tolerate %d consecutive failures with nothing discovered yet, got error: %v", clmWorkflowQueueUnavailableThreshold, err) + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + if err == nil { + t.Fatalf("expected List to fail after %d consecutive failures with nothing discovered yet, got nil error", clmWorkflowQueueUnavailableThreshold) } if len(resources) != 0 { - t.Errorf("expected zero resources, got %d: %+v", len(resources), resources) - } - if res == nil { - t.Errorf("expected a non-nil SyncOpResults, got %+v", res) + t.Errorf("expected zero resources on a hard failure, got %d: %+v", len(resources), resources) } } @@ -579,17 +578,15 @@ func TestClmWorkflowQueueBuilder_List_EscalationThresholdSpansChunks(t *testing. } // Chunk 2: carol is first — this is the 3rd CONSECUTIVE failure counting the two - // from chunk 1, so it must escalate here, before ever reaching dave. + // from chunk 1, so it must escalate here, before ever reaching dave — and now fails + // loud rather than skipping gracefully. attr.PageToken = pagination.Token{Size: 2, Token: syncRes.NextPageToken} - resources, syncRes, err = b.List(ctx, nil, attr) - if err != nil { - t.Fatalf("chunk 2: expected the threshold-crossing failure to skip gracefully, got error: %v", err) + resources, _, err = b.List(ctx, nil, attr) + if err == nil { + t.Fatal("chunk 2: expected the threshold-crossing failure to fail loud, got nil error") } if len(resources) != 0 { - t.Errorf("chunk 2: expected zero resources after escalating, got %d: %+v", len(resources), resources) - } - if syncRes.NextPageToken != "" { - t.Error("chunk 2: expected an empty NextPageToken — escalation should end the sync for this resource type, not request another chunk") + t.Errorf("chunk 2: expected zero resources on a hard failure, got %d: %+v", len(resources), resources) } } From 6c147ce2588d8fa325b1e426cbea4377832f71ef Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 14:18:12 -0300 Subject: [PATCH 24/50] fix: restore CI coverage for the 5 still-tolerant CLM types, fix doc gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync-resource-types is an allowlist, not a denylist — the previous fix excluded all 6 CLM types from test-groups/test-signing-groups/ test-permission-profiles when only clm_workflow_queue needed excluding. That silently dropped end-to-end CI coverage of clm_member/clm_role/ clm_group/clm_permission_set/clm_folder's still-unchanged isOptInFeatureUnavailableError graceful-skip path against a real no-CLM DocuSign account. Listed the other 5 back in explicitly. Also: docs/connector.mdx and docs/doc-info.md both still claimed unqualified "no CLM subscription -> no CLM resources, nothing breaks" - no longer true for clm_workflow_queue after d0b92ed, where enabling it on a non-CLM account now fails the whole sync. And a test comment pointed at clm_roles.go as having "identical rationale" for this pattern, but that file has no API call or error-tolerance logic at all - repointed at the actual rationale in clm_workflow_queues.go's escalation branch. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yaml | 24 ++++++++++++++--------- docs/connector.mdx | 2 +- docs/doc-info.md | 2 +- pkg/connector/clm_workflow_queues_test.go | 5 +++-- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f006be86..ac0741df 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,9 +19,11 @@ jobs: # This CI account has no CLM subscription, and this job runs the connector # directly with no resource-type filter otherwise. clm_workflow_queue now fails # the whole sync rather than skipping gracefully when CLM isn't available (see - # pkg/connector/clm_workflow_queues.go's List()) — exclude the CLM types here - # explicitly to test the ones this job cares about. - BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group + # pkg/connector/clm_workflow_queues.go's List()), so it must be excluded here — + # but sync-resource-types is an allowlist, so the other 5 CLM types (which still + # gracefully skip, unchanged) are listed back in explicitly to keep exercising + # their isOptInFeatureUnavailableError path end-to-end against this no-CLM account. + BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group,clm_member,clm_role,clm_group,clm_permission_set,clm_folder steps: - name: Checkout code uses: actions/checkout@v4 @@ -54,9 +56,11 @@ jobs: # This CI account has no CLM subscription, and this job runs the connector # directly with no resource-type filter otherwise. clm_workflow_queue now fails # the whole sync rather than skipping gracefully when CLM isn't available (see - # pkg/connector/clm_workflow_queues.go's List()) — exclude the CLM types here - # explicitly to test the ones this job cares about. - BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group + # pkg/connector/clm_workflow_queues.go's List()), so it must be excluded here — + # but sync-resource-types is an allowlist, so the other 5 CLM types (which still + # gracefully skip, unchanged) are listed back in explicitly to keep exercising + # their isOptInFeatureUnavailableError path end-to-end against this no-CLM account. + BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group,clm_member,clm_role,clm_group,clm_permission_set,clm_folder steps: - name: Checkout code uses: actions/checkout@v4 @@ -89,9 +93,11 @@ jobs: # This CI account has no CLM subscription, and this job runs the connector # directly with no resource-type filter otherwise. clm_workflow_queue now fails # the whole sync rather than skipping gracefully when CLM isn't available (see - # pkg/connector/clm_workflow_queues.go's List()) — exclude the CLM types here - # explicitly to test the ones this job cares about. - BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group + # pkg/connector/clm_workflow_queues.go's List()), so it must be excluded here — + # but sync-resource-types is an allowlist, so the other 5 CLM types (which still + # gracefully skip, unchanged) are listed back in explicitly to keep exercising + # their isOptInFeatureUnavailableError path end-to-end against this no-CLM account. + BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group,clm_member,clm_role,clm_group,clm_permission_set,clm_folder steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/docs/connector.mdx b/docs/connector.mdx index edb88823..0b76ea45 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -29,7 +29,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 and workflow queues sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign a permission set, and no endpoint to grant or revoke workflow queue membership (only work-item assign/unassign, which isn't synced here). +**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 — except **CLM workflow queues**, which are opt-in and, once enabled, fail the entire sync (not just CLM) if the account can't reach CLM, since ConductorOne doesn't validate the subscription before letting you opt in. CLM permission sets and workflow queues sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign a permission set, and no endpoint to grant or revoke workflow queue membership (only work-item assign/unassign, which isn't synced here). 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/docs/doc-info.md b/docs/doc-info.md index 12893cd5..953a56cd 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. 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 — except CLM workflow queues, which are opt-in and fail the entire sync (all resource types, not just CLM) if enabled on an account that can't reach CLM, since ConductorOne doesn't validate the subscription before letting a customer opt in. - 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/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index e3b377ee..94953952 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -173,8 +173,9 @@ func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreReadFailure(t func TestClmWorkflowQueueBuilder_List_FailsWhenClmUnavailable(t *testing.T) { // clm_workflow_queue is OptInRequired, and C1's opt-in toggle doesn't check the - // account can actually use it first — see clm_roles.go's identical rationale. List() - // must fail loudly here rather than silently succeed with zero resources. + // account can actually use it first — see List()'s escalation branch in + // clm_workflow_queues.go for the full rationale. List() must fail loudly here + // rather than silently succeed with zero resources. s, _ := clmtest.NewServer(t) badClient := s.NewClientWithToken("wrong-token") b := newClmWorkflowQueueBuilder(badClient) From 95c05acf24d6124e8121f17dcf7911f92ae7a1d8 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 14:27:30 -0300 Subject: [PATCH 25/50] fix: guard against an empty member Href, fix stale test cross-references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memberID wasn't checked for empty the way queueID already is a few lines down. An empty ID would call GetMemberWorkflowQueues(ctx, "") -> GET .../members//workflowqueues, which 404s — and on the pre-success path, each such 404 now counts toward clmWorkflowQueueUnavailableThreshold since d0b92ed's fail-loud change, so a handful of malformed members early in scan order could hard-fail the entire sync and misreport it as "CLM workflow queues unavailable" rather than "found members with no usable ID." Added the same skip-and-continue guard the queue-ID case already has. Also fixed two comment cross-references left pointing at TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures (renamed to ...FailsAfterConsecutiveFailures in d0b92ed) with the old "skips gracefully" wording, now describing the opposite of the test's actual behavior. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues.go | 14 ++++++++++++++ pkg/connector/clm_workflow_queues_test.go | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 73f73ab5..95d33fa6 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -169,6 +169,20 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at for _, member := range members { memberID := clmIDFromHref(member.Href) + if memberID == "" { + // Symmetric with the empty-queueID guard below: an empty ID would call + // GetMemberWorkflowQueues(ctx, "") -> GET .../members//workflowqueues, + // which 404s and — on the pre-success path — counts toward + // clmWorkflowQueueUnavailableThreshold, so a handful of malformed members + // early in scan order could hard-fail the sync and misreport it as "CLM + // unavailable" instead of "found members with no usable ID." + state.SkippedMembers++ + if n := state.SkippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { + ctxzap.Extract(ctx).Warn("baton-docusign: CLM member has an empty Href, skipping", + zap.String("member_email", member.Email), zap.Int("total_occurrences", n)) + } + continue + } queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) if err != nil { if isOptInFeatureUnavailableError(err) { diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 94953952..d63c03b5 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -242,7 +242,7 @@ func TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan(t *testing.T) // member-carol is scanned after member-alice (who succeeds and contributes a queue), // so this exercises the "isolated NotFound" branch specifically, not the "nothing has // succeeded yet" escalation -// TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures covers. +// TestClmWorkflowQueueBuilder_List_FailsAfterConsecutiveFailures covers. func TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan(t *testing.T) { srv, c := clmtest.NewServer(t) // member-carol is a real seeded member (clmtest/seed.go) with zero queues of its @@ -551,7 +551,7 @@ func TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDuplicateMembership(t // List() calls, not reset per chunk — otherwise 2 consecutive failures in one chunk // followed by 1 more in the next chunk would never reach clmWorkflowQueueUnavailableThreshold // (3), even though the same 3 consecutive failures in a single unchunked call already do -// (per TestClmWorkflowQueueBuilder_List_SkipsGracefullyAfterConsecutiveFailures). Forces +// (per TestClmWorkflowQueueBuilder_List_FailsAfterConsecutiveFailures). Forces // alice and bob (chunk 1, PageSize 2) and carol (chunk 2) to all fail — the first chunk // alone only sees 2 failures (below threshold, must NOT escalate yet), and the second // chunk's first member pushes the running total to 3 and must escalate there. From 78b175b806086437e1ef1014b3cc021331906c87 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 13 Aug 2026 14:39:31 -0300 Subject: [PATCH 26/50] fix: give the empty-memberID skip its own counter, drop email from its log, add test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more findings on the guard added in 95c05ac: - It reused the shared SkippedMembers counter, whose 1/10/100/1000 log sampling keys off that shared value — so on an account where enough tolerated failures or isolated NotFounds happened first, the first-ever malformed-member skip could log nothing at all. Gave it its own SkippedMembersNoID counter so each failure class's sampling is independent. - The Warn log carried member.Email; switched to member.UserName, which identifies the malformed record just as well without logging PII. - Neither this guard nor its sibling empty-queueID guard had test coverage, since clmtest had no way to seed a member with an empty Href. Added Server.AddMemberWithoutHref and a regression test confirming the scan skips the malformed member, still discovers the other members' queues, and never calls GetMemberWorkflowQueues for it. Co-Authored-By: Claude Sonnet 5 --- pkg/client/clmtest/server.go | 12 ++++++++++++ pkg/connector/clm_workflow_queues.go | 12 ++++++++---- pkg/connector/clm_workflow_queues_test.go | 24 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index c28f49e3..36fac8dc 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -177,6 +177,18 @@ func (s *Server) AddBulkWorkflowQueueMember(memberID string, queueCount int) { s.memberWorkflowQueues[memberID] = queueIDs } +// AddMemberWithoutHref seeds a member with an empty Href — clmIDFromHref then reports an +// empty ID for it, exercising List()'s empty-memberID guard (a malformed record CLM's +// own API could plausibly return; this connector's endpoint shapes are +// documented-but-unexercised against a live tenant) without the scan ever reaching +// GetMemberWorkflowQueues for this member. Call after NewServer returns. +func (s *Server) AddMemberWithoutHref(memberID string) { + s.mu.Lock() + defer s.mu.Unlock() + s.members[memberID] = &client.ClmMember{Email: memberID + "@example.com", UserName: memberID} + s.memberOrder = append(s.memberOrder, memberID) +} + // URL returns the mock server's base URL — also what handleClmAccountDiscovery // returns as the CLM API base URL. func (s *Server) URL() string { return s.baseURL } diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 95d33fa6..626aa960 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -43,6 +43,7 @@ type clmWorkflowQueueDiscoveryState struct { SucceededAtLeastOnce bool `json:"succeeded_at_least_once"` ConsecutiveUnavailableFailures int `json:"consecutive_unavailable_failures"` SkippedMembers int `json:"skipped_members"` + SkippedMembersNoID int `json:"skipped_members_no_id"` SkippedQueues int `json:"skipped_queues"` // NextExpectedInputToken is the ListMembers page token the next call should arrive // with. Any other incoming token (a resumed sync rolling back one or more chunks) @@ -175,11 +176,14 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // which 404s and — on the pre-success path — counts toward // clmWorkflowQueueUnavailableThreshold, so a handful of malformed members // early in scan order could hard-fail the sync and misreport it as "CLM - // unavailable" instead of "found members with no usable ID." - state.SkippedMembers++ - if n := state.SkippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { + // unavailable" instead of "found members with no usable ID." Own counter, + // not the shared SkippedMembers below: sampling on a shared counter would + // let this failure class go unlogged entirely if enough of the other kind + // happened first. + state.SkippedMembersNoID++ + if n := state.SkippedMembersNoID; n == 1 || n == 10 || n == 100 || n%1000 == 0 { ctxzap.Extract(ctx).Warn("baton-docusign: CLM member has an empty Href, skipping", - zap.String("member_email", member.Email), zap.Int("total_occurrences", n)) + zap.String("member_username", member.UserName), zap.Int("total_occurrences", n)) } continue } diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index d63c03b5..4be7ae97 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -236,6 +236,30 @@ func TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan(t *testing.T) } } +// TestClmWorkflowQueueBuilder_List_SkipsMemberWithEmptyHref confirms the empty-memberID +// guard: a malformed member with no Href must not reach GetMemberWorkflowQueues at all +// (which would 404 and, on the pre-success path, count toward +// clmWorkflowQueueUnavailableThreshold), must not disturb discovery of the other +// members' real queues, and must not touch the escalation counter — it's a data-quality +// skip, not an unavailability signal. +func TestClmWorkflowQueueBuilder_List_SkipsMemberWithEmptyHref(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.AddMemberWithoutHref("member-no-href") + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + if err != nil { + t.Fatalf("expected a member with an empty Href to be skipped, got error: %v", err) + } + if len(resources) != 2 { + t.Fatalf("expected the other members' 2 queues to still be discovered, got %d: %+v", len(resources), resources) + } + if got := srv.MemberWorkflowQueuesRequestCount(); got != 6 { + t.Errorf("expected exactly 6 GetMemberWorkflowQueues calls (the 6 real seeded members, not the malformed one), got %d", got) + } +} + // TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan confirms a single member // 404ing (deleted between ListMembers and this call) is skipped, not a sync-wide // failure, once at least one other member has already proven the endpoint works — From 98aac01cd5323ba7fbdd61c5d3835472c361a1d0 Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:55:30 -0300 Subject: [PATCH 27/50] fix: Debug instead of Warn for skip-and-continue logs; propagate session-store write errors once queue data is discovered This repo no longer uses Warn-level logs anywhere (all skip-and-continue logging is Debug now) -- converts the 4 remaining Warn calls in this file. Also fixes a false-deletion risk flagged by mateoHernandez123: once state.SucceededAtLeastOnce is true, a session-store write failure (SetManyJSON/SetJSON) used to return success with zero resources, which the SDK reads as an authoritative empty result -- every previously synced clm_workflow_queue and its grants would look deleted. Now propagates the error instead once real queue data has been discovered, matching the same false-deletion-avoidance design already used throughout this file's escalation-threshold logic. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues.go | 32 ++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 626aa960..a9523143 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -182,7 +182,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // happened first. state.SkippedMembersNoID++ if n := state.SkippedMembersNoID; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Warn("baton-docusign: CLM member has an empty Href, skipping", + ctxzap.Extract(ctx).Debug("baton-docusign: CLM member has an empty Href, skipping", zap.String("member_username", member.UserName), zap.Int("total_occurrences", n)) } continue @@ -227,7 +227,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // (if any) is silently missing from this sync otherwise. state.SkippedMembers++ if n := state.SkippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Warn("baton-docusign: failed to get CLM workflow queues for member, skipping", + ctxzap.Extract(ctx).Debug("baton-docusign: failed to get CLM workflow queues for member, skipping", zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) } continue @@ -240,7 +240,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // this member and keep scanning. state.SkippedMembers++ if n := state.SkippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Warn("baton-docusign: CLM member not found while scanning workflow queues, skipping", + ctxzap.Extract(ctx).Debug("baton-docusign: CLM member not found while scanning workflow queues, skipping", zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) } continue @@ -262,7 +262,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at if queueID == "" { state.SkippedQueues++ if n := state.SkippedQueues; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Warn("baton-docusign: CLM workflow queue has an empty Href, skipping", + ctxzap.Extract(ctx).Debug("baton-docusign: CLM workflow queue has an empty Href, skipping", zap.String("member_id", memberID), zap.String("queue_name", q.Name), zap.Int("total_occurrences", n)) } continue @@ -283,6 +283,16 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at } existing, err := session.GetManyJSON[[]string](ctx, attr.Session, keys) if err != nil { + if state.SucceededAtLeastOnce { + // Real queue membership from this or an earlier chunk is already sitting + // in the session store. Returning success with zero resources here would + // make this the last (and only) response the SDK sees for this resource + // type — an authoritative empty result that reads as every previously + // synced clm_workflow_queue and its grants having been deleted. Propagate + // the error instead: the SDK preserves the last-known-good sync rather + // than accepting a lossy one. + return nil, nil, fmt.Errorf("baton-docusign: failed to read cached CLM workflow queue membership: %w", err) + } // Same opt-in-session-store reasoning as the discovery-state read above. ctxzap.Extract(ctx).Debug("baton-docusign: failed to read cached CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil @@ -306,6 +316,13 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at membersByKey[key] = merged } if err := session.SetManyJSON(ctx, attr.Session, membersByKey); err != nil { + if state.SucceededAtLeastOnce { + // Same false-deletion reasoning as the read above: this chunk's + // membership updates would be silently dropped, and a graceful + // zero-resource response here reads as everything already discovered + // having been deleted. Fail loud instead of accepting that outcome. + return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue membership: %w", err) + } ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } @@ -314,6 +331,13 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at state.NextExpectedInputToken = nextMemberPageToken state.ScanComplete = nextMemberPageToken == "" if err := session.SetJSON(ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState, state); err != nil { + if state.SucceededAtLeastOnce { + // Same false-deletion reasoning as the membership-cache writes above: once + // real queue data has been discovered, a graceful zero-resource response + // here would be the sync's final word on this resource type. Fail loud + // instead so the SDK preserves the last-known-good sync. + return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue discovery progress: %w", err) + } // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) // only tells the SDK to accept a store connection — whether one actually // exists still depends on the parent process wiring a listen port, and it From b61b45d2f2f6da97a91fc0ef003f86cb7f009cff Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:05:20 -0300 Subject: [PATCH 28/50] fix: include clm_workflow_queue in includeClm scope gating, describe it in Metadata() CI review caught this: includeClm (which decides whether buildScopes requests the CLM OAuth scopes) never checked clmWorkflowQueueResourceType. A sync scoped to only clm_workflow_queue would get a token with no CLM scopes, ensureClmReady/ListMembers would 401/403, and since this resource type's List() deliberately does not tolerate isOptInFeatureUnavailableError on that call, the error would propagate and fail the whole sync. Also added workflow queues to Metadata()'s description, matching README.md/docs/connector.mdx/docs/doc-info.md which this PR already updated. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/connector.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 993b2948..057917bc 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -95,7 +95,7 @@ func (d *Connector) Metadata(_ context.Context) (*v2.ConnectorMetadata, error) { // description, so the description no longer branches on includeSigningGroups/ // includeClm — it always lists everything the connector can sync. description := "Connector syncs data from Users, Permission Profiles, Groups, and Signing Groups (if enabled on your account). " + - "Also syncs DocuSign CLM members, roles, groups, folders, folder security, and permission sets (if your account has a CLM subscription). " + + "Also syncs DocuSign CLM members, roles, groups, folders, folder security, permission sets, and workflow queues (if your account has a CLM subscription). " + "It also allows the creation of users in DocuSign" return &v2.ConnectorMetadata{ @@ -181,7 +181,7 @@ func New(ctx context.Context, docusignCfg *cfg.Docusign, opts *cli.ConnectorOpts includeClm := opts.WillSyncResourceType(clmMemberResourceType.Id) || opts.WillSyncResourceType(clmRoleResourceType.Id) || opts.WillSyncResourceType(clmGroupResourceType.Id) || opts.WillSyncResourceType(clmPermissionSetResourceType.Id) || - opts.WillSyncResourceType(clmFolderResourceType.Id) + opts.WillSyncResourceType(clmFolderResourceType.Id) || opts.WillSyncResourceType(clmWorkflowQueueResourceType.Id) // Validate the configuration if err := field.Validate(cfg.ConfigurationSchema, docusignCfg); err != nil { From f490a9a9e1c10cf7b54a43c8bd0b123978568186 Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:05:51 -0300 Subject: [PATCH 29/50] fix: correct CLM resource-type count in isOptInFeatureUnavailableError doc Said "the 5 CLM resource types" unconditionally get the graceful-skip treatment, but there are 6 now and clm_workflow_queue is a deliberate exception (fails loud instead). Flagged by CI review. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/helper.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index e4532098..8e143105 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -40,12 +40,14 @@ func parsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, strin // 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. +// 5 of the 6 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. clm_workflow_queue is the deliberate exception — see +// clm_workflow_queues.go's List() doc for why it fails loud instead. // // 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 From 0776ee0a92306504b71d974b3ba68bfe266b100d Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:28:23 -0300 Subject: [PATCH 30/50] fix: use pageToken (not SucceededAtLeastOnce) to gate fail-loud on session-store writes CI review caught a real regression in the previous commit: inside the chunkMembersByQueue block, state.SucceededAtLeastOnce is ALWAYS true by construction (populating that map requires a prior successful GetMemberWorkflowQueues call), so the added graceful-skip else branch was unreachable dead code -- every GetManyJSON/SetManyJSON failure now hard-errored the whole sync, including the NoOpSessionStore case TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreWriteFailure exists to guard (that test does fail at HEAD, masked by the verify job's `go test ... | tee` swallowing the real exit code). Fixed by using the incoming page token instead: a non-empty pageToken can only exist because an earlier chunk's session-store writes already succeeded, which is the actual signal needed ("is real data already durably persisted", not "did a call succeed in this chunk"). Applied to both the GetManyJSON read and the SetManyJSON write, plus the top-of- function discovery-state read which had the same class of gap (state isn't loaded yet at that point, so SucceededAtLeastOnce isn't even available there). Left the outer SetJSON(discoveryState) write's SucceededAtLeastOnce gate as-is -- that one is NOT dead code, since a member can legitimately succeed with zero queues (or all-empty-Href queues) and set SucceededAtLeastOnce true without chunkMembersByQueue ever becoming non-empty in the same chunk. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues.go | 46 +++++++++++++++++++--------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index a9523143..eb313ab4 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -138,6 +138,15 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at state, found, err := session.GetJSON[clmWorkflowQueueDiscoveryState](ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState) if err != nil { + if pageToken != "" { + // A non-empty incoming page token can only exist because an earlier chunk's + // session-store writes already succeeded — state.SucceededAtLeastOnce isn't + // available here (that's exactly what this failed read was trying to fetch), + // but the token itself proves real queue data is already durably persisted. + // Losing it now would read as every previously discovered queue having been + // deleted, so fail loud instead of accepting a lossy empty result. + return nil, nil, fmt.Errorf("baton-docusign: failed to read CLM workflow queue discovery state: %w", err) + } // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) only // tells the SDK to accept a store connection — whether one actually exists still // depends on the parent process wiring a listen port, and it falls back to @@ -283,17 +292,23 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at } existing, err := session.GetManyJSON[[]string](ctx, attr.Session, keys) if err != nil { - if state.SucceededAtLeastOnce { - // Real queue membership from this or an earlier chunk is already sitting - // in the session store. Returning success with zero resources here would - // make this the last (and only) response the SDK sees for this resource - // type — an authoritative empty result that reads as every previously - // synced clm_workflow_queue and its grants having been deleted. Propagate - // the error instead: the SDK preserves the last-known-good sync rather - // than accepting a lossy one. + if pageToken != "" { + // state.SucceededAtLeastOnce is always true by the time we reach this + // block (populating chunkMembersByQueue requires a prior successful + // GetMemberWorkflowQueues call), so it can't distinguish "real data exists + // only in this in-memory chunk" from "an earlier chunk already durably + // persisted real data" — a non-empty incoming page token is what actually + // proves the latter. Losing an earlier chunk's persisted membership here + // would make this the last (and only) response the SDK sees for this + // resource type — an authoritative empty result that reads as every + // previously synced clm_workflow_queue and its grants having been deleted. + // Propagate the error instead: the SDK preserves the last-known-good sync + // rather than accepting a lossy one. return nil, nil, fmt.Errorf("baton-docusign: failed to read cached CLM workflow queue membership: %w", err) } - // Same opt-in-session-store reasoning as the discovery-state read above. + // First chunk: nothing has been durably persisted yet, so this in-memory + // chunk's data is all that's at risk — same opt-in-session-store reasoning as + // the discovery-state read above. ctxzap.Extract(ctx).Debug("baton-docusign: failed to read cached CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } @@ -316,13 +331,16 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at membersByKey[key] = merged } if err := session.SetManyJSON(ctx, attr.Session, membersByKey); err != nil { - if state.SucceededAtLeastOnce { - // Same false-deletion reasoning as the read above: this chunk's - // membership updates would be silently dropped, and a graceful - // zero-resource response here reads as everything already discovered - // having been deleted. Fail loud instead of accepting that outcome. + if pageToken != "" { + // Same reasoning as the read above: state.SucceededAtLeastOnce can't tell + // "this chunk" apart from "an earlier, already-persisted chunk" here, so + // use the incoming page token instead. This chunk's membership updates + // would be silently dropped, and a graceful zero-resource response here + // reads as everything already discovered having been deleted. Fail loud + // instead of accepting that outcome. return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue membership: %w", err) } + // First chunk: nothing has been durably persisted yet. ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil } From 6b361625367933b9aae684a18d21bab13090da5a Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:28:33 -0300 Subject: [PATCH 31/50] fix: correct 5-of-6 wording -- all 6 CLM types register unconditionally My previous fix said "5 of the 6 CLM resource types ... are registered unconditionally", but the exception is only about tolerance, not registration -- clm_workflow_queue registers the same unconditional way as the other 5. Flagged by CI review. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/helper.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 8e143105..8a3b1173 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -40,14 +40,16 @@ func parsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, strin // needs (CLM's spring_read/spring_write — see oauth.go) — rather than an unexpected // failure. // -// 5 of the 6 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. clm_workflow_queue is the deliberate exception — see -// clm_workflow_queues.go's List() doc for why it fails loud instead. +// All 6 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. Five of them +// tolerate this error on the first page of List() (see call sites), which is what +// makes unconditional registration safe for those five: the sync skips that one +// resource type gracefully instead of failing outright. clm_workflow_queue is the +// deliberate exception — it's registered unconditionally the same way, but does not +// tolerate this error; see clm_workflow_queues.go's List() doc for why it fails loud +// instead. // // 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 From 0a2afb655ea604c0300d2494967eded96074e6be Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:38:25 -0300 Subject: [PATCH 32/50] fix: correct helper.go doc -- only 4 CLM types tolerate this error, not 5 clm_role never makes an API call in List() (a hardcoded set), so it can't encounter isOptInFeatureUnavailableError at all -- it was wrongly counted among the 'tolerates this error' types in my previous fix. Flagged by CI review. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/helper.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 8a3b1173..52d628e9 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -43,13 +43,14 @@ func parsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, strin // All 6 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. Five of them -// tolerate this error on the first page of List() (see call sites), which is what -// makes unconditional registration safe for those five: the sync skips that one -// resource type gracefully instead of failing outright. clm_workflow_queue is the -// deliberate exception — it's registered unconditionally the same way, but does not -// tolerate this error; see clm_workflow_queues.go's List() doc for why it fails loud -// instead. +// disappears from a later sync and gets treated as fully deleted. Four of them +// (clm_member, clm_group, clm_permission_set, clm_folder) tolerate this error on the +// first page of List() (see call sites), which is what makes unconditional +// registration safe for those four: the sync skips that one resource type gracefully +// instead of failing outright. clm_role makes no API call at all in List() (a +// hardcoded set, see clm_roles.go), so it can't encounter this error either way. +// clm_workflow_queue is the deliberate exception that does encounter it but does not +// tolerate it — see clm_workflow_queues.go's List() doc for why it fails loud instead. // // 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 From 52ff742f00f38fe5e95661dea18bf5fe31b046b3 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 20 Aug 2026 12:41:57 -0300 Subject: [PATCH 33/50] fix: correct ci.yaml comment count, add regression test for the false-deletion fix ci.yaml comment fix: same "5 vs 4" correction as helper.go/52d628e -- clm_role has no API call in List(), so it doesn't go through the graceful-skip path the comment described. Adds TestClmWorkflowQueueBuilder_List_FailsLoudlyOnSessionStoreFailureAfterFirstChunk, covering the hard-fail half of the pageToken != "" gating fixed in 0776ee0 (the two existing session-store-failure tests only exercised the graceful-skip half, on the first chunk). Flagged by CI review; verified locally with go test -v since the CI test job's tee-based exit-code masking (see PR comment) means a real regression here wouldn't otherwise show red. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yaml | 27 +++++++++++++------- pkg/connector/clm_workflow_queues_test.go | 30 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ac0741df..e26eb6f7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,9 +20,12 @@ jobs: # directly with no resource-type filter otherwise. clm_workflow_queue now fails # the whole sync rather than skipping gracefully when CLM isn't available (see # pkg/connector/clm_workflow_queues.go's List()), so it must be excluded here — - # but sync-resource-types is an allowlist, so the other 5 CLM types (which still - # gracefully skip, unchanged) are listed back in explicitly to keep exercising - # their isOptInFeatureUnavailableError path end-to-end against this no-CLM account. + # but sync-resource-types is an allowlist, so the other 5 CLM types are listed back + # in explicitly to keep exercising them end-to-end against this no-CLM account. + # Only 4 of those 5 (clm_member, clm_group, clm_permission_set, clm_folder) + # actually go through isOptInFeatureUnavailableError's graceful skip; clm_role + # makes no API call at all in List() (a hardcoded set), so it's exercised here for + # coverage of its own List(), not of the graceful-skip path. BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group,clm_member,clm_role,clm_group,clm_permission_set,clm_folder steps: - name: Checkout code @@ -57,9 +60,12 @@ jobs: # directly with no resource-type filter otherwise. clm_workflow_queue now fails # the whole sync rather than skipping gracefully when CLM isn't available (see # pkg/connector/clm_workflow_queues.go's List()), so it must be excluded here — - # but sync-resource-types is an allowlist, so the other 5 CLM types (which still - # gracefully skip, unchanged) are listed back in explicitly to keep exercising - # their isOptInFeatureUnavailableError path end-to-end against this no-CLM account. + # but sync-resource-types is an allowlist, so the other 5 CLM types are listed back + # in explicitly to keep exercising them end-to-end against this no-CLM account. + # Only 4 of those 5 (clm_member, clm_group, clm_permission_set, clm_folder) + # actually go through isOptInFeatureUnavailableError's graceful skip; clm_role + # makes no API call at all in List() (a hardcoded set), so it's exercised here for + # coverage of its own List(), not of the graceful-skip path. BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group,clm_member,clm_role,clm_group,clm_permission_set,clm_folder steps: - name: Checkout code @@ -94,9 +100,12 @@ jobs: # directly with no resource-type filter otherwise. clm_workflow_queue now fails # the whole sync rather than skipping gracefully when CLM isn't available (see # pkg/connector/clm_workflow_queues.go's List()), so it must be excluded here — - # but sync-resource-types is an allowlist, so the other 5 CLM types (which still - # gracefully skip, unchanged) are listed back in explicitly to keep exercising - # their isOptInFeatureUnavailableError path end-to-end against this no-CLM account. + # but sync-resource-types is an allowlist, so the other 5 CLM types are listed back + # in explicitly to keep exercising them end-to-end against this no-CLM account. + # Only 4 of those 5 (clm_member, clm_group, clm_permission_set, clm_folder) + # actually go through isOptInFeatureUnavailableError's graceful skip; clm_role + # makes no API call at all in List() (a hardcoded set), so it's exercised here for + # coverage of its own List(), not of the graceful-skip path. BATON_SYNC_RESOURCE_TYPES: user,group,permission_profile,signing_group,clm_member,clm_role,clm_group,clm_permission_set,clm_folder steps: - name: Checkout code diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 4be7ae97..84da8d08 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -171,6 +171,36 @@ func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreReadFailure(t } } +// TestClmWorkflowQueueBuilder_List_FailsLoudlyOnSessionStoreFailureAfterFirstChunk is the +// regression test for the false-deletion bug the pageToken != "" gating (see List's +// discovery-state read) exists to prevent: unlike the two graceful-skip tests above +// (both hit on the very first chunk, before anything has been persisted), a +// session-store read failure on a LATER chunk — after an earlier chunk already durably +// wrote real queue data — must fail the sync instead of reporting zero resources, which +// would read as every already-discovered queue having been deleted. +func TestClmWorkflowQueueBuilder_List_FailsLoudlyOnSessionStoreFailureAfterFirstChunk(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + store := newFakeSessionStore() + + _, syncRes, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: store, PageToken: pagination.Token{Size: 1}}) + if err != nil { + t.Fatalf("chunk 1: %v", err) + } + if syncRes.NextPageToken == "" { + t.Fatalf("expected more than one seeded member so chunk 1 doesn't already finish the scan") + } + + _, _, err = b.List(ctx, nil, rs.SyncOpAttrs{ + Session: &readFailingSessionStore{fakeSessionStore: store}, + PageToken: pagination.Token{Size: 1, Token: syncRes.NextPageToken}, + }) + if err == nil { + t.Fatal("expected a session-store read failure on a later chunk to fail loudly, not skip gracefully — an earlier chunk already persisted real queue data that a graceful zero-resource response would read as deleted") + } +} + func TestClmWorkflowQueueBuilder_List_FailsWhenClmUnavailable(t *testing.T) { // clm_workflow_queue is OptInRequired, and C1's opt-in toggle doesn't check the // account can actually use it first — see List()'s escalation branch in From db761c0daf8bcf8564013180bc87ba5ba8dc78cf Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Thu, 20 Aug 2026 12:47:48 -0300 Subject: [PATCH 34/50] fix: wrap test failure message to satisfy revive line-length-limit Same 200-char limit hit earlier today on connector.go:159 -- split the t.Fatal message across concatenated string literals. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 84da8d08..b008eb91 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -197,7 +197,9 @@ func TestClmWorkflowQueueBuilder_List_FailsLoudlyOnSessionStoreFailureAfterFirst PageToken: pagination.Token{Size: 1, Token: syncRes.NextPageToken}, }) if err == nil { - t.Fatal("expected a session-store read failure on a later chunk to fail loudly, not skip gracefully — an earlier chunk already persisted real queue data that a graceful zero-resource response would read as deleted") + t.Fatal("expected a later-chunk session-store read failure to fail loudly, not skip " + + "gracefully — an earlier chunk already persisted real queue data that a graceful " + + "zero-resource response would read as deleted") } } From 3175d6d9bbd89daece466b8ce9d6e401cd25fe4f Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:03:31 -0300 Subject: [PATCH 35/50] fix: use pageToken (not SucceededAtLeastOnce) for the 4th session-store site too CI review caught a real inconsistency: the discovery-state SetJSON write was left on state.SucceededAtLeastOnce while the other 3 sites switched to pageToken != "". The two predicates disagree whenever every member scanned so far has failed below the escalation threshold, which is exactly the false-deletion outcome the other three sites were fixed to prevent. Now all four sites use the same, correct signal. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index eb313ab4..c422f7e5 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -349,11 +349,17 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at state.NextExpectedInputToken = nextMemberPageToken state.ScanComplete = nextMemberPageToken == "" if err := session.SetJSON(ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState, state); err != nil { - if state.SucceededAtLeastOnce { - // Same false-deletion reasoning as the membership-cache writes above: once - // real queue data has been discovered, a graceful zero-resource response - // here would be the sync's final word on this resource type. Fail loud - // instead so the SDK preserves the last-known-good sync. + if pageToken != "" { + // Same reasoning as the other three session-store failure sites above — and + // deliberately the SAME predicate, not state.SucceededAtLeastOnce: that field + // answers "did a member scan succeed", not "did an earlier chunk already + // prove this session store works", and the two diverge whenever every member + // scanned so far has failed below the escalation threshold. A non-empty + // incoming page token means chunk 1 already wrote successfully (that's the + // only way to get here with one), so a failure now is a genuine regression in + // an otherwise-working store, not the first-chunk "maybe never wired at all" + // case. Fail loud so the SDK preserves the last-known-good sync instead of + // accepting a lossy empty result. return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue discovery progress: %w", err) } // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) From 5df561e6e800cbb482ce641ec5731cf039006b54 Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:03:42 -0300 Subject: [PATCH 36/50] test: add membership-write hard-fail coverage after first chunk Mirrors the existing read-failure regression test but hits SetManyJSON instead of the top-of-function GetJSON -- flagged as still-missing by CI review (only the read path had coverage). Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues_test.go | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index b008eb91..f22cc57a 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -203,6 +203,34 @@ func TestClmWorkflowQueueBuilder_List_FailsLoudlyOnSessionStoreFailureAfterFirst } } +// TestClmWorkflowQueueBuilder_List_FailsLoudlyOnMembershipWriteFailureAfterFirstChunk is +// the write-side counterpart of the test above — same pageToken != "" gating, but hit on +// the SetManyJSON membership write instead of the top-of-function discovery-state read. +func TestClmWorkflowQueueBuilder_List_FailsLoudlyOnMembershipWriteFailureAfterFirstChunk(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + store := newFakeSessionStore() + + _, syncRes, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: store, PageToken: pagination.Token{Size: 1}}) + if err != nil { + t.Fatalf("chunk 1: %v", err) + } + if syncRes.NextPageToken == "" { + t.Fatalf("expected more than one seeded member so chunk 1 doesn't already finish the scan") + } + + _, _, err = b.List(ctx, nil, rs.SyncOpAttrs{ + Session: &failingSessionStore{fakeSessionStore: store}, + PageToken: pagination.Token{Size: 1, Token: syncRes.NextPageToken}, + }) + if err == nil { + t.Fatal("expected a later-chunk membership-write failure to fail loudly, not skip " + + "gracefully — an earlier chunk already persisted real queue data that a graceful " + + "zero-resource response would read as deleted") + } +} + func TestClmWorkflowQueueBuilder_List_FailsWhenClmUnavailable(t *testing.T) { // clm_workflow_queue is OptInRequired, and C1's opt-in toggle doesn't check the // account can actually use it first — see List()'s escalation branch in From 37811912d7919b03421f2da288b8dd3bf022a0b0 Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:09:41 -0300 Subject: [PATCH 37/50] fix: fail loud when discovery state is missing mid-scan (found == false) CI review caught a fifth variant of the same false-deletion bug: a later-chunk session-store read that succeeds but reports found == false (the entry is simply gone -- e.g. an in-memory store that restarted between chunks) silently restarted the scan from a zero-value state, discarding whatever an earlier chunk had already discovered. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index c422f7e5..7efe57a0 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -157,6 +157,17 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at ctxzap.Extract(ctx).Debug("baton-docusign: failed to read CLM workflow queue discovery state, skipping clm_workflow_queue sync", zap.Error(err)) return nil, &rs.SyncOpResults{}, nil } + if !found && pageToken != "" { + // A non-empty incoming page token means an earlier chunk already ran and wrote + // real progress, but the read above returned no error and no state — the entry + // itself is simply gone from the store (e.g. an in-memory store that restarted + // between chunks, or an expired entry). Silently restarting the scan from a + // zero-value state here would discover only the tail of the queue set from here + // on, and the final chunk would emit that partial set as the authoritative + // result — every queue an earlier chunk already found would read as deleted, the + // same false-deletion outcome the pageToken != "" checks above exist to prevent. + return nil, nil, fmt.Errorf("baton-docusign: CLM workflow queue discovery state missing mid-scan (page token %q)", pageToken) + } if found && (state.ScanComplete || pageToken != state.NextExpectedInputToken) { return b.replayChunk(bag, state) } From 0b435e0aacd59b3db8b5a9bf15cb7b5ebdfede26 Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:09:51 -0300 Subject: [PATCH 38/50] test: add coverage for discovery state missing mid-scan (found == false) Adds amnesiacSessionStore (Get always reports not-found with nil error) and a regression test using it -- distinct from readFailingSessionStore's hard error. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues_test.go | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index f22cc57a..81133e71 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -125,6 +125,18 @@ func (f *readFailingSessionStore) GetMany(_ context.Context, _ []string, _ ...se return nil, nil, errClmSessionStoreDisabledForTest } +// amnesiacSessionStore wraps fakeSessionStore but Get always reports "not found" with a +// nil error, regardless of what's actually stored — stands in for a session store that +// loses data between chunks without erroring (e.g. an in-memory store that restarted), +// as opposed to readFailingSessionStore's hard error on every read. +type amnesiacSessionStore struct { + *fakeSessionStore +} + +func (f *amnesiacSessionStore) Get(_ context.Context, _ string, _ ...sessions.SessionStoreOption) ([]byte, bool, error) { + return nil, false, nil +} + var errClmSessionStoreDisabledForTest = errors.New("session store disabled (test double)") // TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreWriteFailure confirms @@ -231,6 +243,37 @@ func TestClmWorkflowQueueBuilder_List_FailsLoudlyOnMembershipWriteFailureAfterFi } } +// TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenDiscoveryStateMissingMidScan covers a +// third failure shape distinct from the two above: the session-store read on a later +// chunk succeeds (no error) but the entry is simply gone (found == false) — e.g. an +// in-memory store that restarted between chunks. Silently restarting the scan from a +// zero-value state would discover only the tail of the queue set and emit that as the +// authoritative result, so this must fail loudly too, the same as an outright read error. +func TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenDiscoveryStateMissingMidScan(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmWorkflowQueueBuilder(c) + ctx := context.Background() + store := newFakeSessionStore() + + _, syncRes, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: store, PageToken: pagination.Token{Size: 1}}) + if err != nil { + t.Fatalf("chunk 1: %v", err) + } + if syncRes.NextPageToken == "" { + t.Fatalf("expected more than one seeded member so chunk 1 doesn't already finish the scan") + } + + _, _, err = b.List(ctx, nil, rs.SyncOpAttrs{ + Session: &amnesiacSessionStore{fakeSessionStore: store}, + PageToken: pagination.Token{Size: 1, Token: syncRes.NextPageToken}, + }) + if err == nil { + t.Fatal("expected discovery state missing mid-scan (found == false, no error) to fail " + + "loudly, not silently restart the scan — an earlier chunk already persisted real " + + "queue data that a fresh, partial scan would read as deleted") + } +} + func TestClmWorkflowQueueBuilder_List_FailsWhenClmUnavailable(t *testing.T) { // clm_workflow_queue is OptInRequired, and C1's opt-in toggle doesn't check the // account can actually use it first — see List()'s escalation branch in From 3a9cb161b0721a78041f78f15797a490e773c31d Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:26:27 -0300 Subject: [PATCH 39/50] fix: document operator recovery for the found==false guard CI review's last pass: the found==false guard has no self-healing path (unlike the graceful-skip branches around it) -- documented the actual recovery step (start a fresh full sync) and included it in the error message. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 7efe57a0..a9280fc5 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -166,7 +166,14 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at // on, and the final chunk would emit that partial set as the authoritative // result — every queue an earlier chunk already found would read as deleted, the // same false-deletion outcome the pageToken != "" checks above exist to prevent. - return nil, nil, fmt.Errorf("baton-docusign: CLM workflow queue discovery state missing mid-scan (page token %q)", pageToken) + // + // Unlike those checks, this has no self-healing path: the SDK's persisted page + // token means every retry of this same sync run arrives with the same stale + // token and re-hits this branch, since there's nothing here that can reconstruct + // the lost state. Recovery is operator-driven — start a fresh full sync (a new + // run gets pageToken == "" and rebuilds state.Queues from scratch) once the + // session store is confirmed to persist across whatever caused this loss. + return nil, nil, fmt.Errorf("baton-docusign: CLM workflow queue discovery state missing mid-scan (page token %q); start a fresh full sync once the session store is stable", pageToken) } if found && (state.ScanComplete || pageToken != state.NextExpectedInputToken) { return b.replayChunk(bag, state) From 0c001197423dbb8992bc50172f193be126c9003e Mon Sep 17 00:00:00 2001 From: Felipe <162376288+FeliLucero1@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:26:40 -0300 Subject: [PATCH 40/50] test: pin the found==false regression test to its specific error text CI review's last pass: the test only asserted err != nil, so a future unrelated error earlier in List() could keep it green for the wrong reason -- now asserts the specific error text too. Co-Authored-By: Claude Sonnet 5 --- pkg/connector/clm_workflow_queues_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 81133e71..af57a614 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -3,6 +3,7 @@ package connector import ( "context" "errors" + "strings" "sync" "testing" @@ -272,6 +273,9 @@ func TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenDiscoveryStateMissingMidSca "loudly, not silently restart the scan — an earlier chunk already persisted real " + "queue data that a fresh, partial scan would read as deleted") } + if !strings.Contains(err.Error(), "discovery state missing mid-scan") { + t.Errorf("expected the found==false mid-scan branch specifically, got: %v", err) + } } func TestClmWorkflowQueueBuilder_List_FailsWhenClmUnavailable(t *testing.T) { From a956a1cbb6a554cba0cd0d2f76952971be7e4de4 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 21 Aug 2026 15:08:03 -0300 Subject: [PATCH 41/50] docs: trim workflow-queue comments and ship capabilities entry Add clm_workflow_queue to baton_capabilities.json so platform caps match the new resource type without waiting on the post-merge bot. Co-authored-by: Cursor --- baton_capabilities.json | 22 +++++++ pkg/client/clm_models.go | 8 +-- pkg/connector/clm_workflow_queues.go | 99 ++++------------------------ 3 files changed, 36 insertions(+), 93 deletions(-) diff --git a/baton_capabilities.json b/baton_capabilities.json index 7efaedb7..d6b1fc91 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -101,6 +101,28 @@ "permissions": {}, "optInRequired": true }, + { + "resourceType": { + "id": "clm_workflow_queue", + "displayName": "CLM Workflow Queue", + "traits": [ + "TRAIT_GROUP" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements" + }, + { + "@type": "type.googleapis.com/c1.connector.v2.OptInRequired" + } + ] + }, + "capabilities": [ + "CAPABILITY_SYNC" + ], + "permissions": {}, + "optInRequired": true + }, { "resourceType": { "id": "group", diff --git a/pkg/client/clm_models.go b/pkg/client/clm_models.go index 7a66441a..afd37430 100644 --- a/pkg/client/clm_models.go +++ b/pkg/client/clm_models.go @@ -257,13 +257,7 @@ var ClmRoles = []ClmRole{ {Name: "SuperAdministrator"}, } -// ClmWorkflowQueue represents a CLM WorkflowQueue object (the API's own term for what -// the CLM admin console reportedly calls "Task Groups" — that equivalence is an -// unconfirmed assumption, not a documented fact; see -// pkg/connector/clm_workflow_queues.go's doc). Modeled on the Member's workflow queues -// endpoint's documented response shape; like every other CLM model in this package, the -// exact field set is documented-but-unexercised — no live CLM tenant was available to -// confirm it against a real response. +// ClmWorkflowQueue is a CLM WorkflowQueue (Member's workflow-queues response shape). type ClmWorkflowQueue struct { Href string `json:"Href"` Name string `json:"Name"` diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index a9280fc5..244ac73e 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -18,26 +18,15 @@ import ( "google.golang.org/grpc/status" ) -// clmWorkflowQueueUnavailableThreshold is how many CONSECUTIVE tolerated per-member -// failures (with nothing yet successfully scanned) List() requires, across however many -// chunked calls it takes to see them, before concluding the whole account can't use this -// endpoint — see the escalation branch's doc for why a single failure isn't enough. An -// arbitrary but deliberately small judgment call: no live CLM tenant to derive it from -// empirically. +// Consecutive opt-in failures (no success yet) before failing the sync. const clmWorkflowQueueUnavailableThreshold = 3 -// clmSessionKeyWorkflowQueueDiscoveryState is where List() persists its accumulating -// member-scan state between its own successive SDK-driven calls — see List()'s doc for -// why the scan is chunked this way instead of running to completion inside one call. +// clmSessionKeyWorkflowQueueDiscoveryState is where List() persists member-scan +// progress across successive SDK-driven calls. const clmSessionKeyWorkflowQueueDiscoveryState = "clm_workflow_queue_discovery_state" -// clmWorkflowQueueDiscoveryState is List()'s accumulator: the registry of distinct -// queues the scan has found so far (metadata only — member lists are persisted -// separately and incrementally, see List()'s doc), plus the escalation-threshold -// counters that must span every chunk of the scan, not reset per chunk (three tolerated -// failures on members split across two separate List() calls must still escalate, -// exactly as three in one call would). Exported fields: this round-trips through -// session.SetJSON/GetJSON (encoding/json can't see unexported fields). +// clmWorkflowQueueDiscoveryState is List()'s accumulator across chunked calls. +// Exported fields round-trip through session.SetJSON/GetJSON. type clmWorkflowQueueDiscoveryState struct { Queues map[string]client.ClmWorkflowQueue `json:"queues"` SucceededAtLeastOnce bool `json:"succeeded_at_least_once"` @@ -68,50 +57,10 @@ func clmSessionKeyQueueMembers(queueID string) string { return "clm_workflow_queue_members:" + queueID } -// clmWorkflowQueueBuilder syncs CLM WorkflowQueues — the API's own term for what the CLM -// admin console reportedly calls "Task Groups". That equivalence is an unconfirmed -// assumption: no DocuSign document states it, and confirming it needs eyes on a real CLM -// admin console — see resource_types.go. -// -// The CLM API has no list-all endpoint for workflow queues and no reverse lookup from a -// queue to its members — the only documented read path is per-member (GET -// .../members/{id}/workflowqueues). So both List() and Grants() are built around a -// member scan, not the direct list-all-then-page-per-resource shape every other builder -// in this connector uses: -// -// - List() chunks the scan one ListMembers page per SDK-driven call — the usual -// one-page-per-call shape every sibling builder in this connector uses, unlike an -// earlier version of this file that ran the entire member scan (every ListMembers -// page, plus a GetMemberWorkflowQueues call per member) inside a single call. Each -// chunk merges its own contribution into every touched queue's own session key -// (clmSessionKeyQueueMembers) rather than accumulating one all-queues blob in -// memory: a single growing value would both rewrite on every chunk (O(members^2) -// session-store traffic over a full scan) and risk crossing the session store's -// per-value size ceiling on a large account. Bounds this per queue, not per member — -// a queue most members belong to still rewrites its own growing list each chunk. A -// separate, small -// clmWorkflowQueueDiscoveryState blob — just the distinct queues seen so far plus -// the escalation-threshold counters — persists across chunks instead, since a plain -// Go value doesn't survive across separate List() invocations. Only on the LAST -// member page — the queue set can't be confirmed complete before then (there's no -// list-all endpoint to check it against) — does it emit the discovered queues as -// resources; every earlier chunk returns zero resources plus a NextPageToken. -// - Grants(ctx, queueResource, attr) reads that queue's member list straight back out -// of the session cache instead of re-scanning every member per queue, which would -// turn one expensive O(members) traversal into O(queues * members) API calls against -// an already rate-limited endpoint. -// -// This only works because the session cache persists for the whole sync (across all of -// List()'s own chunked calls, and through to when Grants() runs for every resource of -// this type afterward) and is shared across resource types within one sync — see -// cmd/baton-docusign/main.go's connectorrunner.WithSessionStoreEnabled(). -// -// Read-only: the API documents work-item assign/unassign, not queue-membership -// grant/revoke, so there's no Grant/Revoke on this builder — matching -// clm_permission_set's precedent for a CLM object with no write endpoint. -// -// Like every other CLM model in this connector, the endpoint shapes here are -// documented-but-unexercised — no live CLM tenant was available to confirm them. +// clmWorkflowQueueBuilder syncs CLM WorkflowQueues (UI "Task Groups" — unconfirmed). +// No list-all / no queue→members: List() scans members one page per call, caches +// membership per queue in the session store; Grants() reads that cache (not O(N×M)). +// Read-only — no queue-membership write API (same as clm_permission_set). type clmWorkflowQueueBuilder struct { resourceType *v2.ResourceType client *client.Client @@ -218,32 +167,10 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, at if err != nil { if isOptInFeatureUnavailableError(err) { if !state.SucceededAtLeastOnce { - // Nothing has proven this endpoint works for this account yet. - // DocuSign's own CLM API docs (Response and Error Codes) confirm - // NotFound isn't unique to "this object doesn't exist" — it's the - // SAME response CLM returns for "this exists but you don't have - // access rights", specifically so a 403 never leaks whether the - // object exists ("If the user does not have permissions to see - // the object or the object does not exist, a 404 response code is - // returned"). So a NotFound here is just as plausible a signal - // that workflow queues aren't available for this whole account as - // PermissionDenied/Unauthenticated are — treat it the same way. - // - // But escalating on a SINGLE failure reintroduces a different - // false-deletion race: a member that was genuinely deleted between - // ListMembers and this call (the case the isolated-NotFound skip - // below exists for) would wipe the whole resource type if it just - // happens to be first in scan order. A systemic failure (the - // endpoint disabled for this account) 404s/403s on EVERY member, - // while an isolated deletion race hits exactly one — so require a - // few consecutive failures (spanning as many chunks as it takes) - // before concluding "systemic", capping wasted requests against an - // already rate-limited endpoint without letting one unlucky - // ordering fail the sync. Once concluded, fail loud rather than - // skip gracefully: clm_workflow_queue is OptInRequired, and C1's - // opt-in toggle doesn't check the account can actually use it first - // — an account that opted in but can't reach this endpoint is a - // misconfiguration to surface, not a state to tolerate silently. + // Before any success: tolerate opt-in codes, but require + // clmWorkflowQueueUnavailableThreshold consecutive failures before + // failing the sync (one isolated NotFound must not wipe queues). + // CLM 404 means missing OR no access — same as other opt-in signals. state.ConsecutiveUnavailableFailures++ if state.ConsecutiveUnavailableFailures >= clmWorkflowQueueUnavailableThreshold { return nil, nil, fmt.Errorf("baton-docusign: CLM workflow queues unavailable after %d consecutive member failures: %w", From 2799c6de2472121516ca9a8a632a97ba14f8d51e Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Fri, 21 Aug 2026 15:38:09 -0300 Subject: [PATCH 42/50] docs: restore workflow-queue "why" after builder-doc trim Keep Mateo's short contract, but put back the rationale that other comments still point at (per-queue session keys, last-page emit, chunked List, sync-lifetime session store). Co-authored-by: Cursor --- cmd/baton-docusign/main.go | 5 ++--- pkg/connector/clm_workflow_queues.go | 9 ++++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/baton-docusign/main.go b/cmd/baton-docusign/main.go index e77cb5f8..35ef63b3 100644 --- a/cmd/baton-docusign/main.go +++ b/cmd/baton-docusign/main.go @@ -33,9 +33,8 @@ func main() { version, cfg.ConfigurationSchema, connectorFn, - // clm_workflow_queue's List() needs to cache a member->queue-membership index - // across the whole sync (built once during the member scan, read once per - // queue in Grants()) — see pkg/connector/clm_workflow_queues.go's doc. + // clm_workflow_queue's List() caches a member→queue-membership index across the + // whole sync (built once during the member scan, read once per queue in Grants()). connectorrunner.WithSessionStoreEnabled(), ) } diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 244ac73e..7f719402 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -58,9 +58,12 @@ func clmSessionKeyQueueMembers(queueID string) string { } // clmWorkflowQueueBuilder syncs CLM WorkflowQueues (UI "Task Groups" — unconfirmed). -// No list-all / no queue→members: List() scans members one page per call, caches -// membership per queue in the session store; Grants() reads that cache (not O(N×M)). -// Read-only — no queue-membership write API (same as clm_permission_set). +// No list-all / no queue→members: List() scans members one page per SDK call (same +// shape as sibling builders), writes membership into a per-queue session key (a single +// all-queues blob would rewrite O(members²) and risk the store's size ceiling), and +// only emits resources on the last member page — the queue set isn't complete before +// then. Grants() reads that cache (not O(N×M)). Needs a sync-lifetime session store +// (main.go's WithSessionStoreEnabled). Read-only — no queue-membership write API. type clmWorkflowQueueBuilder struct { resourceType *v2.ResourceType client *client.Client From cf14846182a143b6a62cf283ec7ea39476300ff9 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 26 Aug 2026 13:20:08 -0300 Subject: [PATCH 43/50] refactor: model clm_workflow_queue as ChildResourceType of clm_member Replace the session-store accumulator with per-member child-resource discovery and member-side grant emission, drop WithSessionStoreEnabled, and update tests/docs for the simpler design. Co-authored-by: Cursor --- .github/workflows/ci.yaml | 8 +- README.md | 35 +- cmd/baton-docusign/main.go | 4 - docs/doc-info.md | 2 +- pkg/client/clmtest/server.go | 20 + pkg/connector/clm_members.go | 45 +- pkg/connector/clm_members_test.go | 169 ++++- pkg/connector/clm_workflow_queues.go | 426 ++--------- pkg/connector/clm_workflow_queues_test.go | 842 +++------------------- pkg/connector/connector_test.go | 24 + pkg/connector/helper.go | 54 +- pkg/connector/resource_types.go | 12 +- 12 files changed, 454 insertions(+), 1187 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dd7506b7..b18efabd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,11 +19,9 @@ env: # Connector.Validate() now runs an upfront CLM-readiness check (EnsureClmReady) whenever # ANY clm_* type is in this allowlist, and it fails the whole sync loudly — before any # resource type's List() runs — rather than tolerating a no-subscription account (see - # connector.go's Validate doc comment). That upfront gate applies uniformly regardless - # of which individual CLM types tolerate an unavailable-CLM error inside their own - # List() (isOptInFeatureUnavailableError, pkg/connector/helper.go) — so including even - # one clm_* type here would fail these jobs' group/signing-group/permission-profile - # coverage too, not just skip CLM. TestNonClmAllowlistMatchesCI + # connector.go's Validate doc comment). Including even one clm_* type here would fail + # these jobs' group/signing-group/permission-profile coverage too, not just skip CLM. + # TestNonClmAllowlistMatchesCI # (pkg/connector/connector_test.go) pins this list against nonClmAllowlist() as a drift # guard. # This is an allowlist, not a CLM-only exclusion: if you register a new non-CLM diff --git a/README.md b/README.md index 9488bf9b..7c536e3e 100644 --- a/README.md +++ b/README.md @@ -144,16 +144,18 @@ upfront CLM-readiness check runs once, before any resource type's `List()` and b platform filter is applied to anything — a known, reviewed, and deliberately accepted gap, not an oversight. -Within `List()` itself (once `Validate()` has passed and a sync is actually running), CLM -unavailability is tolerated differently across the 6 types: `clm_member`, `clm_group`, -`clm_permission_set`, and `clm_folder` skip gracefully on the first page if the account -can't reach CLM; `clm_role` makes no API call at all (a hardcoded set), so the question -doesn't arise; and **`clm_workflow_queue` is the deliberate exception that encounters the -same error but does not tolerate it** — it fails the sync loudly instead, since C1's -opt-in toggle for it doesn't validate the underlying DocuSign account first, so an -account that opted in but can't reach CLM is a misconfiguration to surface, not a state -to tolerate silently. See `pkg/connector/helper.go`'s `isOptInFeatureUnavailableError` -doc for the full reasoning. +Within `List()` itself (once `Validate()` has passed and a sync is actually running), none +of the 6 CLM resource types carry their own CLM-availability tolerance logic anymore — +that responsibility now lives entirely in `Connector.Validate()`'s upfront +`EnsureClmReady()` gate (see above), which runs once, before any CLM builder's `List()` +executes. `clm_member`, `clm_group`, `clm_permission_set`, `clm_folder`, and +`clm_workflow_queue` all behave identically here: if `Validate()` passed, their `List()` +bodies just call the API and propagate whatever error comes back, same as any other +resource type; `clm_role` still makes no API call at all (a hardcoded set). An earlier +version of this connector had each CLM builder run its own per-type tolerance check +instead (with `clm_workflow_queue` as a deliberate exception that failed loud where the +others didn't) — that logic has been removed now that `Validate()` covers it once, upfront, +for all 6 types uniformly. 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 @@ -163,10 +165,15 @@ CLM workflow queues (`clm_workflow_queue`) map to what the CLM admin console rep calls "Task Groups" — that equivalence is an unconfirmed assumption, not a documented fact, since no live CLM admin console was available to check it against. The CLM API has no list-all endpoint for workflow queues and no reverse lookup from a queue to its -members, so this connector discovers them by scanning every `clm_member`'s own workflow -queues and deduping — one `GET .../members/{id}/workflowqueues` call per CLM member on -every sync, on top of the member sync itself, which is meaningful request volume on -large accounts. Workflow queue +members, so `clm_workflow_queue` is modeled as `clm_member`'s `ChildResourceType` +(`pkg/connector/resource_types.go`) rather than syncing independently: the SDK calls +`clmWorkflowQueueBuilder.List()` once per synced CLM member automatically, and that call +does one `GET .../members/{id}/workflowqueues` for that member — no session store, no +independent pagination, and no member-scanning/deduping logic of its own. Membership +grants are emitted from the member side (`clmMemberBuilder.Grants()`) rather than from +`clm_workflow_queue` itself, since CLM only exposes this relationship per member. That +means `GetMemberWorkflowQueues` runs twice per member per sync (once in child-resource +`List()`, once in `Grants()`) — an accepted tradeoff of this design. Workflow queue membership syncs for visibility only — the API supports work-item assign/unassign, not queue-membership grant/revoke, so it cannot be granted or revoked through this connector. diff --git a/cmd/baton-docusign/main.go b/cmd/baton-docusign/main.go index 35ef63b3..d26bd622 100644 --- a/cmd/baton-docusign/main.go +++ b/cmd/baton-docusign/main.go @@ -9,7 +9,6 @@ import ( "github.com/conductorone/baton-sdk/pkg/cli" "github.com/conductorone/baton-sdk/pkg/config" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" - "github.com/conductorone/baton-sdk/pkg/connectorrunner" ) var version = "dev" @@ -33,8 +32,5 @@ func main() { version, cfg.ConfigurationSchema, connectorFn, - // clm_workflow_queue's List() caches a member→queue-membership index across the - // whole sync (built once during the member scan, read once per queue in Grants()). - connectorrunner.WithSessionStoreEnabled(), ) } diff --git a/docs/doc-info.md b/docs/doc-info.md index d14b4889..00fe72ee 100644 --- a/docs/doc-info.md +++ b/docs/doc-info.md @@ -50,7 +50,7 @@ - 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. - CLM members are synced as their own resource type rather than merged into the existing eSignature "Users" resource, since the two could not be confirmed to represent the same identity. - - CLM workflow queues also sync for visibility only — the API supports work-item assign/unassign, not queue-membership grant/revoke. There's no list-all endpoint for queues, so the connector discovers them by checking every CLM member's own queue membership — one extra API call per member on top of the member sync itself. + - CLM workflow queues also sync for visibility only — the API supports work-item assign/unassign, not queue-membership grant/revoke. There's no list-all endpoint for queues, so they're modeled as a child resource of CLM members: the SDK calls `clmWorkflowQueueBuilder.List()` once per synced member automatically, and membership grants are emitted from `clmMemberBuilder.Grants()`. Each member therefore triggers two `GetMemberWorkflowQueues` calls per sync (child-resource List + Grants) — an accepted tradeoff of this design. --- diff --git a/pkg/client/clmtest/server.go b/pkg/client/clmtest/server.go index 2d8e12d3..507b8215 100644 --- a/pkg/client/clmtest/server.go +++ b/pkg/client/clmtest/server.go @@ -256,6 +256,26 @@ func (s *Server) AddMemberWithoutHref(memberID string) { s.memberOrder = append(s.memberOrder, memberID) } +// AddMemberWorkflowQueueWithEmptyHref seeds a new member (not part of the default seed) +// with a single workflow-queue membership whose Href is empty — clmIDFromHref then +// reports an empty ID for it, exercising clm_workflow_queue.List()'s skip-unusable-ID +// guard for one queue within an otherwise normal member scan. Mirrors +// AddMemberWithoutHref's equivalent case at the member level. Call after NewServer +// returns. +func (s *Server) AddMemberWorkflowQueueWithEmptyHref(memberID string) { + s.mu.Lock() + defer s.mu.Unlock() + + member := &client.ClmMember{Email: memberID + "@example.com", UserName: memberID} + member.Href = s.MemberHref(memberID) + s.members[memberID] = member + s.memberOrder = append(s.memberOrder, memberID) + + const queueID = "queue-no-href" + s.workflowQueues[queueID] = &client.ClmWorkflowQueue{Name: "No Href Queue"} // Href intentionally empty + s.memberWorkflowQueues[memberID] = []string{queueID} +} + // LastPatchedMemberGroupHrefs returns the raw Href strings the most recent PATCH // .../members/{id} request body carried for memberID — unlike MemberGroups (which // reduces everything to the trailing ID via idFromHref, the same as the real API's own diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 1899219f..91f7c670 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -2,15 +2,18 @@ package connector import ( "context" + "fmt" "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" ) // clmMemberBuilder syncs CLM Members — CLM's own principal object. Synced as its own // resource type rather than reused as the existing `user` resource: identity between -// the two could not be confirmed 1:1. +// the two could not be confirmed 1:1. Also emits clm_workflow_queue membership grants +// from Grants() — see that method's doc comment for why the principal side owns them. type clmMemberBuilder struct { resourceType *v2.ResourceType client *client.Client @@ -63,11 +66,34 @@ func (b *clmMemberBuilder) Entitlements(_ context.Context, _ *v2.Resource, _ rs. return nil, nil, nil } -// Grants: membership/permission grants are emitted from the entitlement-holder's side -// (clm_group, clm_folder) rather than here, per this project's own validated pattern -// for emitting grants from whichever side is cheapest. -func (b *clmMemberBuilder) Grants(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { - return nil, nil, nil +// Grants emits this member's clm_workflow_queue membership — the one exception to this +// project's usual "emit from the entitlement-holder's side" pattern (clm_group, +// clm_folder do that instead). CLM's API only exposes workflow-queue membership per +// member (GetMemberWorkflowQueues), not per-queue, so the principal side is the only +// side that can produce this. clmWorkflowQueueBuilder.List() (this member's child-resource +// sync) makes the same GetMemberWorkflowQueues call earlier in the sync to discover queue +// resources; this Grants() call repeats it once per member — an accepted 2x tradeoff of +// the ChildResourceType design (no session store between List and Grants phases). +func (b *clmMemberBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + memberID := resource.Id.Resource + + queues, annos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) + if err != nil { + return nil, nil, fmt.Errorf("baton-docusign: getting CLM workflow queues for member %s: %w", memberID, err) + } + + grants := make([]*v2.Grant, 0, len(queues)) + for _, q := range queues { + queueID := clmIDFromHref(q.Href) + if queueID == "" { + logSkippedClmWorkflowQueueWithEmptyHref(ctx, memberID, q.Href) + continue + } + queueResourceID := &v2.ResourceId{ResourceType: clmWorkflowQueueResourceType.Id, Resource: queueID} + grants = append(grants, grant.NewGrant(&v2.Resource{Id: queueResourceID}, entitlementClmWorkflowQueueMember, resource.Id)) + } + + return grants, &rs.SyncOpResults{Annotations: annos}, nil } func newClmMemberBuilder(c *client.Client) *clmMemberBuilder { @@ -81,6 +107,12 @@ func newClmMemberBuilder(c *client.Client) *clmMemberBuilder { // kept in the profile both for display and as the preferred sample href for Grant; // Grant falls back to client.MemberHref when it's absent, since neither a profile nor // an annotation is guaranteed to survive to where it's needed. +// +// Stamps the ChildResourceType annotation on every instance, mirroring the one declared +// on clmMemberResourceType itself — the SDK's child-resource scheduling +// (childResourceTypeIDs, pkg/sync/syncer.go) reads it off each resource instance, not +// the type declaration, so this is what actually triggers a clm_workflow_queue.List() +// call per member. func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) { profile := map[string]any{ profileFieldEmail: member.Email, @@ -106,5 +138,6 @@ func parseIntoClmMemberResource(member *client.ClmMember) (*v2.Resource, error) clmIDFromHref(member.Href), userTraits, rs.WithResourceProfile(profile), + rs.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: clmWorkflowQueueResourceType.Id}), ) } diff --git a/pkg/connector/clm_members_test.go b/pkg/connector/clm_members_test.go index 0db9e2c4..0cab1625 100644 --- a/pkg/connector/clm_members_test.go +++ b/pkg/connector/clm_members_test.go @@ -2,11 +2,15 @@ package connector import ( "context" + "strings" "testing" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/conductorone/baton-docusign/pkg/client/clmtest" ) @@ -37,6 +41,41 @@ func TestClmMemberBuilder_List_Pagination(t *testing.T) { } } +// TestClmMemberBuilder_List_StampsWorkflowQueueChildResourceType confirms +// parseIntoClmMemberResource stamps the ChildResourceType annotation on every synced +// clm_member RESOURCE INSTANCE, not just declared on clmMemberResourceType itself — the +// SDK's child-resource scheduling (childResourceTypeIDs, pkg/sync/syncer.go) reads it off +// each instance, so a resource missing this annotation would silently never get its +// clm_workflow_queue.List() call triggered even though the type declaration looks correct. +func TestClmMemberBuilder_List_StampsWorkflowQueueChildResourceType(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmMemberBuilder(c) + ctx := context.Background() + + resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(resources) == 0 { + t.Fatal("expected at least one clm_member resource") + } + for _, r := range resources { + annos := annotations.Annotations(r.Annotations) + var child v2.ChildResourceType + ok, err := annos.Pick(&child) + if err != nil { + t.Fatalf("resource %s: Pick(ChildResourceType): %v", r.Id.Resource, err) + } + if !ok { + t.Errorf("resource %s: expected a ChildResourceType annotation", r.Id.Resource) + continue + } + if child.ResourceTypeId != clmWorkflowQueueResourceType.Id { + t.Errorf("resource %s: expected ChildResourceType.ResourceTypeId %q, got %q", r.Id.Resource, clmWorkflowQueueResourceType.Id, child.ResourceTypeId) + } + } +} + 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 @@ -57,10 +96,8 @@ func TestClmMemberBuilder_List_FailsWhenClmUnavailable(t *testing.T) { } } -func TestClmMemberBuilder_EntitlementsAndGrants_AreNoop(t *testing.T) { - // clm_member is a pure principal: it holds no entitlements of its own, and any - // membership/role grants it's part of are emitted from the other side (clm_group, - // clm_folder) per this project's own "emit from whichever side is cheapest" pattern. +func TestClmMemberBuilder_Entitlements_IsNoop(t *testing.T) { + // clm_member is a pure principal: it holds no entitlements of its own. _, c := clmtest.NewServer(t) b := newClmMemberBuilder(c) ctx := context.Background() @@ -73,7 +110,127 @@ func TestClmMemberBuilder_EntitlementsAndGrants_AreNoop(t *testing.T) { if ents, res, err := b.Entitlements(ctx, memberResource, rs.SyncOpAttrs{}); err != nil || ents != nil || res != nil { t.Errorf("expected Entitlements to return (nil, nil, nil), got (%v, %v, %v)", ents, res, err) } - if grants, res, err := b.Grants(ctx, memberResource, rs.SyncOpAttrs{}); err != nil || grants != nil || res != nil { - t.Errorf("expected Grants to return (nil, nil, nil), got (%v, %v, %v)", grants, res, err) +} + +// TestClmMemberBuilder_Grants_EmitsWorkflowQueueMembership is the core regression test +// for the new design: Grants() moved here from clmWorkflowQueueBuilder (see +// clm_workflow_queues.go's doc) since CLM only exposes workflow-queue membership per +// member. member-bob (clmtest/seed.go) belongs to both seeded queues (Onboarding, +// Escalations) — this confirms one grant per queue, with the queue as the +// entitlement-resource side and the member as the principal. +func TestClmMemberBuilder_Grants_EmitsWorkflowQueueMembership(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmMemberBuilder(c) + ctx := context.Background() + + memberResource, err := rs.NewResource("Bob", clmMemberResourceType, "member-bob") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + + grants, res, err := b.Grants(ctx, memberResource, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants: %v", err) + } + if res == nil { + t.Fatal("expected a non-nil SyncOpResults") + } + if len(grants) != 2 { + t.Fatalf("expected 2 grants for member-bob's workflow queue membership, got %d: %+v", len(grants), grants) + } + + gotQueueIDs := make(map[string]bool, len(grants)) + for _, g := range grants { + if g.Entitlement.Resource.Id.ResourceType != clmWorkflowQueueResourceType.Id { + t.Errorf("expected the entitlement-holder to be a clm_workflow_queue, got %s", g.Entitlement.Resource.Id.ResourceType) + } + wantEntID := clmWorkflowQueueResourceType.Id + ":" + g.Entitlement.Resource.Id.Resource + ":" + entitlementClmWorkflowQueueMember + if g.Entitlement.Id != wantEntID { + t.Errorf("expected entitlement id %q, got %q", wantEntID, g.Entitlement.Id) + } + if g.Principal.Id.ResourceType != clmMemberResourceType.Id || g.Principal.Id.Resource != "member-bob" { + t.Errorf("expected principal member-bob, got %s:%s", g.Principal.Id.ResourceType, g.Principal.Id.Resource) + } + gotQueueIDs[g.Entitlement.Resource.Id.Resource] = true + } + for _, want := range []string{"queue-onboarding", "queue-escalations"} { + if !gotQueueIDs[want] { + t.Errorf("expected a grant for queue %q, got %v", want, gotQueueIDs) + } + } +} + +// TestClmMemberBuilder_Grants_NoQueues confirms a member with zero workflow-queue +// memberships (member-carol, clmtest/seed.go) returns an empty, non-nil grant slice +// rather than an error. +func TestClmMemberBuilder_Grants_NoQueues(t *testing.T) { + _, c := clmtest.NewServer(t) + b := newClmMemberBuilder(c) + ctx := context.Background() + + memberResource, err := rs.NewResource("Carol", clmMemberResourceType, "member-carol") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + + grants, _, err := b.Grants(ctx, memberResource, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants: %v", err) + } + if len(grants) != 0 { + t.Fatalf("expected zero grants for member-carol, got %d: %+v", len(grants), grants) + } +} + +// TestClmMemberBuilder_Grants_PropagatesClientError confirms a GetMemberWorkflowQueues +// failure propagates — wrapped with the baton-docusign: prefix, with the underlying +// gRPC code still reachable through the wrap — rather than being swallowed. +func TestClmMemberBuilder_Grants_PropagatesClientError(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) + b := newClmMemberBuilder(c) + ctx := context.Background() + + memberResource, err := rs.NewResource("Alice", clmMemberResourceType, "member-alice") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + + grants, _, err := b.Grants(ctx, memberResource, rs.SyncOpAttrs{}) + if err == nil { + t.Fatal("expected Grants to propagate the underlying client error, got nil") + } + if !strings.HasPrefix(err.Error(), "baton-docusign:") { + t.Errorf("expected error wrapped with the baton-docusign: prefix, got: %v", err) + } + if status.Code(err) != codes.PermissionDenied { + t.Errorf("expected the underlying PermissionDenied code to still be reachable through the wrap, got code %s (err: %v)", status.Code(err), err) + } + if grants != nil { + t.Errorf("expected nil grants on a hard failure, got %+v", grants) + } +} + +// TestClmMemberBuilder_Grants_SkipsQueueWithEmptyHref confirms a queue with an empty +// Href-derived ID is skipped rather than emitted as a malformed grant with an empty +// entitlement-resource ID — mirrors clm_workflow_queues_test.go's equivalent List() +// case, since Grants() here shares the identical per-queue skip check. +func TestClmMemberBuilder_Grants_SkipsQueueWithEmptyHref(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.AddMemberWorkflowQueueWithEmptyHref("member-no-href-queue") + b := newClmMemberBuilder(c) + ctx := context.Background() + + memberResource, err := rs.NewResource("No Href Queue Member", clmMemberResourceType, "member-no-href-queue") + if err != nil { + t.Fatalf("NewResource: %v", err) + } + + grants, _, err := b.Grants(ctx, memberResource, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants: %v", err) + } + if len(grants) != 0 { + t.Fatalf("expected the empty-Href queue to be skipped, got %d grants: %+v", len(grants), grants) } } diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 7f719402..dcbdebb7 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -6,64 +6,30 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" - "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" - "github.com/conductorone/baton-sdk/pkg/pagination" - "github.com/conductorone/baton-sdk/pkg/session" - "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" ) -// Consecutive opt-in failures (no success yet) before failing the sync. -const clmWorkflowQueueUnavailableThreshold = 3 - -// clmSessionKeyWorkflowQueueDiscoveryState is where List() persists member-scan -// progress across successive SDK-driven calls. -const clmSessionKeyWorkflowQueueDiscoveryState = "clm_workflow_queue_discovery_state" - -// clmWorkflowQueueDiscoveryState is List()'s accumulator across chunked calls. -// Exported fields round-trip through session.SetJSON/GetJSON. -type clmWorkflowQueueDiscoveryState struct { - Queues map[string]client.ClmWorkflowQueue `json:"queues"` - SucceededAtLeastOnce bool `json:"succeeded_at_least_once"` - ConsecutiveUnavailableFailures int `json:"consecutive_unavailable_failures"` - SkippedMembers int `json:"skipped_members"` - SkippedMembersNoID int `json:"skipped_members_no_id"` - SkippedQueues int `json:"skipped_queues"` - // NextExpectedInputToken is the ListMembers page token the next call should arrive - // with. Any other incoming token (a resumed sync rolling back one or more chunks) - // is a replay: List() resumes from this frontier instead of re-running an - // already-applied chunk and double-counting the escalation counters above. - NextExpectedInputToken string `json:"next_expected_input_token"` - // ScanComplete distinguishes "the scan finished" from "nothing processed yet" — both - // otherwise look identical (NextExpectedInputToken == ""). Without it, a replay of a - // scan that completed in a single page wouldn't be detected as a replay at all. - ScanComplete bool `json:"scan_complete"` -} - var _ connectorbuilder.StaticEntitlementSyncerV2 = (*clmWorkflowQueueBuilder)(nil) // entitlementClmWorkflowQueueMember is the single entitlement every CLM workflow queue // shares — see clmGroupBuilder's entitlementClmGroupMember for the identical pattern. const entitlementClmWorkflowQueueMember = "member" -// clmSessionKeyQueueMembers builds the session-cache key clm_workflow_queue's List() -// writes to and Grants() reads from — see clmWorkflowQueueBuilder's doc. -func clmSessionKeyQueueMembers(queueID string) string { - return "clm_workflow_queue_members:" + queueID -} - -// clmWorkflowQueueBuilder syncs CLM WorkflowQueues (UI "Task Groups" — unconfirmed). -// No list-all / no queue→members: List() scans members one page per SDK call (same -// shape as sibling builders), writes membership into a per-queue session key (a single -// all-queues blob would rewrite O(members²) and risk the store's size ceiling), and -// only emits resources on the last member page — the queue set isn't complete before -// then. Grants() reads that cache (not O(N×M)). Needs a sync-lifetime session store -// (main.go's WithSessionStoreEnabled). Read-only — no queue-membership write API. +// clmWorkflowQueueBuilder syncs CLM WorkflowQueues (UI "Task Groups" — unconfirmed) as +// clmMemberResourceType's ChildResourceType (see that type's doc comment). CLM exposes +// workflow-queue membership only per member (GetMemberWorkflowQueues) — there is no +// list-all-queues endpoint — so List() here is driven per-member by the SDK rather than +// paginating independently. A queue with several members is discovered once per member +// whose scan reaches it; the SDK upserts resources by (ResourceType, Resource) regardless +// of which parent scan produced them, so the repeat discovery is a no-op re-upsert, not +// something this builder needs to dedup itself (pkg/sync/syncer.go's syncResources). +// Membership grants are emitted from clmMemberBuilder.Grants() instead of here — the +// query is per-member either way, and emitting from the principal side avoids needing +// durable state between the resources and grants sync phases (contrast the previous +// session-store-accumulator design this replaced). That does mean GetMemberWorkflowQueues +// runs twice per member per sync (once here in List(), once in clmMemberBuilder.Grants()) +// — an accepted tradeoff of this ChildResourceType design. type clmWorkflowQueueBuilder struct { resourceType *v2.ResourceType client *client.Client @@ -73,323 +39,39 @@ func (b *clmWorkflowQueueBuilder) ResourceType(_ context.Context) *v2.ResourceTy return clmWorkflowQueueResourceType } -// List drains one ListMembers page per call — see clmWorkflowQueueBuilder's doc for why -// the member scan is chunked this way, and clmWorkflowQueueDiscoveryState for what gets -// persisted across chunks. Escalation/error-tolerance semantics for -// GetMemberWorkflowQueues failures are unchanged from a single-call scan: a tolerated -// code (PermissionDenied/Unauthenticated/NotFound/FailedPrecondition) before anything has -// succeeded counts toward clmWorkflowQueueUnavailableThreshold regardless of which chunk -// it lands in, and fails the sync loudly once reached; a NotFound after something has -// already succeeded is an isolated skip (an ordinary mid-scan deletion race, unrelated to -// whether this account can use CLM); any other tolerated code after success fails loud. -func (b *clmWorkflowQueueBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { - bag, pageToken, err := parsePageToken(attr.PageToken.Token, &v2.ResourceId{ResourceType: clmWorkflowQueueResourceType.Id}) - if err != nil { - return nil, nil, err - } - - state, found, err := session.GetJSON[clmWorkflowQueueDiscoveryState](ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState) - if err != nil { - if pageToken != "" { - // A non-empty incoming page token can only exist because an earlier chunk's - // session-store writes already succeeded — state.SucceededAtLeastOnce isn't - // available here (that's exactly what this failed read was trying to fetch), - // but the token itself proves real queue data is already durably persisted. - // Losing it now would read as every previously discovered queue having been - // deleted, so fail loud instead of accepting a lossy empty result. - return nil, nil, fmt.Errorf("baton-docusign: failed to read CLM workflow queue discovery state: %w", err) - } - // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) only - // tells the SDK to accept a store connection — whether one actually exists still - // depends on the parent process wiring a listen port, and it falls back to - // NoOpSessionStore (every Get call fails too) whenever it doesn't. This resource - // type's Grants() cannot function without the cache, but that's not true of the - // rest of the sync — a hard error here would fail every other resource type too. - // Skip gracefully instead, same as an unavailable CLM subscription. - ctxzap.Extract(ctx).Debug("baton-docusign: failed to read CLM workflow queue discovery state, skipping clm_workflow_queue sync", zap.Error(err)) - return nil, &rs.SyncOpResults{}, nil - } - if !found && pageToken != "" { - // A non-empty incoming page token means an earlier chunk already ran and wrote - // real progress, but the read above returned no error and no state — the entry - // itself is simply gone from the store (e.g. an in-memory store that restarted - // between chunks, or an expired entry). Silently restarting the scan from a - // zero-value state here would discover only the tail of the queue set from here - // on, and the final chunk would emit that partial set as the authoritative - // result — every queue an earlier chunk already found would read as deleted, the - // same false-deletion outcome the pageToken != "" checks above exist to prevent. - // - // Unlike those checks, this has no self-healing path: the SDK's persisted page - // token means every retry of this same sync run arrives with the same stale - // token and re-hits this branch, since there's nothing here that can reconstruct - // the lost state. Recovery is operator-driven — start a fresh full sync (a new - // run gets pageToken == "" and rebuilds state.Queues from scratch) once the - // session store is confirmed to persist across whatever caused this loss. - return nil, nil, fmt.Errorf("baton-docusign: CLM workflow queue discovery state missing mid-scan (page token %q); start a fresh full sync once the session store is stable", pageToken) - } - if found && (state.ScanComplete || pageToken != state.NextExpectedInputToken) { - return b.replayChunk(bag, state) - } - if state.Queues == nil { - state.Queues = make(map[string]client.ClmWorkflowQueue) - } - - members, nextMemberPageToken, allAnnos, err := b.client.ListMembers(ctx, client.PageOptions{ - PageSize: attr.PageToken.Size, - PageToken: pageToken, - }) +// List returns every workflow queue parentResourceID (a clm_member) belongs to. Called +// once per synced clm_member by the SDK's child-resource scheduling (driven by the +// ChildResourceType annotation clmMemberBuilder stamps on every member resource) — not +// paginated on its own, since GetMemberWorkflowQueues already pages CLM's response to +// completion internally. +func (b *clmWorkflowQueueBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { + if parentResourceID == nil { + // Only reachable if this type's registration ever diverges from the + // ChildResourceType annotation clm_member resources carry — the SDK derives + // every List() call for this type from that annotation, so this should never + // actually happen; failing loud beats silently returning nothing. + return nil, nil, fmt.Errorf("baton-docusign: clm_workflow_queue.List called without a parent CLM member") + } + + queues, annos, err := b.client.GetMemberWorkflowQueues(ctx, parentResourceID.Resource) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("baton-docusign: getting CLM workflow queues for member %s: %w", parentResourceID.Resource, err) } - // chunkMembersByQueue accumulates only THIS chunk's contribution to each queue's - // membership — merged into that queue's own session key below instead of growing - // state.Queues without bound (see this builder's doc for why). - chunkMembersByQueue := make(map[string][]string) - - for _, member := range members { - memberID := clmIDFromHref(member.Href) - if memberID == "" { - // Symmetric with the empty-queueID guard below: an empty ID would call - // GetMemberWorkflowQueues(ctx, "") -> GET .../members//workflowqueues, - // which 404s and — on the pre-success path — counts toward - // clmWorkflowQueueUnavailableThreshold, so a handful of malformed members - // early in scan order could hard-fail the sync and misreport it as "CLM - // unavailable" instead of "found members with no usable ID." Own counter, - // not the shared SkippedMembers below: sampling on a shared counter would - // let this failure class go unlogged entirely if enough of the other kind - // happened first. - state.SkippedMembersNoID++ - if n := state.SkippedMembersNoID; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Debug("baton-docusign: CLM member has an empty Href, skipping", - zap.String("member_username", member.UserName), zap.Int("total_occurrences", n)) - } + resources := make([]*v2.Resource, 0, len(queues)) + for _, q := range queues { + if clmIDFromHref(q.Href) == "" { + logSkippedClmWorkflowQueueWithEmptyHref(ctx, parentResourceID.Resource, q.Href) continue } - queues, queueAnnos, err := b.client.GetMemberWorkflowQueues(ctx, memberID) - if err != nil { - if isOptInFeatureUnavailableError(err) { - if !state.SucceededAtLeastOnce { - // Before any success: tolerate opt-in codes, but require - // clmWorkflowQueueUnavailableThreshold consecutive failures before - // failing the sync (one isolated NotFound must not wipe queues). - // CLM 404 means missing OR no access — same as other opt-in signals. - state.ConsecutiveUnavailableFailures++ - if state.ConsecutiveUnavailableFailures >= clmWorkflowQueueUnavailableThreshold { - return nil, nil, fmt.Errorf("baton-docusign: CLM workflow queues unavailable after %d consecutive member failures: %w", - state.ConsecutiveUnavailableFailures, err) - } - // Below the threshold: same visibility as the post-success - // isolated-NotFound skip below — this member's queue membership - // (if any) is silently missing from this sync otherwise. - state.SkippedMembers++ - if n := state.SkippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Debug("baton-docusign: failed to get CLM workflow queues for member, skipping", - zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) - } - continue - } - if status.Code(err) == codes.NotFound { - // Once at least one call has already succeeded, this endpoint is - // clearly available for this account, so a NotFound from here on - // is far more likely the isolated "member deleted between - // ListMembers and this call" case than a systemic one — skip just - // this member and keep scanning. - state.SkippedMembers++ - if n := state.SkippedMembers; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Debug("baton-docusign: CLM member not found while scanning workflow queues, skipping", - zap.String("member_id", memberID), zap.Int("total_occurrences", n), zap.Error(err)) - } - continue - } - // A non-NotFound tolerated code (PermissionDenied/Unauthenticated/ - // FailedPrecondition) after other members already succeeded is a - // real, isolated problem (an expiring token, a scope revoked - // mid-scan), not an unavailability signal — failing loud beats - // discarding every already-discovered queue as if the whole feature - // were unavailable. - } - return nil, nil, fmt.Errorf("baton-docusign: getting workflow queues for CLM member %s: %w", memberID, err) - } - state.SucceededAtLeastOnce = true - allAnnos = append(allAnnos, queueAnnos...) - - for _, q := range queues { - queueID := clmIDFromHref(q.Href) - if queueID == "" { - state.SkippedQueues++ - if n := state.SkippedQueues; n == 1 || n == 10 || n == 100 || n%1000 == 0 { - ctxzap.Extract(ctx).Debug("baton-docusign: CLM workflow queue has an empty Href, skipping", - zap.String("member_id", memberID), zap.String("queue_name", q.Name), zap.Int("total_occurrences", n)) - } - continue - } - if _, ok := state.Queues[queueID]; !ok { - state.Queues[queueID] = q - } - chunkMembersByQueue[queueID] = append(chunkMembersByQueue[queueID], memberID) - } - } - - // Merge this chunk's contribution into each touched queue's own session key rather - // than growing a single all-queues blob every chunk — see this builder's doc for why. - if len(chunkMembersByQueue) > 0 { - keys := make([]string, 0, len(chunkMembersByQueue)) - for queueID := range chunkMembersByQueue { - keys = append(keys, clmSessionKeyQueueMembers(queueID)) - } - existing, err := session.GetManyJSON[[]string](ctx, attr.Session, keys) - if err != nil { - if pageToken != "" { - // state.SucceededAtLeastOnce is always true by the time we reach this - // block (populating chunkMembersByQueue requires a prior successful - // GetMemberWorkflowQueues call), so it can't distinguish "real data exists - // only in this in-memory chunk" from "an earlier chunk already durably - // persisted real data" — a non-empty incoming page token is what actually - // proves the latter. Losing an earlier chunk's persisted membership here - // would make this the last (and only) response the SDK sees for this - // resource type — an authoritative empty result that reads as every - // previously synced clm_workflow_queue and its grants having been deleted. - // Propagate the error instead: the SDK preserves the last-known-good sync - // rather than accepting a lossy one. - return nil, nil, fmt.Errorf("baton-docusign: failed to read cached CLM workflow queue membership: %w", err) - } - // First chunk: nothing has been durably persisted yet, so this in-memory - // chunk's data is all that's at risk — same opt-in-session-store reasoning as - // the discovery-state read above. - ctxzap.Extract(ctx).Debug("baton-docusign: failed to read cached CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) - return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil - } - membersByKey := make(map[string][]string, len(chunkMembersByQueue)) - for queueID, newMembers := range chunkMembersByQueue { - key := clmSessionKeyQueueMembers(queueID) - // Dedup: a resumed sync can re-issue an already-processed chunk. - seen := make(map[string]struct{}, len(existing[key])+len(newMembers)) - merged := existing[key] - for _, m := range merged { - seen[m] = struct{}{} - } - for _, m := range newMembers { - if _, ok := seen[m]; ok { - continue - } - seen[m] = struct{}{} - merged = append(merged, m) - } - membersByKey[key] = merged - } - if err := session.SetManyJSON(ctx, attr.Session, membersByKey); err != nil { - if pageToken != "" { - // Same reasoning as the read above: state.SucceededAtLeastOnce can't tell - // "this chunk" apart from "an earlier, already-persisted chunk" here, so - // use the incoming page token instead. This chunk's membership updates - // would be silently dropped, and a graceful zero-resource response here - // reads as everything already discovered having been deleted. Fail loud - // instead of accepting that outcome. - return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue membership: %w", err) - } - // First chunk: nothing has been durably persisted yet. - ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue membership, skipping clm_workflow_queue sync", zap.Error(err)) - return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil - } - } - - state.NextExpectedInputToken = nextMemberPageToken - state.ScanComplete = nextMemberPageToken == "" - if err := session.SetJSON(ctx, attr.Session, clmSessionKeyWorkflowQueueDiscoveryState, state); err != nil { - if pageToken != "" { - // Same reasoning as the other three session-store failure sites above — and - // deliberately the SAME predicate, not state.SucceededAtLeastOnce: that field - // answers "did a member scan succeed", not "did an earlier chunk already - // prove this session store works", and the two diverge whenever every member - // scanned so far has failed below the escalation threshold. A non-empty - // incoming page token means chunk 1 already wrote successfully (that's the - // only way to get here with one), so a failure now is a genuine regression in - // an otherwise-working store, not the first-chunk "maybe never wired at all" - // case. Fail loud so the SDK preserves the last-known-good sync instead of - // accepting a lossy empty result. - return nil, nil, fmt.Errorf("baton-docusign: failed to cache CLM workflow queue discovery progress: %w", err) - } - // The session store is opt-in end-to-end: WithSessionStoreEnabled (main.go) - // only tells the SDK to accept a store connection — whether one actually - // exists still depends on the parent process wiring a listen port, and it - // falls back to NoOpSessionStore (every Set call fails) whenever it doesn't. - // This resource type's Grants() cannot function without it, but that's not - // true of the rest of the sync — a hard error here would fail every other - // resource type too. Skip gracefully instead, same as an unavailable CLM - // subscription. - ctxzap.Extract(ctx).Debug("baton-docusign: failed to cache CLM workflow queue discovery progress, skipping clm_workflow_queue sync", zap.Error(err)) - return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil - } - - if nextMemberPageToken != "" { - outToken, err := bag.NextToken(nextMemberPageToken) - if err != nil { - return nil, nil, err - } - return nil, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos), NextPageToken: outToken}, nil - } - - // Last member page: the queue set is only guaranteed complete now (see this - // builder's doc) — every queue's membership was already persisted incrementally - // above, so just emit every discovered queue as a resource. - resources := make([]*v2.Resource, 0, len(state.Queues)) - for _, q := range state.Queues { - queueResource, err := parseIntoClmWorkflowQueueResource(&q) - if err != nil { - return nil, nil, err - } - resources = append(resources, queueResource) - } - - return resources, &rs.SyncOpResults{Annotations: dedupeRateLimitAnnotations(allAnnos)}, nil -} - -// replayChunk handles a resumed sync arriving with an input token that isn't the one -// this state expects next (see NextExpectedInputToken's doc). The per-queue membership -// merge is dedup-safe against a replay, but the escalation counters aren't, so this -// resumes from the persisted frontier instead of re-running the per-member scan and its -// counter updates. -func (b *clmWorkflowQueueBuilder) replayChunk(bag *pagination.Bag, state clmWorkflowQueueDiscoveryState) ([]*v2.Resource, *rs.SyncOpResults, error) { - if state.NextExpectedInputToken != "" { - outToken, err := bag.NextToken(state.NextExpectedInputToken) - if err != nil { - return nil, nil, err - } - return nil, &rs.SyncOpResults{NextPageToken: outToken}, nil - } - resources := make([]*v2.Resource, 0, len(state.Queues)) - for _, q := range state.Queues { - queueResource, err := parseIntoClmWorkflowQueueResource(&q) + queueResource, err := parseIntoClmWorkflowQueueResource(&q, parentResourceID) if err != nil { return nil, nil, err } resources = append(resources, queueResource) } - return resources, &rs.SyncOpResults{}, nil -} -// dedupeRateLimitAnnotations keeps every non-rate-limit annotation as-is but collapses -// all RateLimitDescription entries down to the last one — List() appends one -// GetMemberWorkflowQueues annotation set per member processed in its current chunk, so a -// response can carry several near-identical rate-limit snapshots; only the most recent -// one is meaningful. Chunking (one ListMembers page per call) already bounds this to one -// page's worth of members rather than the whole account, but a page can still be in the -// hundreds, so this stays worth doing. -func dedupeRateLimitAnnotations(annos annotations.Annotations) annotations.Annotations { - var out annotations.Annotations - lastRateLimitIdx := -1 - for i, a := range annos { - if a.MessageIs(&v2.RateLimitDescription{}) { - lastRateLimitIdx = i - continue - } - out = append(out, a) - } - if lastRateLimitIdx >= 0 { - out = append(out, annos[lastRateLimitIdx]) - } - return out + return resources, &rs.SyncOpResults{Annotations: annos}, nil } // Entitlements returns nil — the SDK does not call this when StaticEntitlementSyncerV2 @@ -411,30 +93,10 @@ func (b *clmWorkflowQueueBuilder) StaticEntitlements(_ context.Context, _ rs.Syn return []*v2.Entitlement{ent}, nil, nil } -// Grants reads this queue's membership straight out of the session cache List() -// populated — see this builder's doc for why it doesn't re-scan members here. -func (b *clmWorkflowQueueBuilder) Grants(ctx context.Context, queueResource *v2.Resource, attr rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { - memberIDs, found, err := session.GetJSON[[]string](ctx, attr.Session, clmSessionKeyQueueMembers(queueResource.Id.Resource)) - if err != nil { - return nil, nil, fmt.Errorf("baton-docusign: failed to read cached CLM workflow queue %s membership: %w", queueResource.Id.Resource, err) - } - if !found { - // Shouldn't happen — see this builder's doc (List() always runs before Grants() - // for every resource of a type, and now skips this whole resource type gracefully - // rather than returning partial results whenever it can't populate the cache). - // Fail loudly instead of falling back to a per-queue member re-scan (the - // O(queues * members) cost this design exists to avoid) or silently emitting zero - // grants, which C1 can't distinguish from this queue's membership having been - // genuinely emptied out. - return nil, nil, fmt.Errorf("baton-docusign: no cached membership found for CLM workflow queue %s", queueResource.Id.Resource) - } - - grants := make([]*v2.Grant, 0, len(memberIDs)) - for _, memberID := range memberIDs { - memberResourceId := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: memberID} - grants = append(grants, grant.NewGrant(queueResource, entitlementClmWorkflowQueueMember, memberResourceId)) - } - return grants, nil, nil +// Grants: membership grants are emitted from clmMemberBuilder.Grants() instead — see +// this builder's type doc comment for why the principal side owns them. +func (b *clmWorkflowQueueBuilder) Grants(_ context.Context, _ *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + return nil, nil, nil } func newClmWorkflowQueueBuilder(c *client.Client) *clmWorkflowQueueBuilder { @@ -444,11 +106,17 @@ func newClmWorkflowQueueBuilder(c *client.Client) *clmWorkflowQueueBuilder { } } -func parseIntoClmWorkflowQueueResource(q *client.ClmWorkflowQueue) (*v2.Resource, error) { +// parseIntoClmWorkflowQueueResource maps a client.ClmWorkflowQueue to a Baton v2.Resource, +// scoped under the member whose scan discovered it. A queue can belong to several +// members, each independently discovering and re-upserting it (see this builder's doc +// comment) — parentResourceID here reflects whichever member's List() call produced this +// particular instance, not a claim that this is the queue's only member. +func parseIntoClmWorkflowQueueResource(q *client.ClmWorkflowQueue, parentResourceID *v2.ResourceId) (*v2.Resource, error) { return rs.NewGroupResource( q.Name, clmWorkflowQueueResourceType, clmIDFromHref(q.Href), nil, + rs.WithParentResourceID(parentResourceID), ) } diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index af57a614..79ee6ddd 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -2,328 +2,31 @@ package connector import ( "context" - "errors" "strings" - "sync" "testing" "github.com/conductorone/baton-docusign/pkg/client/clmtest" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" - "github.com/conductorone/baton-sdk/pkg/pagination" - "github.com/conductorone/baton-sdk/pkg/session" rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/conductorone/baton-sdk/pkg/types/sessions" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) -// fakeSessionStore is a minimal in-memory sessions.SessionStore for tests — the real -// implementations either require a live gRPC session server or otter cache wiring not -// worth pulling into a unit test. Ignores the SyncID/prefix bag entirely: a single test -// only ever needs one sync's worth of isolation. -type fakeSessionStore struct { - mu sync.Mutex - data map[string][]byte -} - -var _ sessions.SessionStore = (*fakeSessionStore)(nil) - -func newFakeSessionStore() *fakeSessionStore { - return &fakeSessionStore{data: make(map[string][]byte)} -} - -func (f *fakeSessionStore) Get(_ context.Context, key string, _ ...sessions.SessionStoreOption) ([]byte, bool, error) { - f.mu.Lock() - defer f.mu.Unlock() - v, ok := f.data[key] - return v, ok, nil -} - -// GetMany's second return is for keys this call couldn't get to and wants retried — -// session.UnrollGetMany loops passing it straight back in as the next call's key list, -// erroring if it ever stops shrinking. It is NOT "missing/never-written keys": those -// simply aren't present in the returned map, matching every real SessionStore -// implementation (e.g. dotc1z's SQL "WHERE key IN (...)" naturally omits absent rows). -// This fake never has a reason to ask for a retry, so it always returns nil here. -func (f *fakeSessionStore) GetMany(_ context.Context, keys []string, _ ...sessions.SessionStoreOption) (map[string][]byte, []string, error) { - f.mu.Lock() - defer f.mu.Unlock() - out := make(map[string][]byte) - for _, k := range keys { - if v, ok := f.data[k]; ok { - out[k] = v - } - } - return out, nil, nil -} - -func (f *fakeSessionStore) Set(_ context.Context, key string, value []byte, _ ...sessions.SessionStoreOption) error { - f.mu.Lock() - defer f.mu.Unlock() - f.data[key] = value - return nil -} - -func (f *fakeSessionStore) SetMany(_ context.Context, values map[string][]byte, _ ...sessions.SessionStoreOption) error { - f.mu.Lock() - defer f.mu.Unlock() - for k, v := range values { - f.data[k] = v - } - return nil -} - -func (f *fakeSessionStore) Delete(_ context.Context, key string, _ ...sessions.SessionStoreOption) error { - f.mu.Lock() - defer f.mu.Unlock() - delete(f.data, key) - return nil -} - -func (f *fakeSessionStore) Clear(_ context.Context, _ ...sessions.SessionStoreOption) error { - f.mu.Lock() - defer f.mu.Unlock() - f.data = make(map[string][]byte) - return nil -} - -func (f *fakeSessionStore) GetAll(_ context.Context, _ string, _ ...sessions.SessionStoreOption) (map[string][]byte, string, error) { - f.mu.Lock() - defer f.mu.Unlock() - out := make(map[string][]byte, len(f.data)) - for k, v := range f.data { - out[k] = v - } - return out, "", nil -} - -// failingSessionStore wraps fakeSessionStore but every Set/SetMany call fails — isolates -// the write-failure path (e.g. a value that exceeds the store's size limit) from reads, -// which still succeed via the embedded fakeSessionStore. -type failingSessionStore struct { - *fakeSessionStore -} - -func (f *failingSessionStore) Set(_ context.Context, _ string, _ []byte, _ ...sessions.SessionStoreOption) error { - return errClmSessionStoreDisabledForTest -} - -func (f *failingSessionStore) SetMany(_ context.Context, _ map[string][]byte, _ ...sessions.SessionStoreOption) error { - return errClmSessionStoreDisabledForTest -} - -// readFailingSessionStore wraps fakeSessionStore but every Get/GetMany call fails — -// stands in for the SDK's real NoOpSessionStore (returned whenever the parent process -// hasn't wired a session-store listen port; see session.NoOpSessionStore), whose reads -// fail the exact same way as its writes. -type readFailingSessionStore struct { - *fakeSessionStore -} - -func (f *readFailingSessionStore) Get(_ context.Context, _ string, _ ...sessions.SessionStoreOption) ([]byte, bool, error) { - return nil, false, errClmSessionStoreDisabledForTest -} - -func (f *readFailingSessionStore) GetMany(_ context.Context, _ []string, _ ...sessions.SessionStoreOption) (map[string][]byte, []string, error) { - return nil, nil, errClmSessionStoreDisabledForTest -} - -// amnesiacSessionStore wraps fakeSessionStore but Get always reports "not found" with a -// nil error, regardless of what's actually stored — stands in for a session store that -// loses data between chunks without erroring (e.g. an in-memory store that restarted), -// as opposed to readFailingSessionStore's hard error on every read. -type amnesiacSessionStore struct { - *fakeSessionStore -} - -func (f *amnesiacSessionStore) Get(_ context.Context, _ string, _ ...sessions.SessionStoreOption) ([]byte, bool, error) { - return nil, false, nil -} - -var errClmSessionStoreDisabledForTest = errors.New("session store disabled (test double)") - -// TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreWriteFailure confirms -// List() degrades to a graceful skip (not a hard error) when it can't write to the -// session cache — e.g. because the parent process didn't wire a session-store listen -// port and the SDK fell back to NoOpSessionStore. A hard error here would fail the -// entire sync, not just this resource type, since every other CLM builder's List() also -// runs unconditionally in the same sync. -func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreWriteFailure(t *testing.T) { - _, c := clmtest.NewServer(t) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: &failingSessionStore{fakeSessionStore: newFakeSessionStore()}}) - if err != nil { - t.Fatalf("expected a session-store write failure to be tolerated, not an error: %v", err) - } - if len(resources) != 0 { - t.Errorf("expected zero resources when the session store can't be written to, got %d", len(resources)) - } - if res == nil { - t.Errorf("expected a non-nil SyncOpResults, got %+v", res) - } -} - -// TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreReadFailure confirms -// List() degrades to a graceful skip (not a hard error) when it can't read its -// discovery state from the session cache — the same NoOpSessionStore fallback as the -// write-failure case above, just hit on the read that now happens first in every call. -func TestClmWorkflowQueueBuilder_List_SkipsGracefullyOnSessionStoreReadFailure(t *testing.T) { - _, c := clmtest.NewServer(t) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: &readFailingSessionStore{fakeSessionStore: newFakeSessionStore()}}) - if err != nil { - t.Fatalf("expected a session-store read failure to be tolerated, not an error: %v", err) - } - if len(resources) != 0 { - t.Errorf("expected zero resources when the session store can't be read from, got %d", len(resources)) - } - if res == nil { - t.Errorf("expected a non-nil SyncOpResults, got %+v", res) - } -} - -// TestClmWorkflowQueueBuilder_List_FailsLoudlyOnSessionStoreFailureAfterFirstChunk is the -// regression test for the false-deletion bug the pageToken != "" gating (see List's -// discovery-state read) exists to prevent: unlike the two graceful-skip tests above -// (both hit on the very first chunk, before anything has been persisted), a -// session-store read failure on a LATER chunk — after an earlier chunk already durably -// wrote real queue data — must fail the sync instead of reporting zero resources, which -// would read as every already-discovered queue having been deleted. -func TestClmWorkflowQueueBuilder_List_FailsLoudlyOnSessionStoreFailureAfterFirstChunk(t *testing.T) { - _, c := clmtest.NewServer(t) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - store := newFakeSessionStore() - - _, syncRes, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: store, PageToken: pagination.Token{Size: 1}}) - if err != nil { - t.Fatalf("chunk 1: %v", err) - } - if syncRes.NextPageToken == "" { - t.Fatalf("expected more than one seeded member so chunk 1 doesn't already finish the scan") - } - - _, _, err = b.List(ctx, nil, rs.SyncOpAttrs{ - Session: &readFailingSessionStore{fakeSessionStore: store}, - PageToken: pagination.Token{Size: 1, Token: syncRes.NextPageToken}, - }) - if err == nil { - t.Fatal("expected a later-chunk session-store read failure to fail loudly, not skip " + - "gracefully — an earlier chunk already persisted real queue data that a graceful " + - "zero-resource response would read as deleted") - } -} - -// TestClmWorkflowQueueBuilder_List_FailsLoudlyOnMembershipWriteFailureAfterFirstChunk is -// the write-side counterpart of the test above — same pageToken != "" gating, but hit on -// the SetManyJSON membership write instead of the top-of-function discovery-state read. -func TestClmWorkflowQueueBuilder_List_FailsLoudlyOnMembershipWriteFailureAfterFirstChunk(t *testing.T) { - _, c := clmtest.NewServer(t) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - store := newFakeSessionStore() - - _, syncRes, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: store, PageToken: pagination.Token{Size: 1}}) - if err != nil { - t.Fatalf("chunk 1: %v", err) - } - if syncRes.NextPageToken == "" { - t.Fatalf("expected more than one seeded member so chunk 1 doesn't already finish the scan") - } - - _, _, err = b.List(ctx, nil, rs.SyncOpAttrs{ - Session: &failingSessionStore{fakeSessionStore: store}, - PageToken: pagination.Token{Size: 1, Token: syncRes.NextPageToken}, - }) - if err == nil { - t.Fatal("expected a later-chunk membership-write failure to fail loudly, not skip " + - "gracefully — an earlier chunk already persisted real queue data that a graceful " + - "zero-resource response would read as deleted") - } -} - -// TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenDiscoveryStateMissingMidScan covers a -// third failure shape distinct from the two above: the session-store read on a later -// chunk succeeds (no error) but the entry is simply gone (found == false) — e.g. an -// in-memory store that restarted between chunks. Silently restarting the scan from a -// zero-value state would discover only the tail of the queue set and emit that as the -// authoritative result, so this must fail loudly too, the same as an outright read error. -func TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenDiscoveryStateMissingMidScan(t *testing.T) { - _, c := clmtest.NewServer(t) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - store := newFakeSessionStore() - - _, syncRes, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: store, PageToken: pagination.Token{Size: 1}}) - if err != nil { - t.Fatalf("chunk 1: %v", err) - } - if syncRes.NextPageToken == "" { - t.Fatalf("expected more than one seeded member so chunk 1 doesn't already finish the scan") - } - - _, _, err = b.List(ctx, nil, rs.SyncOpAttrs{ - Session: &amnesiacSessionStore{fakeSessionStore: store}, - PageToken: pagination.Token{Size: 1, Token: syncRes.NextPageToken}, - }) - if err == nil { - t.Fatal("expected discovery state missing mid-scan (found == false, no error) to fail " + - "loudly, not silently restart the scan — an earlier chunk already persisted real " + - "queue data that a fresh, partial scan would read as deleted") - } - if !strings.Contains(err.Error(), "discovery state missing mid-scan") { - t.Errorf("expected the found==false mid-scan branch specifically, got: %v", err) - } -} - -func TestClmWorkflowQueueBuilder_List_FailsWhenClmUnavailable(t *testing.T) { - // clm_workflow_queue is OptInRequired, and C1's opt-in toggle doesn't check the - // account can actually use it first — see List()'s escalation branch in - // clm_workflow_queues.go for the full rationale. List() must fail loudly here - // rather than silently succeed with zero resources. - s, _ := clmtest.NewServer(t) - badClient := s.NewClientWithToken("wrong-token") - b := newClmWorkflowQueueBuilder(badClient) - ctx := context.Background() - - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - 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 TestClmWorkflowQueueBuilder_StaticEntitlements(t *testing.T) { +// TestClmWorkflowQueueBuilder_List_ReturnsMemberWorkflowQueues is the core regression +// test for this builder's redesign: clm_workflow_queue is now clmMemberResourceType's +// ChildResourceType (see resource_types.go), so List() is driven per-member by the SDK's +// own child-resource scheduling instead of an independent, hand-rolled member scan. +// member-bob (clmtest/seed.go) belongs to both seeded queues (Onboarding, Escalations) — +// this confirms List() returns exactly that member's queues, each scoped under the +// parent member resource via ParentResourceId. +func TestClmWorkflowQueueBuilder_List_ReturnsMemberWorkflowQueues(t *testing.T) { _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - ents, _, err := b.StaticEntitlements(ctx, rs.SyncOpAttrs{}) - if err != nil { - t.Fatalf("StaticEntitlements: %v", err) - } - if len(ents) != 1 || ents[0].Slug != entitlementClmWorkflowQueueMember { - t.Fatalf("expected a single %q entitlement, got %+v", entitlementClmWorkflowQueueMember, ents) - } -} - -// TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan is the core regression -// test for this builder's whole design: there is no list-all endpoint for workflow -// queues (see clmWorkflowQueueBuilder's doc), so List() has to discover the distinct -// set by scanning every member's own workflow-queue membership and deduping. The seed -// data (clmtest/seed.go) puts member-alice in one queue and member-bob in two, with one -// queue (Onboarding) shared between them — this confirms both the discovery and the -// dedup-by-ID across members. -func TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan(t *testing.T) { - _, c := clmtest.NewServer(t) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() + parentResourceID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: "member-bob"} - resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) + resources, res, err := b.List(ctx, parentResourceID, rs.SyncOpAttrs{}) if err != nil { t.Fatalf("List: %v", err) } @@ -331,499 +34,190 @@ func TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan(t *testing.T) t.Fatal("expected a non-nil SyncOpResults") } if len(resources) != 2 { - t.Fatalf("expected 2 distinct workflow queues (Onboarding, Escalations), got %d: %+v", len(resources), resources) + t.Fatalf("expected 2 workflow queues for member-bob, got %d: %+v", len(resources), resources) } - names := make(map[string]bool) + gotIDs := make(map[string]bool, len(resources)) for _, r := range resources { - names[r.DisplayName] = true - } - if !names["Onboarding"] || !names["Escalations"] { - t.Errorf("expected both Onboarding and Escalations among the discovered queues, got %v", names) - } -} - -// TestClmWorkflowQueueBuilder_List_SkipsMemberWithEmptyHref confirms the empty-memberID -// guard: a malformed member with no Href must not reach GetMemberWorkflowQueues at all -// (which would 404 and, on the pre-success path, count toward -// clmWorkflowQueueUnavailableThreshold), must not disturb discovery of the other -// members' real queues, and must not touch the escalation counter — it's a data-quality -// skip, not an unavailability signal. -func TestClmWorkflowQueueBuilder_List_SkipsMemberWithEmptyHref(t *testing.T) { - srv, c := clmtest.NewServer(t) - srv.AddMemberWithoutHref("member-no-href") - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - if err != nil { - t.Fatalf("expected a member with an empty Href to be skipped, got error: %v", err) - } - if len(resources) != 2 { - t.Fatalf("expected the other members' 2 queues to still be discovered, got %d: %+v", len(resources), resources) - } - if got := srv.MemberWorkflowQueuesRequestCount(); got != 6 { - t.Errorf("expected exactly 6 GetMemberWorkflowQueues calls (the 6 real seeded members, not the malformed one), got %d", got) - } -} - -// TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan confirms a single member -// 404ing (deleted between ListMembers and this call) is skipped, not a sync-wide -// failure, once at least one other member has already proven the endpoint works — -// member-carol is scanned after member-alice (who succeeds and contributes a queue), -// so this exercises the "isolated NotFound" branch specifically, not the "nothing has -// succeeded yet" escalation -// TestClmWorkflowQueueBuilder_List_FailsAfterConsecutiveFailures covers. -func TestClmWorkflowQueueBuilder_List_ToleratesNotFoundMidScan(t *testing.T) { - srv, c := clmtest.NewServer(t) - // member-carol is a real seeded member (clmtest/seed.go) with zero queues of its - // own — forcing 404 here confirms the skip doesn't disturb discovery of the other - // members' queues (Onboarding/Escalations), not just that the scan doesn't crash. - srv.ForceMemberWorkflowQueuesStatus("member-carol", 404) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - if err != nil { - t.Fatalf("expected a 404 on one member to be tolerated, got error: %v", err) - } - if len(resources) != 2 { - t.Fatalf("expected the other members' 2 queues to still be discovered, got %d: %+v", len(resources), resources) - } -} - -// TestClmWorkflowQueueBuilder_List_ToleratesBelowThresholdFailures is a regression -// test: escalating to the account-wide-unavailability skip on a SINGLE tolerated -// failure (the previous behavior) reintroduced the exact false-deletion race the -// isolated-NotFound skip exists to avoid — a member genuinely deleted between -// ListMembers and this call would wipe the whole resource type if it happened to be -// first in scan order. Forcing a 404 on only member-alice (first in scan order, below -// clmWorkflowQueueUnavailableThreshold) must NOT escalate: member-bob's real queues -// still get discovered normally. -func TestClmWorkflowQueueBuilder_List_ToleratesBelowThresholdFailures(t *testing.T) { - srv, c := clmtest.NewServer(t) - srv.ForceMemberWorkflowQueuesStatus("member-alice", 404) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - if err != nil { - t.Fatalf("expected a single below-threshold failure to be tolerated, got error: %v", err) - } - if len(resources) != 2 { - t.Fatalf("expected member-bob's 2 real queues to still be discovered, got %d: %+v", len(resources), resources) - } -} - -// TestClmWorkflowQueueBuilder_List_FailsAfterConsecutiveFailures confirms the -// account-wide-unavailability escalation still fires once -// clmWorkflowQueueUnavailableThreshold consecutive members fail with nothing -// discovered yet — member-alice, member-bob, and member-carol are first in scan order -// (clmtest/seed.go's memberOrder), so forcing all three to fail reaches the threshold -// before any of them can succeed. Once reached, List() fails loud (see -// TestClmWorkflowQueueBuilder_List_FailsWhenClmUnavailable's rationale) rather than -// tolerating it. -func TestClmWorkflowQueueBuilder_List_FailsAfterConsecutiveFailures(t *testing.T) { - srv, c := clmtest.NewServer(t) - srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) - srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) - srv.ForceMemberWorkflowQueuesStatus("member-carol", 403) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - if err == nil { - t.Fatalf("expected List to fail after %d consecutive failures with nothing discovered yet, got nil error", clmWorkflowQueueUnavailableThreshold) - } - if len(resources) != 0 { - t.Errorf("expected zero resources on a hard failure, got %d: %+v", len(resources), resources) - } -} - -// TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenLaterMemberDenied is a regression -// test: List() previously escalated ANY isOptInFeatureUnavailableError to "CLM -// unavailable, return zero resources", -// regardless of scan position — so a PermissionDenied on member N (a token expiring or -// a scope revoked mid-scan) after earlier members had already contributed real queues -// would silently discard every already-discovered queue as if the whole feature were -// unavailable, the same false-deletion risk ListMembers' own memberPageToken == "" -// narrowing exists to avoid. member-bob is scanned after member-alice (whose Onboarding -// queue is already in membership by the time bob's call fails), so this must now fail -// loud instead of returning zero resources with a nil error. -func TestClmWorkflowQueueBuilder_List_FailsLoudlyWhenLaterMemberDenied(t *testing.T) { - srv, c := clmtest.NewServer(t) - srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - if err == nil { - t.Fatal("expected a PermissionDenied after queues were already discovered to fail loudly, got nil error") - } - if len(resources) != 0 { - t.Errorf("expected zero resources on a hard failure, got %d: %+v", len(resources), resources) - } -} - -// TestClmWorkflowQueueBuilder_List_ZeroQueueSuccessCountsAsSucceeded is a regression -// test for succeededAtLeastOnce's exact semantics: it must be set by ANY successful -// GetMemberWorkflowQueues call, including one that finds zero queues, not just one that -// contributes to membership. member-alice and member-bob (the only two seeded members -// with real queues) are both forced to fail — below clmWorkflowQueueUnavailableThreshold, -// so neither escalates — and member-carol (zero queues, clmtest/seed.go) succeeds next, -// which must count as proof the endpoint works. member-dave failing afterward must then -// fail loud, not be treated as still-pre-success. -func TestClmWorkflowQueueBuilder_List_ZeroQueueSuccessCountsAsSucceeded(t *testing.T) { - srv, c := clmtest.NewServer(t) - srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) - srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) - srv.ForceMemberWorkflowQueuesStatus("member-dave", 403) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - - resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - if err == nil { - t.Fatal("expected member-dave's failure (after member-carol's zero-queue success) to fail loudly, got nil error") + if r.Id.ResourceType != clmWorkflowQueueResourceType.Id { + t.Errorf("expected resource type %q, got %q", clmWorkflowQueueResourceType.Id, r.Id.ResourceType) + } + if r.ParentResourceId == nil || r.ParentResourceId.ResourceType != parentResourceID.ResourceType || r.ParentResourceId.Resource != parentResourceID.Resource { + t.Errorf("expected ParentResourceId %+v, got %+v", parentResourceID, r.ParentResourceId) + } + gotIDs[r.Id.Resource] = true } - if len(resources) != 0 { - t.Errorf("expected zero resources on a hard failure, got %d: %+v", len(resources), resources) + for _, want := range []string{"queue-onboarding", "queue-escalations"} { + if !gotIDs[want] { + t.Errorf("expected queue %q among member-bob's workflow queues, got %v", want, gotIDs) + } } } -// TestClmWorkflowQueueBuilder_Grants_ReadsFromCache confirms the other half of this -// builder's design: Grants() must NOT re-scan every member per queue (that would turn -// one O(members) traversal into O(queues * members) — see the builder's doc) — it reads -// the member list List() already cached for this exact queue. -func TestClmWorkflowQueueBuilder_Grants_ReadsFromCache(t *testing.T) { +// TestClmWorkflowQueueBuilder_List_SingleQueueMember is a narrower complement to +// ReturnsMemberWorkflowQueues above: member-alice belongs to exactly one seeded queue +// (Onboarding), confirming List() doesn't leak another member's queues onto this one. +func TestClmWorkflowQueueBuilder_List_SingleQueueMember(t *testing.T) { _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} - resources, _, err := b.List(ctx, nil, attr) + parentResourceID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: "member-alice"} + resources, _, err := b.List(ctx, parentResourceID, rs.SyncOpAttrs{}) if err != nil { t.Fatalf("List: %v", err) } - - byName := make(map[string]*v2.Resource) - for _, r := range resources { - byName[r.DisplayName] = r - } - - grants, _, err := b.Grants(ctx, byName["Onboarding"], attr) - if err != nil { - t.Fatalf("Grants(Onboarding): %v", err) - } - if len(grants) != 2 { - t.Fatalf("expected 2 members (alice, bob) in Onboarding, got %d: %+v", len(grants), grants) - } - - grants, _, err = b.Grants(ctx, byName["Escalations"], attr) - if err != nil { - t.Fatalf("Grants(Escalations): %v", err) - } - if len(grants) != 1 { - t.Fatalf("expected 1 member (bob) in Escalations, got %d: %+v", len(grants), grants) - } -} - -// TestClmWorkflowQueueBuilder_Grants_CacheMiss confirms Grants() fails loudly (not a -// silent zero-grants degrade, and not a fallback member re-scan) when the cache has -// nothing for a queue — e.g. if it's ever called without List() having populated it -// first. Zero grants would be indistinguishable from this queue's membership having -// been genuinely emptied out, which is worse than failing the sync. -func TestClmWorkflowQueueBuilder_Grants_CacheMiss(t *testing.T) { - _, c := clmtest.NewServer(t) - b := newClmWorkflowQueueBuilder(c) - ctx := context.Background() - - queueResource := &v2.Resource{Id: &v2.ResourceId{ResourceType: clmWorkflowQueueResourceType.Id, Resource: "queue-onboarding"}} - grants, _, err := b.Grants(ctx, queueResource, rs.SyncOpAttrs{Session: newFakeSessionStore()}) - if err == nil { - t.Fatal("expected a cache miss to fail loudly, got nil error") - } - if len(grants) != 0 { - t.Errorf("expected zero grants on a cache miss, got %d: %+v", len(grants), grants) + if len(resources) != 1 || resources[0].Id.Resource != "queue-onboarding" { + t.Fatalf("expected exactly [queue-onboarding] for member-alice, got %+v", resources) } } -// TestClmWorkflowQueueBuilder_List_ChunksAcrossPages is the core regression test for -// this builder's chunked design: List() must page one ListMembers page per call, like -// every other builder in this connector, instead of running the entire member scan -// inside a single call. Forcing PageSize 2 against the 6 seeded members (alice, bob, -// carol, dave, eve, frank) produces 3 ListMembers pages, so 3 separate List() calls are -// required. -// Every call but the last must return zero resources with a non-empty NextPageToken — -// the queue set can't be confirmed complete before the member scan is — and the final -// call must return the same 2 distinct queues (Onboarding, Escalations) the single-call -// design already proved correct in TestClmWorkflowQueueBuilder_List_DiscoversQueuesViaMemberScan. -func TestClmWorkflowQueueBuilder_List_ChunksAcrossPages(t *testing.T) { +// TestClmWorkflowQueueBuilder_List_NoQueues confirms a member with zero workflow-queue +// memberships (member-carol, clmtest/seed.go) produces an empty, non-error result rather +// than List() treating "no queues" as a fault. +func TestClmWorkflowQueueBuilder_List_NoQueues(t *testing.T) { _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} - var resources []*v2.Resource - pageToken := "" - for i := 0; i < 10; i++ { - attr.PageToken = pagination.Token{Size: 2, Token: pageToken} - res, syncRes, err := b.List(ctx, nil, attr) - if err != nil { - t.Fatalf("List page %d: %v", i, err) - } - if syncRes.NextPageToken == "" { - resources = res - break - } - if len(res) != 0 { - t.Fatalf("page %d: expected zero resources before the member scan completes, got %d: %+v", i, len(res), res) - } - pageToken = syncRes.NextPageToken - } - - if len(resources) != 2 { - t.Fatalf("expected 2 distinct workflow queues (Onboarding, Escalations) on the final page, got %d: %+v", len(resources), resources) - } - names := make(map[string]bool) - for _, r := range resources { - names[r.DisplayName] = true - } - if !names["Onboarding"] || !names["Escalations"] { - t.Errorf("expected both Onboarding and Escalations among the discovered queues, got %v", names) - } - - // Grants() must work off the same session store exactly as the single-call design. - byName := make(map[string]*v2.Resource) - for _, r := range resources { - byName[r.DisplayName] = r - } - grants, _, err := b.Grants(ctx, byName["Onboarding"], attr) + parentResourceID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: "member-carol"} + resources, _, err := b.List(ctx, parentResourceID, rs.SyncOpAttrs{}) if err != nil { - t.Fatalf("Grants(Onboarding): %v", err) + t.Fatalf("List: %v", err) } - if len(grants) != 2 { - t.Fatalf("expected 2 members (alice, bob) in Onboarding, got %d: %+v", len(grants), grants) + if len(resources) != 0 { + t.Fatalf("expected zero workflow queues for member-carol, got %d: %+v", len(resources), resources) } } -// TestClmWorkflowQueueBuilder_List_MergesMembershipAcrossChunks is the regression test -// for the incremental per-queue session write: each chunk merges its own contribution -// into a queue's session key via a Get-then-append-then-Set round trip, and a wrong -// merge (e.g. overwriting instead of appending) would silently drop every earlier -// chunk's members. member-alice and member-bob are both in the Onboarding queue (see -// clmtest/seed.go) but PageSize 1 puts them in separate chunks, so this only passes if -// chunk 2's write actually preserves chunk 1's contribution. -func TestClmWorkflowQueueBuilder_List_MergesMembershipAcrossChunks(t *testing.T) { +// TestClmWorkflowQueueBuilder_List_NilParentResourceID pins List()'s documented guard +// against a nil parent. The SDK should never call List() this way — every call for this +// type is derived from clmMemberResourceType's ChildResourceType annotation +// (resource_types.go) — but this proves the guard fails loud with a clear message +// instead of panicking if that invariant is ever violated. +func TestClmWorkflowQueueBuilder_List_NilParentResourceID(t *testing.T) { _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} - var resources []*v2.Resource - pageToken := "" - for i := 0; i < 10; i++ { - attr.PageToken = pagination.Token{Size: 1, Token: pageToken} - res, syncRes, err := b.List(ctx, nil, attr) - if err != nil { - t.Fatalf("List page %d: %v", i, err) - } - if syncRes.NextPageToken == "" { - resources = res - break - } - pageToken = syncRes.NextPageToken - } - - byName := make(map[string]*v2.Resource) - for _, r := range resources { - byName[r.DisplayName] = r + resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{}) + if err == nil { + t.Fatal("expected an error when List is called without a parent CLM member, got nil") } - grants, _, err := b.Grants(ctx, byName["Onboarding"], attr) - if err != nil { - t.Fatalf("Grants(Onboarding): %v", err) + if !strings.Contains(err.Error(), "clm_workflow_queue.List called without a parent CLM member") { + t.Errorf("expected the documented nil-parent error message, got: %v", err) } - if len(grants) != 2 { - t.Fatalf("expected both alice (chunk 1) and bob (chunk 2) merged into Onboarding, got %d: %+v", len(grants), grants) + if resources != nil || res != nil { + t.Errorf("expected (nil, nil, err), got (%v, %v, %v)", resources, res, err) } } -// TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDuplicateMembership is a -// regression test: a resumed sync can re-issue a List() call with a PageToken whose -// chunk was already processed and merged. Re-running the first chunk (alice) must not -// double-count alice in Onboarding's cached membership. -func TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDuplicateMembership(t *testing.T) { - _, c := clmtest.NewServer(t) +// TestClmWorkflowQueueBuilder_List_PropagatesClientError confirms a +// GetMemberWorkflowQueues failure propagates — wrapped with the baton-docusign: prefix, +// with the underlying gRPC code still reachable through the wrap — rather than being +// swallowed or downgraded to an empty result. Unlike the old session-store design, this +// builder has no error-tolerance logic of its own to bypass here. +func TestClmWorkflowQueueBuilder_List_PropagatesClientError(t *testing.T) { + srv, c := clmtest.NewServer(t) + srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - attr := rs.SyncOpAttrs{Session: newFakeSessionStore(), PageToken: pagination.Token{Size: 1}} - if _, _, err := b.List(ctx, nil, attr); err != nil { - t.Fatalf("List (first run of chunk 1): %v", err) - } - if _, _, err := b.List(ctx, nil, attr); err != nil { - t.Fatalf("List (replayed chunk 1): %v", err) + parentResourceID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: "member-alice"} + resources, _, err := b.List(ctx, parentResourceID, rs.SyncOpAttrs{}) + if err == nil { + t.Fatal("expected List to propagate the underlying client error, got nil") } - - memberIDs, found, err := session.GetJSON[[]string](ctx, attr.Session, clmSessionKeyQueueMembers("queue-onboarding")) - if err != nil { - t.Fatalf("GetJSON: %v", err) + if !strings.HasPrefix(err.Error(), "baton-docusign:") { + t.Errorf("expected error wrapped with the baton-docusign: prefix, got: %v", err) } - if !found { - t.Fatal("expected cached membership for queue-onboarding") + if status.Code(err) != codes.PermissionDenied { + t.Errorf("expected the underlying PermissionDenied code to still be reachable through the wrap, got code %s (err: %v)", status.Code(err), err) } - if len(memberIDs) != 1 || memberIDs[0] != "member-alice" { - t.Errorf("expected exactly one alice entry after the replay, got %v", memberIDs) + if resources != nil { + t.Errorf("expected nil resources on a hard failure, got %+v", resources) } } -// TestClmWorkflowQueueBuilder_List_EscalationThresholdSpansChunks is a regression test -// for the specific risk chunking introduces: the escalation-threshold counters -// (clmWorkflowQueueDiscoveryState) must persist and keep accumulating ACROSS separate -// List() calls, not reset per chunk — otherwise 2 consecutive failures in one chunk -// followed by 1 more in the next chunk would never reach clmWorkflowQueueUnavailableThreshold -// (3), even though the same 3 consecutive failures in a single unchunked call already do -// (per TestClmWorkflowQueueBuilder_List_FailsAfterConsecutiveFailures). Forces -// alice and bob (chunk 1, PageSize 2) and carol (chunk 2) to all fail — the first chunk -// alone only sees 2 failures (below threshold, must NOT escalate yet), and the second -// chunk's first member pushes the running total to 3 and must escalate there. -func TestClmWorkflowQueueBuilder_List_EscalationThresholdSpansChunks(t *testing.T) { +// TestClmWorkflowQueueBuilder_List_SkipsQueueWithEmptyHref confirms a queue with an +// empty Href-derived ID is skipped, not turned into a malformed resource with an empty +// native ID — mirrors clm_folders.go/clm_groups.go's established pattern of skipping +// rather than erroring on an unusable ID (see e.g. clmFolderBuilder.Grants' skip of +// unmapped AccessType entries and AddMemberWithoutHref's equivalent member-level case). +func TestClmWorkflowQueueBuilder_List_SkipsQueueWithEmptyHref(t *testing.T) { srv, c := clmtest.NewServer(t) - srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) - srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) - srv.ForceMemberWorkflowQueuesStatus("member-carol", 403) + srv.AddMemberWorkflowQueueWithEmptyHref("member-no-href-queue") b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} - // Chunk 1: alice, bob — 2 failures, below threshold, must continue (non-empty - // NextPageToken) with zero resources, not escalate yet. - attr.PageToken = pagination.Token{Size: 2, Token: ""} - resources, syncRes, err := b.List(ctx, nil, attr) + parentResourceID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: "member-no-href-queue"} + resources, _, err := b.List(ctx, parentResourceID, rs.SyncOpAttrs{}) if err != nil { - t.Fatalf("chunk 1: expected 2 below-threshold failures to be tolerated, got error: %v", err) - } - if len(resources) != 0 { - t.Fatalf("chunk 1: expected zero resources, got %d: %+v", len(resources), resources) - } - if syncRes.NextPageToken == "" { - t.Fatal("chunk 1: expected a non-empty NextPageToken — the member scan isn't done and shouldn't have escalated yet") - } - - // Chunk 2: carol is first — this is the 3rd CONSECUTIVE failure counting the two - // from chunk 1, so it must escalate here, before ever reaching dave — and now fails - // loud rather than skipping gracefully. - attr.PageToken = pagination.Token{Size: 2, Token: syncRes.NextPageToken} - resources, _, err = b.List(ctx, nil, attr) - if err == nil { - t.Fatal("chunk 2: expected the threshold-crossing failure to fail loud, got nil error") + t.Fatalf("List: %v", err) } if len(resources) != 0 { - t.Errorf("chunk 2: expected zero resources on a hard failure, got %d: %+v", len(resources), resources) + t.Fatalf("expected the empty-Href queue to be skipped, got %d resources: %+v", len(resources), resources) } } -// TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDoubleCountFailures is a -// regression test: a resumed sync can re-issue a List() call with the same PageToken as -// a chunk already applied. Replaying a below-threshold chunk must not double-count its -// failures toward ConsecutiveUnavailableFailures and falsely escalate. -func TestClmWorkflowQueueBuilder_List_ReplayedChunkDoesNotDoubleCountFailures(t *testing.T) { - srv, c := clmtest.NewServer(t) - srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) - srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) +// TestClmWorkflowQueueBuilder_Grants_IsNoop pins that this builder's Grants() is a +// deliberate no-op: membership grants moved to clmMemberBuilder.Grants() (clm_members.go) +// since CLM only exposes workflow-queue membership per member. A future reader might +// otherwise expect grants to still come from here. +func TestClmWorkflowQueueBuilder_Grants_IsNoop(t *testing.T) { + _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - attr := rs.SyncOpAttrs{Session: newFakeSessionStore(), PageToken: pagination.Token{Size: 2, Token: ""}} - _, syncRes, err := b.List(ctx, nil, attr) + queueResource, err := rs.NewGroupResource("Onboarding", clmWorkflowQueueResourceType, "queue-onboarding", nil) if err != nil { - t.Fatalf("chunk 1: %v", err) - } - if syncRes.NextPageToken == "" { - t.Fatal("chunk 1: expected a non-empty NextPageToken — 2 failures is below the threshold") + t.Fatalf("NewGroupResource: %v", err) } - // Replay chunk 1 with the exact same input token. - resources, syncRes, err := b.List(ctx, nil, attr) - if err != nil { - t.Fatalf("replayed chunk 1: %v", err) - } - if len(resources) != 0 { - t.Errorf("replayed chunk 1: expected zero resources, got %d: %+v", len(resources), resources) - } - if syncRes.NextPageToken == "" { - t.Fatal("replayed chunk 1: expected a non-empty NextPageToken — must not have escalated from double-counted failures") + grants, res, err := b.Grants(ctx, queueResource, rs.SyncOpAttrs{}) + if err != nil || grants != nil || res != nil { + t.Errorf("expected Grants to return (nil, nil, nil), got (%v, %v, %v)", grants, res, err) } } -// TestClmWorkflowQueueBuilder_List_ReplayedRollbackByMoreThanOneChunk is a regression -// test: a resume can roll back more than one chunk, replaying an input token that isn't -// the immediately preceding one. Replaying chunk 1's token after chunk 2 already applied -// must resume from the current frontier (chunk 2's own NextPageToken), not re-run either -// chunk and double-count their failures. -func TestClmWorkflowQueueBuilder_List_ReplayedRollbackByMoreThanOneChunk(t *testing.T) { - srv, c := clmtest.NewServer(t) - srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) - srv.ForceMemberWorkflowQueuesStatus("member-bob", 403) +// TestClmWorkflowQueueBuilder_StaticEntitlements confirms every CLM workflow queue +// shares the single "member" entitlement, grantable only to clm_member. +func TestClmWorkflowQueueBuilder_StaticEntitlements(t *testing.T) { + _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - attr := rs.SyncOpAttrs{Session: newFakeSessionStore(), PageToken: pagination.Token{Size: 1, Token: ""}} - _, syncRes, err := b.List(ctx, nil, attr) // chunk 1: alice fails (count=1) + ents, res, err := b.StaticEntitlements(ctx, rs.SyncOpAttrs{}) if err != nil { - t.Fatalf("chunk 1: %v", err) - } - attr.PageToken.Token = syncRes.NextPageToken - - _, syncRes, err = b.List(ctx, nil, attr) // chunk 2: bob fails (count=2) - if err != nil { - t.Fatalf("chunk 2: %v", err) + t.Fatalf("StaticEntitlements: %v", err) } - if syncRes.NextPageToken == "" { - t.Fatal("chunk 2: expected a non-empty NextPageToken — 2 failures is below the threshold") + if res != nil { + t.Errorf("expected nil SyncOpResults, got %+v", res) } - frontier := syncRes.NextPageToken - - // Replay chunk 1's original token — two chunks stale, not just one. - attr.PageToken.Token = "" - resources, syncRes, err := b.List(ctx, nil, attr) - if err != nil { - t.Fatalf("rolled-back replay: %v", err) - } - if len(resources) != 0 { - t.Errorf("rolled-back replay: expected zero resources, got %d: %+v", len(resources), resources) + if len(ents) != 1 || ents[0].Slug != entitlementClmWorkflowQueueMember { + t.Fatalf("expected a single %q entitlement, got %+v", entitlementClmWorkflowQueueMember, ents) } - if syncRes.NextPageToken != frontier { - t.Errorf("rolled-back replay: expected to resume from the frontier %q, got %q", frontier, syncRes.NextPageToken) + if len(ents[0].GrantableTo) != 1 || ents[0].GrantableTo[0].Id != clmMemberResourceType.Id { + t.Errorf("expected the entitlement to be grantable only to clm_member, got %+v", ents[0].GrantableTo) } } -// TestClmWorkflowQueueBuilder_List_ReplayOfSinglePageScanIsDetected is a regression -// test: a scan that completes in a single page has NextExpectedInputToken == "" both -// before the first call and after the scan finishes, so token comparison alone can't -// tell a replay of that one chunk from a genuine first call. Without ScanComplete, a -// replay here would re-process member-alice's tolerated failure AFTER -// SucceededAtLeastOnce is already true, hitting the "fails loud" branch instead of the -// first-pass escalation path — turning a harmless replay into a hard sync failure. -func TestClmWorkflowQueueBuilder_List_ReplayOfSinglePageScanIsDetected(t *testing.T) { - srv, c := clmtest.NewServer(t) - srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) +// TestClmWorkflowQueueBuilder_Entitlements_IsNoop confirms Entitlements() returns nil — +// the SDK doesn't call it once StaticEntitlementSyncerV2 is implemented, but this pins +// the contract directly the same way clm_groups_test.go/clm_folders_test.go do for their +// own StaticEntitlementSyncerV2 builders. +func TestClmWorkflowQueueBuilder_Entitlements_IsNoop(t *testing.T) { + _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() - attr := rs.SyncOpAttrs{Session: newFakeSessionStore()} - _, syncRes, err := b.List(ctx, nil, attr) + queueResource, err := rs.NewGroupResource("Onboarding", clmWorkflowQueueResourceType, "queue-onboarding", nil) if err != nil { - t.Fatalf("first call: %v", err) - } - if syncRes.NextPageToken != "" { - t.Fatal("first call: expected the scan to complete in a single page") + t.Fatalf("NewGroupResource: %v", err) } - resources, _, err := b.List(ctx, nil, attr) - if err != nil { - t.Fatalf("replay of the single-page scan: %v", err) - } - if len(resources) != 2 { - t.Errorf("replay: expected the same 2 queues as the first call, got %d: %+v", len(resources), resources) + ents, res, err := b.Entitlements(ctx, queueResource, rs.SyncOpAttrs{}) + if err != nil || ents != nil || res != nil { + t.Errorf("expected Entitlements to return (nil, nil, nil), got (%v, %v, %v)", ents, res, err) } } diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index d7d25551..e5baa16f 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -11,6 +11,8 @@ import ( "github.com/conductorone/baton-docusign/pkg/client/clmtest" cfg "github.com/conductorone/baton-docusign/pkg/config" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/cli" "golang.org/x/oauth2" "gopkg.in/yaml.v3" @@ -36,6 +38,28 @@ var alwaysRegisteredTypeIDs = []string{ "clm_workflow_queue", } +// TestClmMemberResourceType_HasWorkflowQueueChildResourceType pins the annotation that +// drives clm_workflow_queue's whole redesign: clm_workflow_queue is modeled as +// clmMemberResourceType's ChildResourceType (see resource_types.go's doc and +// clm_workflow_queues.go) rather than syncing independently, so the SDK's child-resource +// scheduling can call clmWorkflowQueueBuilder.List() once per synced clm_member. A +// missing or misconfigured annotation here would silently stop that scheduling from ever +// firing, with no compile-time signal. +func TestClmMemberResourceType_HasWorkflowQueueChildResourceType(t *testing.T) { + annos := annotations.Annotations(clmMemberResourceType.Annotations) + var child v2.ChildResourceType + ok, err := annos.Pick(&child) + if err != nil { + t.Fatalf("Pick(ChildResourceType): %v", err) + } + if !ok { + t.Fatal("expected clmMemberResourceType to carry a ChildResourceType annotation") + } + if child.ResourceTypeId != clmWorkflowQueueResourceType.Id { + t.Errorf("expected ChildResourceType.ResourceTypeId %q, got %q", clmWorkflowQueueResourceType.Id, child.ResourceTypeId) + } +} + func registeredTypeIDs(ctx context.Context, d *Connector) map[string]bool { syncers := d.ResourceSyncers(ctx) got := make(map[string]bool, len(syncers)) diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go index 479573dc..07113980 100644 --- a/pkg/connector/helper.go +++ b/pkg/connector/helper.go @@ -41,52 +41,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. -// -// All 6 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. Four of them -// (clm_member, clm_group, clm_permission_set, clm_folder) tolerate this error on the -// first page of List() (see call sites), which is what makes unconditional -// registration safe for those four: the sync skips that one resource type gracefully -// instead of failing outright. clm_role makes no API call at all in List() (a -// hardcoded set, see clm_roles.go), so it can't encounter this error either way. -// clm_workflow_queue is the deliberate exception that does encounter it but does not -// tolerate it — see clm_workflow_queues.go's List() doc for why it fails loud instead. -// -// 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 discovery still runs through. -// - 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 @@ -214,3 +168,11 @@ func clmSampleHrefsFrom[T any](principal *v2.Resource, entries []T, hrefOf func( } return sampleHrefs } + +// logSkippedClmWorkflowQueueWithEmptyHref Debug-logs a workflow-queue entry whose Href +// does not resolve to a usable native ID — the queue is skipped rather than synced/granted +// with an empty resource ID. +func logSkippedClmWorkflowQueueWithEmptyHref(ctx context.Context, memberID, queueHref string) { + ctxzap.Extract(ctx).Debug("baton-docusign: skipping CLM workflow queue with an empty Href-derived ID", + zap.String("member_id", memberID), zap.String("queue_href", queueHref)) +} diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index e00f7443..16a6aaa6 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -51,11 +51,17 @@ var ( // clmMemberResourceType is CLM's own principal object. Deliberately NOT reusing // userResourceType's id ("user") — the CLM Members API is a distinct upstream // object, and 1:1 identity with the eSignature user could not be confirmed. + // + // Carries ChildResourceType(clm_workflow_queue): workflow-queue membership is + // discovered per-member (GetMemberWorkflowQueues), not via any list-all endpoint, + // so clm_workflow_queue is modeled as this type's child — see clm_workflow_queues.go + // and parseIntoClmMemberResource in clm_members.go for how the annotation is stamped + // on each synced member instance. clmMemberResourceType = &v2.ResourceType{ Id: "clm_member", DisplayName: "CLM Member", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, - Annotations: annotations.New(&v2.OptInRequired{}), + Annotations: annotations.New(&v2.OptInRequired{}, &v2.ChildResourceType{ResourceTypeId: clmWorkflowQueueResourceType.Id}), } // clmRoleResourceType represents the 5 fixed CLM account-level Member.Role values. @@ -105,7 +111,9 @@ var ( // doesn't sync) and for the unconfirmed "is this the same thing as the CLM admin // console's 'Task Groups'?" naming question. Uses StaticEntitlementSyncerV2 for the // same reason clm_group does: every queue shares the same single "member" - // entitlement. + // entitlement. Synced as clmMemberResourceType's ChildResourceType (see that type's + // doc) rather than independently — CLM's API only exposes queue membership per + // member (GetMemberWorkflowQueues), not a list-all-queues endpoint. clmWorkflowQueueResourceType = &v2.ResourceType{ Id: "clm_workflow_queue", DisplayName: "CLM Workflow Queue", From a73fbaed1074b852f323cc4fa0ec685070c12cb5 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 26 Aug 2026 18:13:15 -0300 Subject: [PATCH 44/50] fix: gate clm_member grants on workflow queue opt-in Use dynamic ResourceType() annotations (SkipEntitlementsAndGrants when clm_workflow_queue is filtered out) matching the user/permission_profile pattern, drop unstable ParentResourceId on shared queues, update capabilities metadata and docs for the clm_member dependency. Co-authored-by: Cursor --- baton_capabilities.json | 7 +++ docs/connector.mdx | 2 +- docs/doc-info.md | 2 +- pkg/connector/clm_members.go | 29 +++++++++-- pkg/connector/clm_members_test.go | 61 ++++++++++++++++++++--- pkg/connector/clm_workflow_queues.go | 17 +++---- pkg/connector/clm_workflow_queues_test.go | 7 ++- pkg/connector/connector.go | 16 ++++-- pkg/connector/connector_test.go | 4 +- 9 files changed, 112 insertions(+), 33 deletions(-) diff --git a/baton_capabilities.json b/baton_capabilities.json index 4f341d5c..dd765f0b 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -54,6 +54,13 @@ "annotations": [ { "@type": "type.googleapis.com/c1.connector.v2.OptInRequired" + }, + { + "@type": "type.googleapis.com/c1.connector.v2.ChildResourceType", + "resourceTypeId": "clm_workflow_queue" + }, + { + "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements" } ] }, diff --git a/docs/connector.mdx b/docs/connector.mdx index 7acc75dc..5959b622 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -29,7 +29,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. 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 and workflow queues sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign a permission set, and no endpoint to grant or revoke workflow queue membership (only work-item assign/unassign, which isn't synced here). +**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 and workflow queues sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign a permission set, and no endpoint to grant or revoke workflow queue membership (only work-item assign/unassign, which isn't synced here). CLM workflow queues also require CLM members to be enabled — queues are discovered per member, not via a standalone list endpoint. 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/docs/doc-info.md b/docs/doc-info.md index 00fe72ee..1f2ff968 100644 --- a/docs/doc-info.md +++ b/docs/doc-info.md @@ -50,7 +50,7 @@ - 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. - CLM members are synced as their own resource type rather than merged into the existing eSignature "Users" resource, since the two could not be confirmed to represent the same identity. - - CLM workflow queues also sync for visibility only — the API supports work-item assign/unassign, not queue-membership grant/revoke. There's no list-all endpoint for queues, so they're modeled as a child resource of CLM members: the SDK calls `clmWorkflowQueueBuilder.List()` once per synced member automatically, and membership grants are emitted from `clmMemberBuilder.Grants()`. Each member therefore triggers two `GetMemberWorkflowQueues` calls per sync (child-resource List + Grants) — an accepted tradeoff of this design. + - CLM workflow queues also sync for visibility only — the API supports work-item assign/unassign, not queue-membership grant/revoke. There's no list-all endpoint for queues, so they're modeled as a child resource of CLM members: the SDK calls `clmWorkflowQueueBuilder.List()` once per synced member automatically, and membership grants are emitted from `clmMemberBuilder.Grants()`. Each member therefore triggers two `GetMemberWorkflowQueues` calls per sync (child-resource List + Grants) when workflow queues are enabled — an accepted tradeoff of this design. Workflow queues require CLM members to be enabled in the sync configuration. --- diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 91f7c670..09292282 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -6,8 +6,10 @@ import ( "github.com/conductorone/baton-docusign/pkg/client" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "google.golang.org/protobuf/proto" ) // clmMemberBuilder syncs CLM Members — CLM's own principal object. Synced as its own @@ -17,10 +19,28 @@ import ( type clmMemberBuilder struct { resourceType *v2.ResourceType client *client.Client + // includeWorkflowQueues reports whether clm_workflow_queue is included in the + // customer's sync filter. When false, ResourceType() attaches + // SkipEntitlementsAndGrants so the SDK never calls Grants() — which would + // otherwise invoke GetMemberWorkflowQueues even though the customer didn't + // opt into workflow queues (mirrors userBuilder + skipPermissionProfileResourceType). + includeWorkflowQueues bool } +// ResourceType returns the Baton resource type handled by this builder, +// annotated to tell the SDK's sync engine whether it can skip calling +// Grants() for clm_member resources. clmMemberResourceType is a package-level +// var shared with other code, so it's cloned before its annotations are mutated. func (b *clmMemberBuilder) ResourceType(_ context.Context) *v2.ResourceType { - return clmMemberResourceType + rt := proto.Clone(clmMemberResourceType).(*v2.ResourceType) + annos := annotations.Annotations(rt.Annotations) + if b.includeWorkflowQueues { + annos.Update(&v2.SkipEntitlements{}) + } else { + annos.Update(&v2.SkipEntitlementsAndGrants{}) + } + rt.Annotations = annos + return rt } func (b *clmMemberBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { @@ -96,10 +116,11 @@ func (b *clmMemberBuilder) Grants(ctx context.Context, resource *v2.Resource, _ return grants, &rs.SyncOpResults{Annotations: annos}, nil } -func newClmMemberBuilder(c *client.Client) *clmMemberBuilder { +func newClmMemberBuilder(c *client.Client, includeWorkflowQueues bool) *clmMemberBuilder { return &clmMemberBuilder{ - resourceType: clmMemberResourceType, - client: c, + resourceType: clmMemberResourceType, + client: c, + includeWorkflowQueues: includeWorkflowQueues, } } diff --git a/pkg/connector/clm_members_test.go b/pkg/connector/clm_members_test.go index 0cab1625..cb74ade8 100644 --- a/pkg/connector/clm_members_test.go +++ b/pkg/connector/clm_members_test.go @@ -19,7 +19,7 @@ func TestClmMemberBuilder_List_Pagination(t *testing.T) { // member-frank has 105 synthetic groups but is just one member row among 6 — this // confirms ListMembers' own pagination (not GetMemberGroups') is threaded correctly. _, c := clmtest.NewServer(t) - b := newClmMemberBuilder(c) + b := newClmMemberBuilder(c, true) ctx := context.Background() var all []*v2.Resource @@ -49,7 +49,7 @@ func TestClmMemberBuilder_List_Pagination(t *testing.T) { // clm_workflow_queue.List() call triggered even though the type declaration looks correct. func TestClmMemberBuilder_List_StampsWorkflowQueueChildResourceType(t *testing.T) { _, c := clmtest.NewServer(t) - b := newClmMemberBuilder(c) + b := newClmMemberBuilder(c, true) ctx := context.Background() resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) @@ -84,7 +84,7 @@ func TestClmMemberBuilder_List_FailsWhenClmUnavailable(t *testing.T) { // 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) + b := newClmMemberBuilder(badClient, true) ctx := context.Background() resources, _, err := b.List(ctx, nil, rs.SyncOpAttrs{PageToken: pagination.Token{Size: 10}}) @@ -99,7 +99,7 @@ func TestClmMemberBuilder_List_FailsWhenClmUnavailable(t *testing.T) { func TestClmMemberBuilder_Entitlements_IsNoop(t *testing.T) { // clm_member is a pure principal: it holds no entitlements of its own. _, c := clmtest.NewServer(t) - b := newClmMemberBuilder(c) + b := newClmMemberBuilder(c, true) ctx := context.Background() memberResource, err := rs.NewResource("Alice", clmMemberResourceType, "member-alice") @@ -120,7 +120,7 @@ func TestClmMemberBuilder_Entitlements_IsNoop(t *testing.T) { // entitlement-resource side and the member as the principal. func TestClmMemberBuilder_Grants_EmitsWorkflowQueueMembership(t *testing.T) { _, c := clmtest.NewServer(t) - b := newClmMemberBuilder(c) + b := newClmMemberBuilder(c, true) ctx := context.Background() memberResource, err := rs.NewResource("Bob", clmMemberResourceType, "member-bob") @@ -165,7 +165,7 @@ func TestClmMemberBuilder_Grants_EmitsWorkflowQueueMembership(t *testing.T) { // rather than an error. func TestClmMemberBuilder_Grants_NoQueues(t *testing.T) { _, c := clmtest.NewServer(t) - b := newClmMemberBuilder(c) + b := newClmMemberBuilder(c, true) ctx := context.Background() memberResource, err := rs.NewResource("Carol", clmMemberResourceType, "member-carol") @@ -188,7 +188,7 @@ func TestClmMemberBuilder_Grants_NoQueues(t *testing.T) { func TestClmMemberBuilder_Grants_PropagatesClientError(t *testing.T) { srv, c := clmtest.NewServer(t) srv.ForceMemberWorkflowQueuesStatus("member-alice", 403) - b := newClmMemberBuilder(c) + b := newClmMemberBuilder(c, true) ctx := context.Background() memberResource, err := rs.NewResource("Alice", clmMemberResourceType, "member-alice") @@ -218,7 +218,7 @@ func TestClmMemberBuilder_Grants_PropagatesClientError(t *testing.T) { func TestClmMemberBuilder_Grants_SkipsQueueWithEmptyHref(t *testing.T) { srv, c := clmtest.NewServer(t) srv.AddMemberWorkflowQueueWithEmptyHref("member-no-href-queue") - b := newClmMemberBuilder(c) + b := newClmMemberBuilder(c, true) ctx := context.Background() memberResource, err := rs.NewResource("No Href Queue Member", clmMemberResourceType, "member-no-href-queue") @@ -234,3 +234,48 @@ func TestClmMemberBuilder_Grants_SkipsQueueWithEmptyHref(t *testing.T) { t.Fatalf("expected the empty-Href queue to be skipped, got %d grants: %+v", len(grants), grants) } } + +// TestClmMemberBuilder_ResourceType_SyncWorkflowQueuesEnabled verifies that when +// clm_workflow_queue is included in the sync, ResourceType() attaches SkipEntitlements +// (Entitlements() is a no-op so it's safe to skip) but NOT SkipEntitlementsAndGrants +// (Grants() must still run to emit workflow-queue membership grants). +func TestClmMemberBuilder_ResourceType_SyncWorkflowQueuesEnabled(t *testing.T) { + ctx := context.Background() + b := newClmMemberBuilder(nil, true) + + rt := b.ResourceType(ctx) + + rtAnnos := annotations.Annotations(rt.Annotations) + if !rtAnnos.Contains(&v2.SkipEntitlements{}) { + t.Errorf("expected ResourceType() annotations to contain SkipEntitlements when includeWorkflowQueues=true") + } + if rtAnnos.Contains(&v2.SkipEntitlementsAndGrants{}) { + t.Errorf("expected ResourceType() annotations NOT to contain SkipEntitlementsAndGrants when includeWorkflowQueues=true") + } +} + +// TestClmMemberBuilder_ResourceType_SyncWorkflowQueuesDisabled verifies that when the +// customer's sync filter excludes clm_workflow_queue, ResourceType() attaches +// SkipEntitlementsAndGrants so the SDK never calls Grants() — which would otherwise +// invoke GetMemberWorkflowQueues even though the customer didn't opt into workflow queues. +// Also verifies that building this annotated ResourceType does not mutate the shared +// package-level clmMemberResourceType var. +func TestClmMemberBuilder_ResourceType_SyncWorkflowQueuesDisabled(t *testing.T) { + ctx := context.Background() + b := newClmMemberBuilder(nil, false) + + rt := b.ResourceType(ctx) + + rtAnnos := annotations.Annotations(rt.Annotations) + if !rtAnnos.Contains(&v2.SkipEntitlementsAndGrants{}) { + t.Errorf("expected ResourceType() annotations to contain SkipEntitlementsAndGrants when includeWorkflowQueues=false") + } + if rtAnnos.Contains(&v2.SkipEntitlements{}) { + t.Errorf("expected ResourceType() annotations NOT to contain a bare SkipEntitlements when includeWorkflowQueues=false") + } + + baseAnnos := annotations.Annotations(clmMemberResourceType.Annotations) + if baseAnnos.Contains(&v2.SkipEntitlementsAndGrants{}) { + t.Error("clmMemberResourceType should not be mutated by ResourceType()") + } +} diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index dcbdebb7..a932e8f2 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -22,8 +22,9 @@ const entitlementClmWorkflowQueueMember = "member" // list-all-queues endpoint — so List() here is driven per-member by the SDK rather than // paginating independently. A queue with several members is discovered once per member // whose scan reaches it; the SDK upserts resources by (ResourceType, Resource) regardless -// of which parent scan produced them, so the repeat discovery is a no-op re-upsert, not -// something this builder needs to dedup itself (pkg/sync/syncer.go's syncResources). +// of which parent scan produced them, so repeat discovery is an idempotent identity +// re-upsert — parent is not modeled on the resource because a shared queue has no +// single canonical member parent (see parseIntoClmWorkflowQueueResource). // Membership grants are emitted from clmMemberBuilder.Grants() instead of here — the // query is per-member either way, and emitting from the principal side avoids needing // durable state between the resources and grants sync phases (contrast the previous @@ -106,17 +107,15 @@ func newClmWorkflowQueueBuilder(c *client.Client) *clmWorkflowQueueBuilder { } } -// parseIntoClmWorkflowQueueResource maps a client.ClmWorkflowQueue to a Baton v2.Resource, -// scoped under the member whose scan discovered it. A queue can belong to several -// members, each independently discovering and re-upserting it (see this builder's doc -// comment) — parentResourceID here reflects whichever member's List() call produced this -// particular instance, not a claim that this is the queue's only member. -func parseIntoClmWorkflowQueueResource(q *client.ClmWorkflowQueue, parentResourceID *v2.ResourceId) (*v2.Resource, error) { +// parseIntoClmWorkflowQueueResource maps a client.ClmWorkflowQueue to a Baton v2.Resource. +// parentResourceID is the member whose List() call discovered this queue — used only for +// logging context in callers; it is not stamped as ParentResourceId because a queue can +// belong to several members and the parent would be last-writer-wins across syncs. +func parseIntoClmWorkflowQueueResource(q *client.ClmWorkflowQueue, _ *v2.ResourceId) (*v2.Resource, error) { return rs.NewGroupResource( q.Name, clmWorkflowQueueResourceType, clmIDFromHref(q.Href), nil, - rs.WithParentResourceID(parentResourceID), ) } diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index 79ee6ddd..f280fe51 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -17,8 +17,7 @@ import ( // ChildResourceType (see resource_types.go), so List() is driven per-member by the SDK's // own child-resource scheduling instead of an independent, hand-rolled member scan. // member-bob (clmtest/seed.go) belongs to both seeded queues (Onboarding, Escalations) — -// this confirms List() returns exactly that member's queues, each scoped under the -// parent member resource via ParentResourceId. +// this confirms List() returns exactly that member's queues by stable queue ID. func TestClmWorkflowQueueBuilder_List_ReturnsMemberWorkflowQueues(t *testing.T) { _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) @@ -42,8 +41,8 @@ func TestClmWorkflowQueueBuilder_List_ReturnsMemberWorkflowQueues(t *testing.T) if r.Id.ResourceType != clmWorkflowQueueResourceType.Id { t.Errorf("expected resource type %q, got %q", clmWorkflowQueueResourceType.Id, r.Id.ResourceType) } - if r.ParentResourceId == nil || r.ParentResourceId.ResourceType != parentResourceID.ResourceType || r.ParentResourceId.Resource != parentResourceID.Resource { - t.Errorf("expected ParentResourceId %+v, got %+v", parentResourceID, r.ParentResourceId) + if r.ParentResourceId != nil { + t.Errorf("expected no ParentResourceId (shared queues have no canonical parent), got %+v", r.ParentResourceId) } gotIDs[r.Id.Resource] = true } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index e518964b..47b5fa9c 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -35,6 +35,10 @@ type Connector struct { // skipPermissionProfileResourceType reports whether permission_profile is // excluded from the sync filter. skipPermissionProfileResourceType bool + // includeWorkflowQueues reports whether clm_workflow_queue is included in the + // customer's sync filter. Gates clmMemberBuilder.ResourceType()'s Grants skip + // annotation — see clm_members.go. + includeWorkflowQueues bool } // Configure handles the OAuth2 authorization flow to obtain a refresh token. @@ -80,7 +84,7 @@ func (d *Connector) ResourceSyncers(_ context.Context) []connectorbuilder.Resour newUserBuilder(d.client, d.skipPermissionProfileResourceType), newGroupBuilder(d.client), newPermissionProfilesBuilder(d.client), - newClmMemberBuilder(d.client), + newClmMemberBuilder(d.client, d.includeWorkflowQueues), newClmRoleBuilder(), newClmGroupBuilder(d.client), newClmPermissionSetBuilder(d.client), @@ -166,7 +170,7 @@ func (d *Connector) Validate(ctx context.Context) (annotations.Annotations, erro func NewWithRefreshToken( ctx context.Context, isDemo bool, clientId, clientSecret, redirectURI, refreshToken, accountId string, includeSigningGroups, includeClm bool, clmBaseURLOverride, baseURLOverride string, - skipPermissionProfileResourceType bool, + skipPermissionProfileResourceType, includeWorkflowQueues bool, ) (*Connector, error) { l := ctxzap.Extract(ctx) @@ -184,6 +188,7 @@ func NewWithRefreshToken( includeSigningGroups: includeSigningGroups, includeClm: includeClm, skipPermissionProfileResourceType: skipPermissionProfileResourceType, + includeWorkflowQueues: includeWorkflowQueues, }, nil } @@ -194,7 +199,7 @@ func NewWithRefreshToken( func NewWithTokenSource( ctx context.Context, isDemo bool, tokenSource oauth2.TokenSource, accountId string, includeSigningGroups, includeClm bool, clmBaseURLOverride string, - skipPermissionProfileResourceType bool, + skipPermissionProfileResourceType, includeWorkflowQueues bool, ) (*Connector, error) { docusignClient := client.NewClient(ctx, isDemo, tokenSource, accountId, clmBaseURLOverride) @@ -203,6 +208,7 @@ func NewWithTokenSource( includeSigningGroups: includeSigningGroups, includeClm: includeClm, skipPermissionProfileResourceType: skipPermissionProfileResourceType, + includeWorkflowQueues: includeWorkflowQueues, }, nil } @@ -225,12 +231,13 @@ func New(ctx context.Context, docusignCfg *cfg.Docusign, opts *cli.ConnectorOpts // nil opts means no filter, so nothing is skipped. skipPermissionProfileResourceType := opts != nil && !opts.WillSyncResourceType(PermissionProfileResourceTypeID) + includeWorkflowQueues := opts == nil || opts.WillSyncResourceType(clmWorkflowQueueResourceType.Id) if opts.TokenSource != nil { cbWithTokenSource, err := NewWithTokenSource( ctx, isDemo, opts.TokenSource, docusignCfg.AccountId, docusignCfg.IncludeSigningGroups, includeClm, docusignCfg.ClmBaseUrl, - skipPermissionProfileResourceType, + skipPermissionProfileResourceType, includeWorkflowQueues, ) if err != nil { l.Error("error creating connector with token source", zap.Error(err)) @@ -264,6 +271,7 @@ func New(ctx context.Context, docusignCfg *cfg.Docusign, opts *cli.ConnectorOpts docusignCfg.ClmBaseUrl, docusignCfg.BaseUrl, skipPermissionProfileResourceType, + includeWorkflowQueues, ) if err != nil { l.Error("error creating connector", zap.Error(err)) diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index e5baa16f..01e779d7 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -176,7 +176,7 @@ func TestNewWithRefreshToken_StoresIncludeClm(t *testing.T) { 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, + "https://clm.example.com", "https://api.example.com", false, false, ) if err != nil { t.Fatalf("includeClm=%v: NewWithRefreshToken: %v", includeClm, err) @@ -198,7 +198,7 @@ func TestNewWithTokenSource_StoresIncludeClm(t *testing.T) { for _, includeClm := range []bool{true, false} { cb, err := NewWithTokenSource( - ctx, false, tokenSource, "account-1", false, includeClm, "https://clm.example.com", false, + ctx, false, tokenSource, "account-1", false, includeClm, "https://clm.example.com", false, false, ) if err != nil { t.Fatalf("includeClm=%v: NewWithTokenSource: %v", includeClm, err) From 60f713fde3138f66dcf8e1dd37c6101d9c44c0af Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 26 Aug 2026 18:24:01 -0300 Subject: [PATCH 45/50] refactor: drop unused parent param from parseIntoClmWorkflowQueueResource The caller already uses parentResourceID for logging and API calls; ParentResourceId is no longer stamped on the resource. Co-authored-by: Cursor --- pkg/connector/clm_workflow_queues.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index a932e8f2..603c6bbb 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -65,7 +65,7 @@ func (b *clmWorkflowQueueBuilder) List(ctx context.Context, parentResourceID *v2 logSkippedClmWorkflowQueueWithEmptyHref(ctx, parentResourceID.Resource, q.Href) continue } - queueResource, err := parseIntoClmWorkflowQueueResource(&q, parentResourceID) + queueResource, err := parseIntoClmWorkflowQueueResource(&q) if err != nil { return nil, nil, err } @@ -108,10 +108,10 @@ func newClmWorkflowQueueBuilder(c *client.Client) *clmWorkflowQueueBuilder { } // parseIntoClmWorkflowQueueResource maps a client.ClmWorkflowQueue to a Baton v2.Resource. -// parentResourceID is the member whose List() call discovered this queue — used only for -// logging context in callers; it is not stamped as ParentResourceId because a queue can -// belong to several members and the parent would be last-writer-wins across syncs. -func parseIntoClmWorkflowQueueResource(q *client.ClmWorkflowQueue, _ *v2.ResourceId) (*v2.Resource, error) { +// No ParentResourceId is stamped: a queue can belong to several members, so the +// discovering member's ID would be last-writer-wins across syncs rather than a stable +// canonical parent. +func parseIntoClmWorkflowQueueResource(q *client.ClmWorkflowQueue) (*v2.Resource, error) { return rs.NewGroupResource( q.Name, clmWorkflowQueueResourceType, From 78f68084d2fd790c61eb0b3ca05014ebc335366e Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 26 Aug 2026 18:32:21 -0300 Subject: [PATCH 46/50] test: pin includeClm and includeWorkflowQueues derivation in New() Add clm_workflow_queue to TestNew_IncludeClmDerivation and assert includeWorkflowQueues for each sync filter case. Co-authored-by: Cursor --- pkg/connector/connector_test.go | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 01e779d7..1f6ff1a9 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -279,17 +279,19 @@ func TestNew_IncludeClmDerivation(t *testing.T) { tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok"}) tests := []struct { - name string - syncResourceTypeIDs []string - wantIncludeClm bool + name string + syncResourceTypeIDs []string + wantIncludeClm bool + wantIncludeWorkflowQueues bool }{ - {"no filter (opts.SyncResourceTypeIDs empty): syncs everything, including CLM", nil, true}, - {"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}, - {"clm_permission_set present", []string{"user", clmPermissionSetResourceType.Id}, true}, - {"clm_folder present", []string{"user", clmFolderResourceType.Id}, true}, + {"no filter (opts.SyncResourceTypeIDs empty): syncs everything, including CLM", nil, true, true}, + {"CI's actual allowlist: no clm_* type present", nonClmAllowlist(), false, false}, + {"clm_member present", []string{"user", clmMemberResourceType.Id}, true, false}, + {"clm_role present", []string{"user", clmRoleResourceType.Id}, true, false}, + {"clm_group present", []string{"user", clmGroupResourceType.Id}, true, false}, + {"clm_permission_set present", []string{"user", clmPermissionSetResourceType.Id}, true, false}, + {"clm_folder present", []string{"user", clmFolderResourceType.Id}, true, false}, + {"clm_workflow_queue present", []string{"user", clmWorkflowQueueResourceType.Id}, true, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -305,6 +307,9 @@ func TestNew_IncludeClmDerivation(t *testing.T) { if cb.includeClm != tt.wantIncludeClm { t.Errorf("SyncResourceTypeIDs=%v: expected includeClm=%v, got %v", tt.syncResourceTypeIDs, tt.wantIncludeClm, cb.includeClm) } + if cb.includeWorkflowQueues != tt.wantIncludeWorkflowQueues { + t.Errorf("SyncResourceTypeIDs=%v: expected includeWorkflowQueues=%v, got %v", tt.syncResourceTypeIDs, tt.wantIncludeWorkflowQueues, cb.includeWorkflowQueues) + } }) } } From 9012d60b400fa8d95b4f6f856a108d9f1c6372d8 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 26 Aug 2026 18:50:39 -0300 Subject: [PATCH 47/50] fix: no-op unparented List for child-only clm_workflow_queue The SDK always calls List once without a parent per registered type; return empty instead of error. gofmt connector_test.go struct alignment. Co-authored-by: Cursor --- pkg/connector/clm_workflow_queues.go | 10 +++++----- pkg/connector/clm_workflow_queues_test.go | 20 +++++++++----------- pkg/connector/connector_test.go | 6 +++--- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/pkg/connector/clm_workflow_queues.go b/pkg/connector/clm_workflow_queues.go index 603c6bbb..099835b6 100644 --- a/pkg/connector/clm_workflow_queues.go +++ b/pkg/connector/clm_workflow_queues.go @@ -47,11 +47,11 @@ func (b *clmWorkflowQueueBuilder) ResourceType(_ context.Context) *v2.ResourceTy // completion internally. func (b *clmWorkflowQueueBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { if parentResourceID == nil { - // Only reachable if this type's registration ever diverges from the - // ChildResourceType annotation clm_member resources carry — the SDK derives - // every List() call for this type from that annotation, so this should never - // actually happen; failing loud beats silently returning nothing. - return nil, nil, fmt.Errorf("baton-docusign: clm_workflow_queue.List called without a parent CLM member") + // The SDK always issues one unparented top-level List() per registered resource + // type (syncer.go SyncResources) in addition to parented child-resource calls. + // clm_workflow_queue is child-only — queues are discovered per clm_member via + // ChildResourceType scheduling — so the unparented call is a no-op. + return nil, nil, nil } queues, annos, err := b.client.GetMemberWorkflowQueues(ctx, parentResourceID.Resource) diff --git a/pkg/connector/clm_workflow_queues_test.go b/pkg/connector/clm_workflow_queues_test.go index f280fe51..d452d6fc 100644 --- a/pkg/connector/clm_workflow_queues_test.go +++ b/pkg/connector/clm_workflow_queues_test.go @@ -89,25 +89,23 @@ func TestClmWorkflowQueueBuilder_List_NoQueues(t *testing.T) { } } -// TestClmWorkflowQueueBuilder_List_NilParentResourceID pins List()'s documented guard -// against a nil parent. The SDK should never call List() this way — every call for this -// type is derived from clmMemberResourceType's ChildResourceType annotation -// (resource_types.go) — but this proves the guard fails loud with a clear message -// instead of panicking if that invariant is ever violated. +// TestClmWorkflowQueueBuilder_List_NilParentResourceID pins List()'s no-op for the SDK's +// mandatory unparented top-level call (one per registered resource type). Child discovery +// happens via parented calls driven by clmMemberResourceType's ChildResourceType annotation. func TestClmWorkflowQueueBuilder_List_NilParentResourceID(t *testing.T) { _, c := clmtest.NewServer(t) b := newClmWorkflowQueueBuilder(c) ctx := context.Background() resources, res, err := b.List(ctx, nil, rs.SyncOpAttrs{}) - if err == nil { - t.Fatal("expected an error when List is called without a parent CLM member, got nil") + if err != nil { + t.Fatalf("expected nil error for unparented top-level List(), got: %v", err) } - if !strings.Contains(err.Error(), "clm_workflow_queue.List called without a parent CLM member") { - t.Errorf("expected the documented nil-parent error message, got: %v", err) + if resources != nil { + t.Errorf("expected nil resources, got %+v", resources) } - if resources != nil || res != nil { - t.Errorf("expected (nil, nil, err), got (%v, %v, %v)", resources, res, err) + if res != nil { + t.Errorf("expected nil SyncOpResults, got %+v", res) } } diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 1f6ff1a9..61840ddf 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -279,9 +279,9 @@ func TestNew_IncludeClmDerivation(t *testing.T) { tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "tok"}) tests := []struct { - name string - syncResourceTypeIDs []string - wantIncludeClm bool + name string + syncResourceTypeIDs []string + wantIncludeClm bool wantIncludeWorkflowQueues bool }{ {"no filter (opts.SyncResourceTypeIDs empty): syncs everything, including CLM", nil, true, true}, From 98a20f2f4c7cd75482bc95e87b1a3dc1067ab50c Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 26 Aug 2026 19:26:52 -0300 Subject: [PATCH 48/50] fix: address Sergio review nits on workflow queue sync Warn when clm_workflow_queue is opted in without clm_member, drop dead opts nil guards, refresh GetMemberWorkflowQueues doc comment, and document accepted List/Grants phase skew in clmMemberBuilder.Grants(). Co-authored-by: Cursor --- pkg/client/clm_client.go | 4 ++-- pkg/connector/clm_members.go | 5 ++++- pkg/connector/connector.go | 11 ++++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 75e3c98a..8359b067 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -966,8 +966,8 @@ func (c *Client) ListPermissionSets(ctx context.Context, options PageOptions) ([ // GetMemberWorkflowQueues lists the workflow queues a CLM member belongs to. Like // GetMemberGroups, this fetches the member's complete set rather than exposing a page -// token: clm_workflow_queue's List() (pkg/connector/clm_workflow_queues.go) needs every -// queue a member is in to build its member->queues index, not one page at a time. +// token: clm_workflow_queue's List() (pkg/connector/clm_workflow_queues.go) needs the +// complete set for the member it was called with, not one page at a time. // Confirmed read-only intent per the API's documented surface: there is no reverse // lookup (queue to members) and no membership grant/revoke endpoint, only work-item // assign/unassign — which this connector doesn't sync (see clm_workflow_queues.go). diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 09292282..7d57b93f 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -93,7 +93,10 @@ func (b *clmMemberBuilder) Entitlements(_ context.Context, _ *v2.Resource, _ rs. // side that can produce this. clmWorkflowQueueBuilder.List() (this member's child-resource // sync) makes the same GetMemberWorkflowQueues call earlier in the sync to discover queue // resources; this Grants() call repeats it once per member — an accepted 2x tradeoff of -// the ChildResourceType design (no session store between List and Grants phases). +// the ChildResourceType design (no session store between List and Grants phases). That +// also means membership can drift between the resources and grants phases on very long +// syncs (a grant could reference a queue resource not yet stored); we accept that skew +// rather than reintroduce session-store coupling per the ChildResourceType redesign. func (b *clmMemberBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { memberID := resource.Id.Resource diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 47b5fa9c..45c0e7f7 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -229,9 +229,14 @@ func New(ctx context.Context, docusignCfg *cfg.Docusign, opts *cli.ConnectorOpts // client ID is provided (GUI demo group selection). isDemo := docusignCfg.Demo || opts.SelectedAuthMethod == "demo" - // nil opts means no filter, so nothing is skipped. - skipPermissionProfileResourceType := opts != nil && !opts.WillSyncResourceType(PermissionProfileResourceTypeID) - includeWorkflowQueues := opts == nil || opts.WillSyncResourceType(clmWorkflowQueueResourceType.Id) + // skipPermissionProfileResourceType and includeWorkflowQueues follow the sync filter; + // opts is non-nil here (WillSyncResourceType above requires it). + skipPermissionProfileResourceType := !opts.WillSyncResourceType(PermissionProfileResourceTypeID) + includeWorkflowQueues := opts.WillSyncResourceType(clmWorkflowQueueResourceType.Id) + + if includeWorkflowQueues && !opts.WillSyncResourceType(clmMemberResourceType.Id) { + l.Warn("clm_workflow_queue is enabled but clm_member is not — workflow queues are discovered per member, so this sync will produce zero queues and zero grants") + } if opts.TokenSource != nil { cbWithTokenSource, err := NewWithTokenSource( From ab49815a96c208ba48af1cc161c12120a78b8f46 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 26 Aug 2026 19:29:11 -0300 Subject: [PATCH 49/50] fix: use Debug log for clm_workflow_queue without clm_member Sergio's review asked for runtime visibility at debug level, not Warn. Co-authored-by: Cursor --- pkg/connector/connector.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 45c0e7f7..191f1bc1 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -235,7 +235,7 @@ func New(ctx context.Context, docusignCfg *cfg.Docusign, opts *cli.ConnectorOpts includeWorkflowQueues := opts.WillSyncResourceType(clmWorkflowQueueResourceType.Id) if includeWorkflowQueues && !opts.WillSyncResourceType(clmMemberResourceType.Id) { - l.Warn("clm_workflow_queue is enabled but clm_member is not — workflow queues are discovered per member, so this sync will produce zero queues and zero grants") + l.Debug("baton-docusign: clm_workflow_queue is enabled but clm_member is not — workflow queues are discovered per member, so this sync will produce zero queues and zero grants") } if opts.TokenSource != nil { From e0f316e06cb870eafa807afdeedb33413272c3d3 Mon Sep 17 00:00:00 2001 From: Felipe Lucero Date: Wed, 26 Aug 2026 21:13:08 -0300 Subject: [PATCH 50/50] fix: address open review items on clm_member and CLM URLs Wrap List() errors at the connector layer, PathEscape CLM path segments in prepareClmPagedRequest/buildClmClientURL, and document which SkipEntitlements variant baton_capabilities.json reflects. Co-authored-by: Cursor --- .github/workflows/capabilities_and_config.yaml | 4 ++++ pkg/client/clm_client.go | 18 ++++++++++++++++-- pkg/connector/clm_members.go | 8 +++++++- pkg/connector/clm_members_test.go | 3 +++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/capabilities_and_config.yaml b/.github/workflows/capabilities_and_config.yaml index fbd7f9e1..dbbe368d 100644 --- a/.github/workflows/capabilities_and_config.yaml +++ b/.github/workflows/capabilities_and_config.yaml @@ -37,6 +37,10 @@ jobs: run: ./connector config > config_schema.json - name: Run and save capabilities output + # ./connector capabilities calls ResourceType() with no sync-resource-types + # filter, so clm_member's SkipEntitlements (not SkipEntitlementsAndGrants) + # reflects the all-types-enabled default; runtime differs when + # clm_workflow_queue is filtered out — see clmMemberBuilder.ResourceType(). env: BATON_DOCUSIGN_CLIENT_ID: ${{ secrets.CLIENTID }} BATON_DOCUSIGN_CLIENT_SECRET: ${{ secrets.CLIENTSECRET }} diff --git a/pkg/client/clm_client.go b/pkg/client/clm_client.go index 8359b067..b0717e3c 100644 --- a/pkg/client/clm_client.go +++ b/pkg/client/clm_client.go @@ -264,7 +264,21 @@ func (c *Client) buildClmClientURL(path string, params ...any) (*url.URL, error) accountId := c.accountId c.mutex.RUnlock() - return buildURL(clmBaseURI, path, append([]any{accountId}, params...)...) + return buildURL(clmBaseURI, path, clmPathParams(append([]any{accountId}, params...)...)...) +} + +// clmPathParams applies url.PathEscape to every string path-segment before fmt.Sprintf +// inserts it into a CLM endpoint template (accountId, memberID, groupID, folderID, etc.). +func clmPathParams(params ...any) []any { + out := make([]any, len(params)) + for i, p := range params { + if s, ok := p.(string); ok { + out[i] = url.PathEscape(s) + } else { + out[i] = p + } + } + return out } // prepareClmPagedRequest safely prepares a paged CLM request URL. extra supplies any @@ -282,7 +296,7 @@ func (c *Client) prepareClmPagedRequest(endpoint string, options PageOptions, ex return nil, clmRequestedPage{}, fmt.Errorf("baton-docusign: invalid CLM base URL: %w", err) } - formatted := fmt.Sprintf(endpoint, append([]any{accountId}, extra...)...) + formatted := fmt.Sprintf(endpoint, clmPathParams(append([]any{accountId}, extra...)...)...) return preparePagedRequestClm(baseURL, formatted, options) } diff --git a/pkg/connector/clm_members.go b/pkg/connector/clm_members.go index 7d57b93f..5ddd0ad5 100644 --- a/pkg/connector/clm_members.go +++ b/pkg/connector/clm_members.go @@ -31,6 +31,12 @@ type clmMemberBuilder struct { // annotated to tell the SDK's sync engine whether it can skip calling // Grants() for clm_member resources. clmMemberResourceType is a package-level // var shared with other code, so it's cloned before its annotations are mutated. +// +// baton_capabilities.json (generated via ./connector capabilities) reflects the +// all-types-enabled case: SkipEntitlements when clm_workflow_queue is in scope. +// When a customer's sync filter excludes clm_workflow_queue, this returns +// SkipEntitlementsAndGrants instead — same pattern as userBuilder + +// skipPermissionProfileResourceType. func (b *clmMemberBuilder) ResourceType(_ context.Context) *v2.ResourceType { rt := proto.Clone(clmMemberResourceType).(*v2.ResourceType) annos := annotations.Annotations(rt.Annotations) @@ -56,7 +62,7 @@ func (b *clmMemberBuilder) List(ctx context.Context, _ *v2.ResourceId, attr rs.S PageToken: pageToken, }) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("baton-docusign: listing CLM members: %w", err) } for _, member := range members { diff --git a/pkg/connector/clm_members_test.go b/pkg/connector/clm_members_test.go index cb74ade8..feab0967 100644 --- a/pkg/connector/clm_members_test.go +++ b/pkg/connector/clm_members_test.go @@ -91,6 +91,9 @@ func TestClmMemberBuilder_List_FailsWhenClmUnavailable(t *testing.T) { if err == nil { t.Fatal("expected List to fail when CLM is unavailable, got nil error") } + if !strings.HasPrefix(err.Error(), "baton-docusign:") { + t.Errorf("expected error wrapped with the baton-docusign: prefix, got: %v", err) + } if len(resources) != 0 { t.Errorf("expected zero resources on a hard failure, got %d", len(resources)) }