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/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b3ecae4a..b18efabd 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -15,9 +15,15 @@ concurrency:
env:
# This CI account has no CLM subscription, and (unlike a C1-hosted sync) nothing here
# filters resource types by OptInRequired — every registered resource type is attempted
- # by default. CLM builders now fail the whole sync rather than skip gracefully when CLM
- # isn't available (see pkg/connector/clm_roles.go), so the 5 clm_* types must be
- # excluded here explicitly to test the ones the three test-* jobs below care about.
+ # by default. Every clm_* type must stay excluded here, not just clm_workflow_queue:
+ # 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). 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
# resource type in pkg/connector/connector.go, add it here too — otherwise it silently
# gets zero CI sync-test coverage. Declared once at the workflow level (all three jobs
diff --git a/README.md b/README.md
index 859fd50c..7c536e3e 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 are opt-in: they don't sync by
-default, and a customer must explicitly enable each CLM resource type in C1's sync
-configuration.
+groups, folders, folder security, permission sets, and workflow queues are opt-in: they
+don't sync by default, and a customer must explicitly enable each CLM resource type in
+C1's sync configuration.
Requirements:
@@ -115,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, but each carries
+The 6 CLM resource types are always registered and visible to C1, but each carries
`OptInRequired` — C1 excludes them from a customer's sync by default, and they only run
once a customer explicitly opts in (see [CHANGE_TYPES.md](CHANGE_TYPES.md) if you're
touching this). C1's opt-in toggle does not validate the underlying DocuSign account
@@ -130,22 +131,53 @@ itself — a self-hosted connector running in service mode still has its per-res
`List()` calls filtered by the platform's opt-in selection (applied inside baton-sdk's
syncer, not surfaced to the connector's own code), but running `baton-docusign` directly
as a one-shot CLI sync (the quickstarts below, with no service/task involved at all)
-attempts all 5 CLM resource types by default, with no opt-in gate at all. If that account
+attempts all 6 CLM resource types by default, with no opt-in gate at all. If that account
doesn't have a CLM subscription, the sync now fails instead of skipping CLM gracefully.
Pass `--sync-resource-types` (or `BATON_SYNC_RESOURCE_TYPES`, comma-separated) with the
resource type IDs you actually want (e.g. `user,group,permission_profile`) to exclude
-`clm_member,clm_role,clm_group,clm_permission_set,clm_folder` on an eSignature-only
-account run this way.
+`clm_member,clm_role,clm_group,clm_permission_set,clm_folder,clm_workflow_queue` on an
+eSignature-only account run this way (see `.github/workflows/ci.yaml` for a working
+example).
One check does NOT see that platform filter in either deployment mode: `Connector.Validate()`'s
upfront CLM-readiness check runs once, before any resource type's `List()` and before the
platform filter is applied to anything — a known, reviewed, and deliberately accepted gap,
not an oversight.
+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
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 `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.
+
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
@@ -253,7 +285,7 @@ baton resources
- Groups
- Signing Groups
- Permission Profiles
-- CLM Members, Roles, Groups, Folders, Folder Security, and Permission Sets (requires a DocuSign CLM subscription)
+- CLM Members, Roles, Groups, Folders, Folder Security, Permission Sets, and Workflow Queues (requires a DocuSign CLM subscription)
# Contributing, Support and Issues
diff --git a/baton_capabilities.json b/baton_capabilities.json
index dfb942eb..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"
}
]
},
@@ -101,6 +108,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/docs/connector.mdx b/docs/connector.mdx
index 563186a5..5959b622 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. Once enabled, your account must actually have the signing groups feature — ConductorOne doesn't validate this before letting you turn the setting on, so enabling it without the feature will fail the sync rather than silently sync no signing groups.
-**DocuSign CLM (Contract Lifecycle Management) is a separate, separately-licensed DocuSign product. CLM resources are opt-in — enable each CLM resource type in your sync configuration to turn them on. Once enabled, your DocuSign account must have a CLM production subscription and the credential must have been granted the OAuth scopes CLM needs; enabling a CLM resource type without them will fail the sync rather than silently sync no data, since ConductorOne doesn't validate the underlying subscription before letting you opt in. CLM permission sets sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign one.
+**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 cb75a414..1f2ff968 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?**
@@ -45,11 +45,12 @@
**Important Note about CLM:**
- - CLM (Contract Lifecycle Management) is a separate, separately-licensed DocuSign product with its own API. The 5 CLM resource types carry `OptInRequired` and don't sync until a customer explicitly enables them in C1's sync configuration; C1's opt-in toggle doesn't validate the underlying subscription/scopes first, so an account that opts in but can't reach CLM fails the sync loudly rather than silently syncing zero CLM resources.
+ - CLM (Contract Lifecycle Management) is a separate, separately-licensed DocuSign product with its own API. The 6 CLM resource types carry `OptInRequired` and don't sync until a customer explicitly enables them in C1's sync configuration; C1's opt-in toggle doesn't validate the underlying subscription/scopes first, so an account that opts in but can't reach CLM fails the sync loudly rather than silently syncing zero CLM resources.
- Requires a DocuSign CLM production subscription.
- When using ConductorOne's managed OAuth app (the default cloud-hosted authentication method), CLM also requires that managed app to be granted the CLM API scope on ConductorOne's platform side — this is outside the connector's own configuration. Self-hosted or demo-environment setups using a customer-supplied DocuSign app do not have this extra requirement.
- CLM permission sets sync for visibility only; DocuSign's CLM API has no endpoint to assign or unassign one, so they cannot be granted or revoked.
- 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) when workflow queues are enabled — an accepted tradeoff of this design. Workflow queues require CLM members to be enabled in the sync configuration.
---
@@ -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 once a customer explicitly enables each CLM resource type in C1's sync configuration (see the CLM note above — these types carry `OptInRequired`).
+3. The connector then syncs CLM Members, Roles, Groups, Folders, Folder Security, Permission Sets, and Workflow Queues once a customer explicitly enables each CLM resource type in C1's sync configuration (see the CLM note above — these types carry `OptInRequired`).
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 aa8dfa25..b0717e3c 100644
--- a/pkg/client/clm_client.go
+++ b/pkg/client/clm_client.go
@@ -39,6 +39,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)
//
@@ -136,6 +137,7 @@ const (
clmGetMemberGroups = "/v2/%s/members/%s/groups"
clmPatchPutMember = "/v2/%s/members/%s"
clmGetPermissionSet = "/v2/%s/permissionsets"
+ clmGetMemberQueues = "/v2/%s/members/%s/workflowqueues"
// clmGroupPath and clmMemberPath are path *shapes*, not endpoints this connector
// calls — hrefFor builds a Href string locally from these, issuing no request beyond
@@ -262,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
@@ -280,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)
}
@@ -800,32 +816,27 @@ 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.
-//
-// 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
}
@@ -836,12 +847,30 @@ 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)
+}
+
+// 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
+// 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
@@ -948,3 +977,39 @@ 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 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).
+func (c *Client) GetMemberWorkflowQueues(ctx context.Context, memberID string) ([]ClmWorkflowQueue, annotations.Annotations, error) {
+ 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) {
+ 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 5d773de0..e0909a4a 100644
--- a/pkg/client/clm_client_test.go
+++ b/pkg/client/clm_client_test.go
@@ -385,3 +385,58 @@ 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")
+ }
+ })
+}
+
+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/clm_models.go b/pkg/client/clm_models.go
index c8a8f1ff..33bd5231 100644
--- a/pkg/client/clm_models.go
+++ b/pkg/client/clm_models.go
@@ -514,3 +514,16 @@ var ClmRoles = []ClmRole{
{Name: "UserAdministrator"},
{Name: "SuperAdministrator"},
}
+
+// ClmWorkflowQueue is a CLM WorkflowQueue (Member's workflow-queues response shape).
+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 92b5e46f..5cc051a1 100644
--- a/pkg/client/clmtest/handlers.go
+++ b/pkg/client/clmtest/handlers.go
@@ -283,6 +283,34 @@ 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()
+ 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
+ }
+
+ 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 b7ce863f..9f386c6c 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 9e4d41b1..507b8215 100644
--- a/pkg/client/clmtest/server.go
+++ b/pkg/client/clmtest/server.go
@@ -40,6 +40,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 (
@@ -115,7 +116,13 @@ type Server struct {
permissionSets map[string]*client.ClmPermissionSet
permissionSetOrder []string
- memberGroupsRequests int // count of GET .../members/{id}/groups calls, for pagination assertions
+ 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
+ memberWorkflowQueuesRequests int // count of GET .../members/{id}/workflowqueues calls, for pagination assertions
+
+ forcedMemberWorkflowQueuesStatus map[string]int // memberID -> forced HTTP status, for tests
lastPatchedMemberGroupHrefs map[string][]string // memberID -> the raw Href strings the last PATCH request body carried, for tests
@@ -143,6 +150,17 @@ type Server struct {
pendingChangeSecurityPolls int
}
+// 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
+}
+
// SetPendingFolderSearchPolls makes the next folder search task created by this server
// require n polls of GET .../foldersearchtasks/{id} before resolving to "Success" —
// exercises SearchFolders' awaitClmFolderSearchTask polling loop, which every live test
@@ -192,6 +210,72 @@ 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
+}
+
+// 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)
+}
+
+// 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
@@ -284,6 +368,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 {
@@ -321,13 +409,16 @@ 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),
- lastPatchedMemberGroupHrefs: 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),
+ lastPatchedMemberGroupHrefs: make(map[string][]string),
}
}
@@ -347,6 +438,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_members.go b/pkg/connector/clm_members.go
index 1899219f..5ddd0ad5 100644
--- a/pkg/connector/clm_members.go
+++ b/pkg/connector/clm_members.go
@@ -2,22 +2,51 @@ 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/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
// 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
+ // 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.
+//
+// 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 {
- 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) {
@@ -33,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 {
@@ -63,17 +92,44 @@ 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). 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
+
+ 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 {
+func newClmMemberBuilder(c *client.Client, includeWorkflowQueues bool) *clmMemberBuilder {
return &clmMemberBuilder{
- resourceType: clmMemberResourceType,
- client: c,
+ resourceType: clmMemberResourceType,
+ client: c,
+ includeWorkflowQueues: includeWorkflowQueues,
}
}
@@ -81,6 +137,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 +168,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..feab0967 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"
)
@@ -15,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
@@ -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, true)
+ 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
@@ -45,24 +84,25 @@ 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}})
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))
}
}
-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)
+ b := newClmMemberBuilder(c, true)
ctx := context.Background()
memberResource, err := rs.NewResource("Alice", clmMemberResourceType, "member-alice")
@@ -73,7 +113,172 @@ 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, true)
+ 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, true)
+ 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, true)
+ 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, true)
+ 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)
+ }
+}
+
+// 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
new file mode 100644
index 00000000..099835b6
--- /dev/null
+++ b/pkg/connector/clm_workflow_queues.go
@@ -0,0 +1,121 @@
+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/connectorbuilder"
+ rs "github.com/conductorone/baton-sdk/pkg/types/resource"
+)
+
+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"
+
+// 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 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
+// 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
+}
+
+func (b *clmWorkflowQueueBuilder) ResourceType(_ context.Context) *v2.ResourceType {
+ return clmWorkflowQueueResourceType
+}
+
+// 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 {
+ // 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)
+ if err != nil {
+ return nil, nil, fmt.Errorf("baton-docusign: getting CLM workflow queues for member %s: %w", parentResourceID.Resource, err)
+ }
+
+ resources := make([]*v2.Resource, 0, len(queues))
+ for _, q := range queues {
+ if clmIDFromHref(q.Href) == "" {
+ logSkippedClmWorkflowQueueWithEmptyHref(ctx, parentResourceID.Resource, q.Href)
+ continue
+ }
+ queueResource, err := parseIntoClmWorkflowQueueResource(&q)
+ if err != nil {
+ return nil, nil, err
+ }
+ resources = append(resources, queueResource)
+ }
+
+ return resources, &rs.SyncOpResults{Annotations: annos}, 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: 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 {
+ return &clmWorkflowQueueBuilder{
+ resourceType: clmWorkflowQueueResourceType,
+ client: c,
+ }
+}
+
+// parseIntoClmWorkflowQueueResource maps a client.ClmWorkflowQueue to a Baton v2.Resource.
+// 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,
+ 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..d452d6fc
--- /dev/null
+++ b/pkg/connector/clm_workflow_queues_test.go
@@ -0,0 +1,220 @@
+package connector
+
+import (
+ "context"
+ "strings"
+ "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"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// 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 by stable queue ID.
+func TestClmWorkflowQueueBuilder_List_ReturnsMemberWorkflowQueues(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, parentResourceID, rs.SyncOpAttrs{})
+ 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 workflow queues for member-bob, got %d: %+v", len(resources), resources)
+ }
+
+ gotIDs := make(map[string]bool, len(resources))
+ for _, r := range resources {
+ if r.Id.ResourceType != clmWorkflowQueueResourceType.Id {
+ t.Errorf("expected resource type %q, got %q", clmWorkflowQueueResourceType.Id, r.Id.ResourceType)
+ }
+ if r.ParentResourceId != nil {
+ t.Errorf("expected no ParentResourceId (shared queues have no canonical parent), got %+v", r.ParentResourceId)
+ }
+ gotIDs[r.Id.Resource] = true
+ }
+ 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_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()
+
+ 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)
+ }
+ if len(resources) != 1 || resources[0].Id.Resource != "queue-onboarding" {
+ t.Fatalf("expected exactly [queue-onboarding] for member-alice, got %+v", resources)
+ }
+}
+
+// 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()
+
+ parentResourceID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: "member-carol"}
+ resources, _, err := b.List(ctx, parentResourceID, rs.SyncOpAttrs{})
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(resources) != 0 {
+ t.Fatalf("expected zero workflow queues for member-carol, got %d: %+v", len(resources), resources)
+ }
+}
+
+// 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.Fatalf("expected nil error for unparented top-level List(), got: %v", err)
+ }
+ if resources != nil {
+ t.Errorf("expected nil resources, got %+v", resources)
+ }
+ if res != nil {
+ t.Errorf("expected nil SyncOpResults, got %+v", res)
+ }
+}
+
+// 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()
+
+ 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")
+ }
+ 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 resources != nil {
+ t.Errorf("expected nil resources on a hard failure, got %+v", resources)
+ }
+}
+
+// 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.AddMemberWorkflowQueueWithEmptyHref("member-no-href-queue")
+ b := newClmWorkflowQueueBuilder(c)
+ ctx := context.Background()
+
+ parentResourceID := &v2.ResourceId{ResourceType: clmMemberResourceType.Id, Resource: "member-no-href-queue"}
+ resources, _, err := b.List(ctx, parentResourceID, rs.SyncOpAttrs{})
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(resources) != 0 {
+ t.Fatalf("expected the empty-Href queue to be skipped, got %d resources: %+v", len(resources), resources)
+ }
+}
+
+// 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()
+
+ queueResource, err := rs.NewGroupResource("Onboarding", clmWorkflowQueueResourceType, "queue-onboarding", nil)
+ if err != nil {
+ t.Fatalf("NewGroupResource: %v", err)
+ }
+
+ 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_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()
+
+ ents, res, err := b.StaticEntitlements(ctx, rs.SyncOpAttrs{})
+ if err != nil {
+ t.Fatalf("StaticEntitlements: %v", err)
+ }
+ if res != nil {
+ t.Errorf("expected nil SyncOpResults, got %+v", res)
+ }
+ if len(ents) != 1 || ents[0].Slug != entitlementClmWorkflowQueueMember {
+ t.Fatalf("expected a single %q entitlement, got %+v", entitlementClmWorkflowQueueMember, ents)
+ }
+ 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_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()
+
+ queueResource, err := rs.NewGroupResource("Onboarding", clmWorkflowQueueResourceType, "queue-onboarding", nil)
+ if err != nil {
+ t.Fatalf("NewGroupResource: %v", err)
+ }
+
+ 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.go b/pkg/connector/connector.go
index 26b1ccf6..191f1bc1 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -28,13 +28,17 @@ type Connector struct {
// opts.WillSyncResourceType(...) signal that already determines whether any CLM
// builder's List() gets invoked this run (see New()). Gates Validate()'s upfront CLM
// readiness check only: it does NOT gate resource-type registration. ResourceSyncers
- // always registers all 5 CLM builders unconditionally, because toggling registration
+ // always registers all 6 CLM builders unconditionally, because toggling registration
// itself would make ListResourceTypes() advertise a different set between syncs and
// C1 would read previously-synced CLM resources/grants as deleted.
includeClm bool
// skipPermissionProfileResourceType reports whether permission_profile is
// excluded from the sync filter.
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,11 +84,12 @@ 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),
newClmFolderBuilder(d.client),
+ newClmWorkflowQueueBuilder(d.client),
}
// Only include signing groups if opted in
@@ -105,7 +110,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{
@@ -165,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)
@@ -183,6 +188,7 @@ func NewWithRefreshToken(
includeSigningGroups: includeSigningGroups,
includeClm: includeClm,
skipPermissionProfileResourceType: skipPermissionProfileResourceType,
+ includeWorkflowQueues: includeWorkflowQueues,
}, nil
}
@@ -193,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)
@@ -202,6 +208,7 @@ func NewWithTokenSource(
includeSigningGroups: includeSigningGroups,
includeClm: includeClm,
skipPermissionProfileResourceType: skipPermissionProfileResourceType,
+ includeWorkflowQueues: includeWorkflowQueues,
}, nil
}
@@ -211,7 +218,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 {
@@ -222,14 +229,20 @@ 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)
+ // 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.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 {
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))
@@ -263,6 +276,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 0db4b2a5..61840ddf 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"
@@ -33,6 +35,29 @@ var alwaysRegisteredTypeIDs = []string{
"clm_group",
"clm_permission_set",
"clm_folder",
+ "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 {
@@ -151,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)
@@ -173,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)
@@ -254,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) {
@@ -280,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)
+ }
})
}
}
diff --git a/pkg/connector/helper.go b/pkg/connector/helper.go
index bbc4ea02..07113980 100644
--- a/pkg/connector/helper.go
+++ b/pkg/connector/helper.go
@@ -168,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 c716832a..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.
@@ -98,4 +104,20 @@ 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. 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",
+ Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP},
+ Annotations: annotations.New(&v2.SkipEntitlements{}, &v2.OptInRequired{}),
+ }
)