diff --git a/pkg/accessreview/drivers/docusign.go b/pkg/accessreview/drivers/docusign.go index 53f7de817..3c59ebe7f 100644 --- a/pkg/accessreview/drivers/docusign.go +++ b/pkg/accessreview/drivers/docusign.go @@ -17,6 +17,7 @@ package drivers import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -28,20 +29,64 @@ import ( ) // DocuSignDriver fetches account users from DocuSign via OAuth2-authenticated -// REST API requests. It auto-discovers the account ID and base URI from the -// OAuth2 userinfo endpoint, then paginates through the eSignature Users API. +// REST API requests. It resolves the data-center base URI for the configured +// account from the OAuth2 userinfo endpoint, then paginates through the +// eSignature Users API. The account is the one the user picked after OAuth +// (a DocuSign user may have access to several). type DocuSignDriver struct { httpClient *http.Client + accountID string } var _ Driver = (*DocuSignDriver)(nil) -type docusignUserInfoResponse struct { - Accounts []struct { - AccountID string `json:"account_id"` - IsDefault bool `json:"is_default"` - BaseURI string `json:"base_uri"` - } `json:"accounts"` +// errDocuSignUserInfoStatus marks a non-2xx userinfo response (typically a +// revoked or expired token). Callers that treat a dead token as terminal — +// the name resolver — branch on it via errors.Is; the driver and picker +// surface it as an ordinary failure. +var errDocuSignUserInfoStatus = errors.New("docusign userinfo returned a non-success status") + +// docusignAccount is one entry of the /oauth/userinfo accounts list, carrying +// every field the driver, name resolver and picker key off. +type docusignAccount struct { + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + BaseURI string `json:"base_uri"` +} + +// fetchDocuSignAccounts returns the DocuSign accounts the access token can +// reach, from the OAuth2 userinfo endpoint. A non-2xx response yields +// errDocuSignUserInfoStatus; request and decode failures surface as ordinary +// errors so transient conditions stay distinguishable from a dead token. +func fetchDocuSignAccounts(ctx context.Context, httpClient *http.Client) ([]docusignAccount, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, docusignUserInfoEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create docusign userinfo request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute docusign userinfo request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("%w: status %d", errDocuSignUserInfoStatus, httpResp.StatusCode) + } + + var resp struct { + Accounts []docusignAccount `json:"accounts"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode docusign userinfo response: %w", err) + } + + return resp.Accounts, nil } type docusignUsersResponse struct { @@ -67,14 +112,15 @@ const ( docusignUsersPageSize = 100 ) -func NewDocuSignDriver(httpClient *http.Client) *DocuSignDriver { +func NewDocuSignDriver(httpClient *http.Client, accountID string) *DocuSignDriver { return &DocuSignDriver{ httpClient: httpClient, + accountID: accountID, } } func (d *DocuSignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { - accountID, baseURI, err := d.discoverAccount(ctx) + baseURI, err := d.discoverBaseURI(ctx) if err != nil { return nil, fmt.Errorf("cannot discover docusign account: %w", err) } @@ -84,7 +130,7 @@ func (d *DocuSignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err startPosition := 0 for range maxPaginationPages { - resp, err := d.queryUsers(ctx, baseURI, accountID, startPosition) + resp, err := d.queryUsers(ctx, baseURI, d.accountID, startPosition) if err != nil { return nil, err } @@ -147,43 +193,23 @@ func (d *DocuSignDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err return nil, fmt.Errorf("cannot list all docusign accounts: %w", ErrPaginationLimitReached) } -func (d *DocuSignDriver) discoverAccount(ctx context.Context) (accountID string, baseURI string, err error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, docusignUserInfoEndpoint, nil) +// discoverBaseURI resolves the data-center base URI (e.g. +// https://na3.docusign.net) for the configured account from the OAuth2 +// userinfo endpoint. DocuSign issues account-specific base URIs, so the +// eSignature REST host cannot be hardcoded. +func (d *DocuSignDriver) discoverBaseURI(ctx context.Context) (string, error) { + accounts, err := fetchDocuSignAccounts(ctx, d.httpClient) if err != nil { - return "", "", fmt.Errorf("cannot create docusign userinfo request: %w", err) + return "", err } - req.Header.Set("Accept", "application/json") - - httpResp, err := d.httpClient.Do(req) - if err != nil { - return "", "", fmt.Errorf("cannot execute docusign userinfo request: %w", err) - } - - defer func() { - _ = httpResp.Body.Close() - }() - - if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { - return "", "", fmt.Errorf("cannot fetch docusign userinfo: unexpected status %d", httpResp.StatusCode) - } - - var resp docusignUserInfoResponse - if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { - return "", "", fmt.Errorf("cannot decode docusign userinfo response: %w", err) - } - - for _, account := range resp.Accounts { - if account.IsDefault { - return account.AccountID, account.BaseURI, nil + for _, account := range accounts { + if account.AccountID == d.accountID { + return account.BaseURI, nil } } - if len(resp.Accounts) > 0 { - return resp.Accounts[0].AccountID, resp.Accounts[0].BaseURI, nil - } - - return "", "", fmt.Errorf("no docusign accounts found in userinfo response") + return "", fmt.Errorf("docusign account not found in userinfo") } func (d *DocuSignDriver) queryUsers(ctx context.Context, baseURI string, accountID string, startPosition int) (*docusignUsersResponse, error) { diff --git a/pkg/accessreview/drivers/docusign_test.go b/pkg/accessreview/drivers/docusign_test.go index cf6ac3e3b..951841af4 100644 --- a/pkg/accessreview/drivers/docusign_test.go +++ b/pkg/accessreview/drivers/docusign_test.go @@ -26,17 +26,27 @@ import ( func TestDocuSignDriver(t *testing.T) { t.Parallel() + // Account UUID matches the userinfo cassette: the driver resolves the + // selected account's data-center base URI before listing its users. + const accountID = "a1a1a1a1-1111-4111-8111-111111111111" + rec := newRecorder(t, "testdata/docusign", "DOCUSIGN_TOKEN") client := newVCRClient(rec, bearerAuth(os.Getenv("DOCUSIGN_TOKEN"))) - driver := NewDocuSignDriver(client) + driver := NewDocuSignDriver(client, accountID) records, err := driver.ListAccounts(context.Background()) require.NoError(t, err) - require.NotEmpty(t, records) + assert.Len(t, records, 2) r := records[0] - assert.NotEmpty(t, r.Email) - assert.NotEmpty(t, r.FullName) - assert.NotEmpty(t, r.ExternalID) - assert.NotEmpty(t, r.Roles) + assert.Equal(t, "jane.doe@example.com", r.Email) + assert.Equal(t, "Jane Doe", r.FullName) + assert.Equal(t, "11111111-1111-4111-8111-111111111111", r.ExternalID) + assert.Equal(t, []string{"Account Administrator"}, r.Roles) + assert.Equal(t, "CTO", r.JobTitle) + assert.True(t, r.IsAdmin) + require.NotNil(t, r.Active) + assert.True(t, *r.Active) + + assert.False(t, records[1].IsAdmin) } diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index 8bd418c77..4ec081eac 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -513,54 +513,36 @@ func (r *hubspotNameResolver) ResolveInstanceName(ctx context.Context) (string, return resp.AccountName, nil } -// docusignNameResolver resolves the DocuSign account name from userinfo. +// docusignNameResolver resolves the configured DocuSign account's name from +// the OAuth2 userinfo endpoint, for the AccessReviewSource title. type docusignNameResolver struct { httpClient *http.Client + accountID string } -func NewDocuSignNameResolver(httpClient *http.Client) NameResolver { - return &docusignNameResolver{httpClient: httpClient} +func NewDocuSignNameResolver(httpClient *http.Client, accountID string) NameResolver { + return &docusignNameResolver{httpClient: httpClient, accountID: accountID} } func (r *docusignNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, docusignUserInfoEndpoint, nil) + accounts, err := fetchDocuSignAccounts(ctx, r.httpClient) if err != nil { - return "", fmt.Errorf("cannot create docusign userinfo request: %w", err) + // A dead token (non-2xx) is terminal: the source-name worker marks + // the source synced on ("", nil), so it stops retrying. Transient and + // decode failures stay errors so the worker retries. + if errors.Is(err, errDocuSignUserInfoStatus) { + return "", nil + } + + return "", err } - req.Header.Set("Accept", "application/json") - - httpResp, err := r.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("cannot execute docusign userinfo request: %w", err) - } - - defer func() { _ = httpResp.Body.Close() }() - - if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { - return "", fmt.Errorf("cannot fetch docusign userinfo: unexpected status %d", httpResp.StatusCode) - } - - var resp struct { - Accounts []struct { - AccountName string `json:"account_name"` - IsDefault bool `json:"is_default"` - } `json:"accounts"` - } - if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { - return "", fmt.Errorf("cannot decode docusign userinfo response: %w", err) - } - - for _, account := range resp.Accounts { - if account.IsDefault { + for _, account := range accounts { + if account.AccountID == r.accountID { return account.AccountName, nil } } - if len(resp.Accounts) > 0 { - return resp.Accounts[0].AccountName, nil - } - return "", nil } diff --git a/pkg/accessreview/drivers/organizations.go b/pkg/accessreview/drivers/organizations.go index 1930cfc0a..edb75826e 100644 --- a/pkg/accessreview/drivers/organizations.go +++ b/pkg/accessreview/drivers/organizations.go @@ -392,6 +392,30 @@ func ListNetlifyOrganizations(ctx context.Context, httpClient *http.Client) ([]O return result, nil } +// ListDocuSignOrganizations fetches the DocuSign accounts the authenticated +// user can access, from the OAuth2 userinfo endpoint. A user may belong to +// several accounts; the picker scopes the access source to one. The account +// UUID is surfaced as the Organization slug (it is what the driver and name +// resolver key off). +func ListDocuSignOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { + accounts, err := fetchDocuSignAccounts(ctx, httpClient) + if err != nil { + return nil, err + } + + result := make([]Organization, len(accounts)) + for i, a := range accounts { + displayName := a.AccountName + if displayName == "" { + displayName = a.AccountID + } + + result[i] = Organization{Slug: a.AccountID, DisplayName: displayName} + } + + return result, nil +} + // ListClickUpOrganizations fetches the ClickUp teams (workspaces) the // authenticated user belongs to. func ListClickUpOrganizations(ctx context.Context, httpClient *http.Client) ([]Organization, error) { diff --git a/pkg/accessreview/drivers/testdata/docusign.yaml b/pkg/accessreview/drivers/testdata/docusign.yaml new file mode 100644 index 000000000..bf5c0c45f --- /dev/null +++ b/pkg/accessreview/drivers/testdata/docusign.yaml @@ -0,0 +1,60 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: account.docusign.com + headers: + Accept: + - application/json + url: https://account.docusign.com/oauth/userinfo + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"sub":"a1a1a1a1-1111-4111-8111-111111111111","name":"Jane Doe","accounts":[{"account_id":"a1a1a1a1-1111-4111-8111-111111111111","account_name":"Acme Corp","is_default":true,"base_uri":"https://na3.docusign.net"},{"account_id":"b2b2b2b2-2222-4222-8222-222222222222","account_name":"Acme Sandbox","is_default":false,"base_uri":"https://na3.docusign.net"}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 1ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: na3.docusign.net + form: + additional_info: + - "true" + count: + - "100" + start_position: + - "0" + headers: + Accept: + - application/json + url: https://na3.docusign.net/restapi/v2.1/accounts/a1a1a1a1-1111-4111-8111-111111111111/users?additional_info=true&count=100&start_position=0 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"users":[{"userId":"11111111-1111-4111-8111-111111111111","userName":"Jane Doe","email":"jane.doe@example.com","userStatus":"Active","isAdmin":"True","createdDateTime":"2024-01-15T10:00:00.0000000Z","lastLogin":"2026-05-01T09:30:00.0000000Z","permissionProfileName":"Account Administrator","jobTitle":"CTO"},{"userId":"22222222-2222-4222-8222-222222222222","userName":"John Smith","email":"john.smith@example.com","userStatus":"Active","isAdmin":"false","createdDateTime":"2025-03-20T14:00:00.0000000Z","lastLogin":"2026-04-28T16:45:00.0000000Z","permissionProfileName":"DocuSign Sender","jobTitle":"Account Executive"}],"resultSetSize":"2","totalSetSize":"2","startPosition":"0","endPosition":"1"}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 1ms diff --git a/pkg/connector/provider/docusign.go b/pkg/connector/provider/docusign.go index 79811971c..d2396c363 100644 --- a/pkg/connector/provider/docusign.go +++ b/pkg/connector/provider/docusign.go @@ -16,6 +16,7 @@ package provider import ( "context" + "fmt" "net/http" "go.gearno.de/kit/log" @@ -31,13 +32,40 @@ func docusignRegistration() *Registration { TokenURL: "https://account.docusign.com/oauth/token", TokenEndpointAuth: "basic-form", ProbeURL: "https://account.docusign.com/oauth/userinfo", - OAuth2Scopes: []string{"signature"}, - SupportsAPIKey: true, - NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { - return drivers.NewDocuSignDriver(c), nil + // signature grants the eSignature REST API (the userinfo probe and + // the account users list). extended rolls the 30-day refresh-token + // window on every refresh so the connection survives long-term — the + // review engine persists the rotated token on each poll. Without it + // the refresh token hard-expires 30 days after the initial consent. + OAuth2Scopes: []string{"signature", "extended"}, + // DocuSign enables PKCE (S256) on the integration key. The confidential + // authorization-code grant still authenticates the token exchange with + // Basic auth (basic-form); PKCE rides along as the documented hardening + // layer, replaying the verifier in the token request body. + RequiresPKCE: true, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.DocuSignConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read docusign connector settings: %w", err) + } + + if s.AccountID == "" { + return nil, fmt.Errorf("cannot create docusign driver: account_id is required") + } + + return drivers.NewDocuSignDriver(c, s.AccountID), nil }, - NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { - return drivers.NewDocuSignNameResolver(c) + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.DocuSignConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read docusign connector settings", log.Error(err)) + return nil + } + + return drivers.NewDocuSignNameResolver(c, s.AccountID) + }, + SetOrganizationSettings: func(c *coredata.Connector, accountID string) error { + return c.SetSettings(&coredata.DocuSignConnectorSettings{AccountID: accountID}) }, } } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index fe5bf73fb..813852c46 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -88,6 +88,15 @@ type ( TeamID string `json:"team_id"` } + // DocuSignConnectorSettings holds the DocuSign account the user picked + // after OAuth. A DocuSign user can have access to multiple accounts, so + // the post-OAuth picker scopes the access source to one; AccountID is the + // selected account's UUID. The driver and name resolver re-resolve the + // account's data-center base URI from /oauth/userinfo at fetch time. + DocuSignConnectorSettings struct { + AccountID string `json:"account_id"` + } + VercelConnectorSettings struct { TeamID string `json:"team_id"` } diff --git a/pkg/server/api/console/v1/access_source_provider_config.go b/pkg/server/api/console/v1/access_source_provider_config.go index 411de4ac6..599b2d192 100644 --- a/pkg/server/api/console/v1/access_source_provider_config.go +++ b/pkg/server/api/console/v1/access_source_provider_config.go @@ -108,6 +108,14 @@ var providerOrgConfigs = map[coredata.ConnectorProvider]providerOrgConfig{ }, NeedsPicker: true, }, + coredata.ConnectorProviderDocuSign: { + ListOrgs: drivers.ListDocuSignOrganizations, + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.DocuSignConnectorSettings](c) + return s.AccountID + }, + NeedsPicker: true, + }, // Pattern 2-auto: identifier is captured during the OAuth callback // (subdomain for PagerDuty, team_id or fallback /v2/user.id for // Vercel). No picker UI; NeedsPicker = false.