Docusign

Docusign is a leading provider of electronic signature technology, allowing individuals and organizations to sign, send, and manage documents digitally.

Analytics & SIEM · Docusign

Details

IDDocusign
ProviderDocusign
CategoryAnalytics & SIEM
From Version8.4.0
Docker Imagedemisto/auth-utils:1.0.0.10133006
Supported ModulesXSIAM

README

Docusign is a leading provider of electronic signature technology, allowing individuals and organizations to sign, send, and manage documents digitally.

Customer Events API

The Docusign Monitor API provides access to customer events, which are events that occur in your Docusign account.

User Data API

The Docusign Admin API provides access to user data, which is information about users in your Docusign account.


Configure DocuSign new application

Follow the steps below to create and configure a DocuSign application for use with this integration:

1. Access DocuSign Developer Portal

  • Open the DocuSign web UI and log in.
  • Navigate to Account.
  • From the left sidebar, click Apps and Keys.

2. Create a New Application

  • Click Add App.
  • Provide a name for your application.
  • Copy the Integration Key.

3. Setting Integration Type

  • Select the App integration type required for your workflow.

configureNewAppPart1

4. Configure Application Settings

  • Under User Application, select Yes.
  • Under Authentication Method for your App, leave the default option, Authorization Code Grant.

5. Generate a Secret Key

  • Click Add Secret Key and generate a new secret key.

6. Generate RSA Key Pair

  • Under Service Integration Click Generate RSA.
  • Copy the Private Key. (The public key is not used by the integration)

configureNewAppPart2

7. Set Redirect URI

  • Navigate to Additional Settings.
  • Set the Redirect URI to https://localhost.

8. Save the Application

  • Click Save to finalize your application configuration.

configureNewAppPart3

9. Retrieve Organization ID

  • Navigate to the Organization tab from the left sidebar.
  • Copy the Organization ID from the URL.

10. Configure and Test

  • Configure and save the instance.
  • To use the Docusign integration and allow access to Docusign events, an administrator has to approve our app using an admin consent flow by running the !docusign-generate-consent-url command.
  • Run the command !docusign-auth-test to test the full authentication flow and API connectivity.

Go-Live - Customer events data type

When you are ready to launch your app in production, you will need to promote your application’s integration key from your developer account to a production Docusign account by passing a Go-Live review, similar to the Go-Live process for the eSignature REST API.

Before you can begin the Go-Live process, you must have:

  • A paid production Docusign account with a plan that includes Docusign Monitor
  • Completed at least 20 consecutive successful test eSignature API requests in the developer environment

The 20 successful requests must be eSignature REST API requests, not Monitor API requests.

To start the Go-Live review for your application, follow the steps described on the Go-Live overview page for the Docusign eSignature REST API.

Note: If your application fails the Go-Live review, you may be required to bring it into compliance with the Rules and resource limits before proceeding.

Once the form is processed (which can take up to three business days), your integration key will be copied into production, enabling your app to call the production API endpoints.


API Endpoints

The developer and production endpoints for most Docusign APIs use slightly different paths.
The table below shows the base endpoint paths for each DocuSign environment, helping you update your code when migrating from the developer environment to production.

Environment API base URI Web Site Login URL
Developer https://lens-d.docusign.net/api/v2.0/datasets/monitor/.. https://account-d.docusign.com
Production https://lens.docusign.net/api/v2.0/datasets/monitor/... https://{server}.docusign.net/

Note: To access production API endpoints, you will need to enable your integration key in the production environment. See Go-Live for more information.


Go-Live - Audit Users data type

Before you can begin the Go-Live process for an app that uses the Admin API, you must have:

  • Admin API access enabled for your account
  • Completed at least 20 consecutive successful test eSignature API requests in the developer environment

The 20 successful requests must be API Reference requests, not Admin API requests.

When you are ready to start the Go-Live review for your application, follow the steps described on the Go-Live overview page for Docusign eSignature.

Note: If your application fails the Go-Live review, you may need to bring it into compliance with the API rules and resource limits before proceeding.

After the form is processed, your integration key is copied to your production account, enabling your app to call production Admin API endpoints.
Note that while the key is copied, you must configure all required values separately in the production environment; configuration settings are not copied automatically.


API Endpoints

The developer and production endpoints for the Admin API use slightly different paths.
The examples in the how-to section use the developer paths; the table below shows the production version of the base path.

Environment Admin API base URI eSignature API base URI
Developer https://api-d.docusign.net/management/ https://demo.docusign.net/
Production https://api.docusign.net/management/ https://{server}.docusign.net/

Configure Docusign in Cortex

Parameter Description Required
Server URL   True
Integration Key   True
User ID   True
Redirect URL   True
Private Key   True
Account ID For fetching user data only False
Organization ID For fetching user data only False
Fetch events   False
Event types to fetch   False
Maximum number of customer events per fetch Due to API limitations, the maximum is 2000. False
Maximum number of user data events per fetch Due to API limitations, the maximum is 1250. False
Trust any certificate (not secure)   False
Use system proxy settings   False

Commands

You can execute these commands from the CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room showing the command details.

docusign-generate-consent-url


Generates the Docusign admin consent URL based on configured parameters and environment.

Base Command

docusign-generate-consent-url

Input

There is no input for this command.

Context Output

There is no context output for this command.

Command Example

!docusign-generate-login-url

Human Readable Output

Docusign Consent URL

[Click here to authorize]

docusign-auth-test


Tests the full authentication flow and API connectivity by validating configuration, exchanging a JWT for an access token, and calling the /oauth/userinfo endpoint.

Base Command

docusign-auth-test

Input

There is no input for this command.

Context Output

There is no context output for this command.

Command Example

!docusign-auth-test

Human Readable Output

ok

docusign-get-events


Fetches events from Docusign. Used for debugging purposes on failures.

Base Command

docusign-get-events

Input

Argument Name Description Required
event_type The type of events to fetch. Possible values are: Customer events, Audit Users. True
limit Maximum number of events to fetch, default is 10. False

Context Output

There is no context output for this command.

Command Example

!docusign-get-events event_type="Customer events" limit=10

Human Readable Output

Docusign Customer events (fetched 10)

eventId timestamp accountId
11111111-1111-1111-1111-111111111111 2024-06-30T07:08:06Z 11111111-1111-1111-1111-111111111111

docusign-reset-access-token


Resets the access token stored in the integration context.

Base Command

docusign-reset-access-token

Input

There is no input for this command.

Context Output

There is no context output for this command.

Configuration parameters

  • url — Server URL (required)
  • integration_key — Integration Key (required)
  • user_id — User ID (required)
  • redirect_url — Redirect URL (required)
  • credentials — (required)
  • account_id — Account ID
  • organization_id — Organization ID
  • isFetch — Fetch events
  • event_types — Event types to fetch
  • max_user_events_per_fetch — Maximum number of audit user events to fetch per request
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (4)

  • docusign-auth-test

    Tests the full authentication flow and API connectivity by validating configuration, exchanging a JWT for an access token, and calling the /oauth/userinfo endpoint.

  • docusign-generate-consent-url

    Generates the Docusign Admin consent URL based on the configured parameters and environment.

  • docusign-get-events

    Fetches events from Docusign. Used for debugging purposes on failures.

  • docusign-reset-access-token

    Resets the access token stored in the integration context.

import demistomock as demisto  # noqa: F401
from CommonServerPython import *
import urllib3
import datetime as dt
import time
from typing import Any
import jwt  # PyJWT
from urllib.parse import urlparse


# Disable insecure warnings
urllib3.disable_warnings()

""" CONSTANTS """

VENDOR = "docusign"
PRODUCT = "docusign"

LOG_PREFIX = "[Docusign]"

# yml parameter names
CUSTOMER_EVENTS_TYPE = "Customer events"
USER_DATA_TYPE = "Audit Users"

# yml default values
DEFAULT_SERVER_DEV_URL = "https://account.docusign.com"
MAX_USER_DATA_PER_FETCH = 1250

# API limits
MAX_USER_DATA_PER_PAGE = 250
# Due to API limitations, a maximum of 2,000 items can be fetched per request. Only one request per minute is recommended.
MAX_CUSTOMER_EVENTS_PER_FETCH = 2000

# scopes
CUSTOMER_EVENTS_SCOPE = ["signature", "impersonation"]
USER_DATA_SCOPE = ["organization_read", "user_read", "signature", "impersonation"]

SCOPES_PER_FETCH_TYPE = {
    CUSTOMER_EVENTS_TYPE: CUSTOMER_EVENTS_SCOPE,
    USER_DATA_TYPE: USER_DATA_SCOPE,
}

SERVER_PROD_URL = "https://account.docusign.com"


class CustomerEventsClient(BaseClient):
    """
    Client for DocuSign Monitor API to fetch customer events.
    Extends BaseClient to support proxy configuration and proper HTTP request handling.
    """

    def __init__(self, server_url: str, proxy: bool = False, verify: bool = True):
        """Initialize the Customer Events Monitor client.

        Args:
            server_url: Base URL for the Docusign Monitor API (developer/production environment URI)
            proxy: Whether to use a proxy for requests
            verify: Whether to verify SSL certificates
        """
        env = get_env_from_server_url(server_url)
        base_url = urljoin(self.get_monitor_base_url(env), "api/v2.0/datasets/monitor/stream")
        super().__init__(base_url=base_url, verify=verify, proxy=proxy)

    def get_monitor_base_url(self, env: str) -> str:
        return "https://lens-d.docusign.net" if env == "dev" else "https://lens.docusign.net"

    def get_customer_events_request(self, cursor: str, limit: int, access_token: str) -> dict:
        """Send GET request to fetch a stream of customer events from Docusign Monitor API."""

        headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json", "Content-Type": "application/json"}
        params = {"cursor": cursor, "limit": limit}

        demisto.debug(f"{LOG_PREFIX}Requesting customer events\nParams: {params}")
        resp = self._http_request(method="GET", headers=headers, params=params, resp_type="json")
        return resp


class UserDataClient(BaseClient):
    """
    Docusign Audit Users client for fetching audit users from the Docusign Admin API.

    Args:
        account_id: Docusign account ID
        organization_id: Docusign organization ID
        env: Docusign environment (dev/production)
        proxy: Whether to use a proxy for requests
        verify: Whether to verify SSL certificates
    """

    def __init__(self, account_id: str, organization_id: str, env: str, proxy: bool = False, verify: bool = True):
        super().__init__(base_url="", verify=verify, proxy=proxy)
        self.account_id = account_id
        self.organization_id = organization_id
        self.env = env

    def get_admin_base_url(self) -> str:
        return "https://api-d.docusign.net" if self.env == "dev" else "https://api.docusign.net"

    def get_users_request(self, access_token: str, url: str) -> dict:
        """
        Docusign Admin API
        """
        headers = {"Authorization": f"Bearer {access_token}"}
        resp = self._http_request(method="GET", full_url=url, headers=headers, resp_type="json")
        return resp

    def get_users_first_request(self, access_token: str, request_params: Dict[str, Any]) -> tuple[dict, str]:
        """
        Docusign Admin API
        """
        base = self.get_admin_base_url()
        url = urljoin(base, f"management/v2/organizations/{self.organization_id}/users")

        headers = {"Authorization": f"Bearer {access_token}"}
        request_params["account_id"] = self.account_id
        resp = self._http_request(method="GET", full_url=url, headers=headers, params=request_params, resp_type="json")
        return resp, url

    def get_user_detail(self, base_uri: str, user_id: str, access_token: str) -> Dict[str, Any]:
        """
        eSignature REST API
        """
        url = urljoin(base_uri, f"restapi/v2.1/accounts/{self.account_id}/users/{user_id}")
        resp = self._http_request(
            method="GET", full_url=url, headers={"Authorization": f"Bearer {access_token}"}, resp_type="json"
        )
        return resp


def remove_duplicate_users(fetched_users: list, ids_to_remove: list, time_to_remove: str) -> list:
    """remove users from fetched_users whose id appears in ids_to_remove and whose their _time field equals to time_to_remove"""
    if not fetched_users or not ids_to_remove:
        return fetched_users

    ids_to_remove = set(ids_to_remove)
    filtered_users = []
    for user in fetched_users:
        if not (user.get("id") in ids_to_remove and user.get("_time") == time_to_remove):
            filtered_users.append(user)

    if not filtered_users:
        demisto.debug(f"{LOG_PREFIX}No users remain for this request after removing duplicates.")

    return filtered_users


def get_env_from_server_url(server_url: str) -> str:
    try:
        parsed_url = urlparse(server_url)
        hostname = parsed_url.hostname or ""

        return "dev" if hostname == "account-d.docusign.com" else "prod"
    except Exception:
        demisto.debug(f"{LOG_PREFIX}Failed to parse server URL: {server_url}")
        raise DemistoException(f"Failed to parse server URL: {server_url}")


def _utcnow() -> dt.datetime:
    return dt.datetime.now()


class AuthClient(BaseClient):
    def __init__(
        self, server_url: str, integration_key: str, user_id: str, private_key_pem: str, verify: bool = True, proxy: bool = False
    ) -> None:
        """
        Initialize the Docusign authenticator.

        Args:
            server_url: Base URL for the Docusign API
            integration_key: Docusign Integration Key (OAuth Client ID)
            user_id: Docusign User ID used as the JWT subject
            private_key_pem: RSA private key in PEM format for JWT signing
            verify: Whether to verify SSL certificates
            proxy: Whether to use a proxy for requests
        """
        super().__init__(base_url="", verify=verify, proxy=proxy)
        self.server_url = server_url.rstrip("/")
        self.integration_key = integration_key
        self.user_id = user_id

        self.private_key_pem = self.validate_private_key(private_key_pem)

        self.access_token = self.get_access_token()

    def get_access_token(self) -> str:
        """
        Generate a JWT and exchange it for access token for the Docusign API OAuth flow.

        This function first checks if an access token already exists in the integration context.
        If not found, it exchanges the JWT for an access token using Docusign's OAuth token endpoint.

        Args:
            client (AuthClient): AuthClient instance used for making requests

        Returns:
            str: Access token for Docusign API authentication
        Note:
            The access token is stored in the integration context for reuse in subsequent calls.
        """
        params = demisto.params()
        selected_fetch_types = params.get("event_types", "")
        required_scopes = get_scopes_per_type(selected_fetch_types)

        integration_context = get_integration_context()
        access_token = integration_context.get("access_token", "")
        expired_at = integration_context.get("expired_at", "")
        access_token_scopes = integration_context.get("access_token_scopes", [])
        consent_scopes = integration_context.get("consent_scopes", [])
        demisto.debug(f"{LOG_PREFIX}required scopes: {required_scopes}\nconsent scopes: {consent_scopes}")

        # Step 1: Check if we have enough consent scopes to generate a valid access token according to the selected fetch types
        if not is_required_scopes_set(required_scopes, consent_scopes):
            message = "Please run docusign-generate-consent-url command to generate a new consent URL for your fetch types."
            demisto.debug(message)
            raise DemistoException(message)

        # Step 2: Check if an access token exists and is usable
        if access_token:
            token_is_expired = is_access_token_expired(expired_at)
            has_required_scopes = is_required_scopes_set(required_scopes, access_token_scopes)

            if not token_is_expired and has_required_scopes:
                demisto.debug(f"{LOG_PREFIX}Access token is valid. Using existing access token.")
                return access_token

        # Step 3: Generate a new access token if needed
        demisto.debug(f"{LOG_PREFIX}Generating a new Access token.")
        try:
            access_token, expired_at, scope = self.get_token(consent_scopes)

            integration_context.update({"access_token": access_token, "expired_at": expired_at, "access_token_scopes": scope})
            set_integration_context(integration_context)

            demisto.debug(
                f"{LOG_PREFIX}Access token received successfully and set to integration context."
                f"\nexpired_at: {expired_at}\nscope: {scope}"
            )

            return access_token

        except Exception as e:
            demisto.debug(f"{LOG_PREFIX}Error retrieving access token: {str(e)}")
            raise DemistoException(f"Error retrieving access token: {str(e)}")

    def validate_private_key(self, private_key_pem: str) -> str:
        """Validate the private key format."""

        prefix = "-----BEGIN RSA PRIVATE KEY-----"
        suffix = "-----END RSA PRIVATE KEY-----"

        if not private_key_pem.strip().startswith(prefix):
            demisto.debug(f"{LOG_PREFIX}Private key must start with {prefix}")
            raise DemistoException(f"Private key must start with {prefix}")
        if not private_key_pem.strip().endswith(suffix):
            demisto.debug(f"{LOG_PREFIX}Private key must end with {suffix}")
            raise DemistoException(f"Private key must end with {suffix}")

        private_key_pem = private_key_pem.replace(prefix, "").replace(suffix, "")
        private_key_sections = private_key_pem.strip().split(" ")
        striped_private_key = "".join(private_key_sections)
        formatted_private_key = prefix + "\n" + striped_private_key + "\n" + suffix

        return formatted_private_key

    def get_jwt(self, scopes: str) -> str:
        """Generate a JWT for authentication.

        Returns:
            str: Signed JWT token

        """
        demisto.debug(f"{LOG_PREFIX}Generating JWT for authentication.")
        now = _utcnow()
        headers = {"alg": "RS256", "typ": "JWT"}
        payload = {
            "iss": self.integration_key,
            "sub": self.user_id,
            "aud": self.server_url.replace("https://", "").replace("http://", ""),
            "iat": int(now.timestamp()),
            "exp": int((now + dt.timedelta(hours=1)).timestamp()),
            "scope": scopes,
        }
        token = jwt.encode(payload, self.private_key_pem, algorithm="RS256", headers=headers)
        return token

    def exchange_jwt_to_access_token(self, jwt: str) -> tuple[str, str, str]:
        """Exchange a JWT for an access token."""

        url = urljoin(self.server_url, "/oauth/token")
        data = {
            "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
            "assertion": jwt,
        }
        headers = {"Content-Type": "application/x-www-form-urlencoded"}

        resp = self._http_request(method="POST", full_url=url, headers=headers, data=data, resp_type="json")

        access_token = resp.get("access_token")
        expires_in_seconds = resp.get("expires_in")
        scope = resp.get("scope")

        if not access_token or not expires_in_seconds:
            demisto.error(f"{LOG_PREFIX}: Token exchange failed, response missing access_token or expires_in\n{resp}")
            raise DemistoException(f"Token exchange failed, response missing access_token or expires_in\n{resp}")

        expired_at = (_utcnow() + dt.timedelta(seconds=expires_in_seconds)).strftime("%Y-%m-%dT%H:%M:%SZ")
        return access_token, expired_at, scope

    def get_user_info(self, access_token: str) -> dict:
        """Fetch user information from /oauth/userinfo endpoint."""

        url = urljoin(self.server_url, "/oauth/userinfo")
        headers = {"Authorization": f"Bearer {access_token}"}
        resp = self._http_request(method="GET", full_url=url, headers=headers, resp_type="json")
        return resp

    def get_base_uri(self, access_token: str, account_id: str) -> str:
        """Fetch user information including the base_uri for the account.

        Args:
            access_token: Valid access token
            account_id: Account ID to fetch base URI for

        Returns:
            str: Base URI for the user's account
        """

        integration_context = get_integration_context()
        base_uri = integration_context.get("base_uri")
        if base_uri:
            demisto.debug(f"{LOG_PREFIX}: using base_uri from integration context: {base_uri}")
            return base_uri

        demisto.debug(f"{LOG_PREFIX}: fetching base_uri from /oauth/userinfo")
        user_info = self.get_user_info(access_token)

        accounts = user_info.get("accounts", [])
        if not accounts:
            demisto.debug(f"{LOG_PREFIX}: /oauth/userinfo missing accounts.")
            raise DemistoException("Docusign: /oauth/userinfo missing accounts.")

        for account in accounts:
            if account.get("account_id") == account_id:
                base_uri = account.get("base_uri")

                # Set the base_uri for future runs; it remains constant for the account.
                integration_context.update({"base_uri": base_uri})
                set_integration_context(integration_context)
                demisto.debug(f"{LOG_PREFIX}update integration context with base_uri: {base_uri}")

                return base_uri

        demisto.debug(f"{LOG_PREFIX}: /oauth/userinfo missing configuration account id: {account_id}.")
        raise DemistoException(f"/oauth/userinfo missing configuration account id: {account_id}.")

    def get_token(self, scopes: list[str]) -> tuple[str, str, str]:
        """Exchange JWT for access token using Docusign OAuth flow."""
        scope_str = " ".join(scopes)
        jwt_token = self.get_jwt(scope_str)
        access_token, expired_at, scope = self.exchange_jwt_to_access_token(jwt_token)
        return access_token, expired_at, scope


def is_access_token_expired(expired_at: str) -> bool:
    """Check if access token is expired."""

    is_not_expired = expired_at > (_utcnow() + dt.timedelta(minutes=15)).strftime("%Y-%m-%dT%H:%M:%SZ")
    if is_not_expired:
        demisto.debug(f"{LOG_PREFIX}using existing Access token from integration context (expires at {expired_at}).")
        return False
    else:
        demisto.debug(f"{LOG_PREFIX}Access token expired.")
        return True


def is_required_scopes_set(required_scopes: list[str], target_scopes: list[str]) -> bool:
    """Check if all required scopes included in target scopes list."""
    return all(scope in target_scopes for scope in required_scopes)


def get_customer_events(last_run: dict, limit: int, client: CustomerEventsClient, access_token: str) -> tuple[list, dict]:
    """Fetch customer events from Docusign Monitor API.

    Args:
        client: Initialized Docusign client
        last_run: Previous fetch state containing cursor
        limit: Maximum number of events to fetch  (API parameter does not currently work)
    Returns:
        tuple: (events, last_run) where events are the fetched events and last_run is the updated state.

    """
    one_minute_ago = timestamp_to_datestring(int(time.time() - 60) * 1000, date_format="%Y-%m-%dT%H:%M:%SZ")
    # note for developer - cursor is returned from the API even if the response is empty
    cursor = last_run.get("cursor") or one_minute_ago
    demisto.debug(f"{LOG_PREFIX}Customer Events: requesting {limit} events with cursor: {cursor}")

    try:
        resp = client.get_customer_events_request(cursor, limit, access_token)

        cursor = resp.get("endCursor")
        total_events = resp.get("data", [])
        demisto.debug(f"Total customer events fetched: {len(total_events)}")

        if not total_events:
            demisto.info(f"{LOG_PREFIX} Customer Events: No data available for cursor: {cursor}")

        last_run["cursor"] = cursor
        return total_events, last_run

    except Exception as e:
        demisto.debug(f"{LOG_PREFIX}Exception during get customer events. Exception is {e!s}")
        raise DemistoException(f"Exception during get customer events. Exception is {e!s}")


def get_remaining_user_data(last_run: dict, client: UserDataClient, access_token: str, limit: int) -> tuple[list, dict]:
    """
    Fetch only users that were not retrieved in the last page from the previous run
    """
    excess_users_info = last_run.get("excess_users_info", {})
    if not excess_users_info:
        demisto.debug(f"{LOG_PREFIX}No excess users info found in last run.")
        return [], last_run

    offset = excess_users_info.get("offset")
    prev_url = excess_users_info.get("url")

    try:
        response = client.get_users_request(access_token, prev_url)
        fetched_users = response.get("users", [])[offset:]
        last_run["excess_users_info"] = None

        demisto.debug(
            f"{LOG_PREFIX}Fetched {len(fetched_users)} excess users, "
            f"limit changes from {limit} to {limit - len(fetched_users)}"
            f"last_run after fetching remaining users: {last_run}"
        )

        return fetched_users, last_run

    except Exception as e:
        demisto.debug(f"{LOG_PREFIX}Exception during get remaining audit users. Exception is {e!s}")
        raise DemistoException(f"Exception during get remaining audit users. Exception is {e!s}")


def fetch_audit_user_data(last_run: dict, auth_client: AuthClient, limit: int, test_mode: bool = False) -> tuple[dict, list]:
    limit = min(limit, MAX_USER_DATA_PER_FETCH)
    users_per_page = min(MAX_USER_DATA_PER_PAGE, limit)
    users = []
    access_token = auth_client.access_token
    try:
        demisto.debug(f"{LOG_PREFIX} last_run before fetching audit users: {last_run}")
        user_data_client = initiate_user_data_client()

        # Handle fetching excess users from last page fetched
        if last_run.get("excess_users_info"):
            excess_users_from_last_page, last_run = get_remaining_user_data(last_run, user_data_client, access_token, limit)
            users.extend(excess_users_from_last_page)
            limit -= len(excess_users_from_last_page)

        # ---------- STEP 1: Fetch users ----------
        fetched_users, last_run = get_user_data(last_run, limit, users_per_page, user_data_client, access_token)
        users.extend(fetched_users)
        # ---------- STEP 2: Fetch user details ----------
        if not test_mode:
            base_uri = auth_client.get_base_uri(access_token, user_data_client.account_id)

            latest_modified_dt = last_run.get("latest_modifiedDate")
            latest_modified_dt = datetime.strptime(latest_modified_dt, "%Y-%m-%dT%H:%M:%SZ") if latest_modified_dt else None

            users, latest_modified = get_user_details(users, base_uri, access_token, user_data_client, latest_modified_dt)

            # Persist the latest modifiedDate users for next fetch
            last_run["latest_modifiedDate"] = latest_modified

    except Exception as e:
        demisto.debug(f"{LOG_PREFIX}Exception during fetch audit users.\n{e!s}")
        raise DemistoException(f"Exception during fetch audit users.\n{e!s}")

    return last_run, users


def get_user_details(
    users: list, base_uri: str, access_token: str, client: UserDataClient, latest_modified_dt: datetime | None
) -> tuple[list[Any], str | None]:
    """
    eSignature REST API.
    Fetches user details for each user in the list.
    Returns the updated list of users and the latest modifiedDate.
    latest_modified value must be tracked because the response of the Admin API is not sorted by modifiedDate.

    returns:
        tuple[list, str]: The updated list of users and the latest modifiedDate.
        users: The updated list of users after enrichment
        latest_modified_iso: The latest modifiedDate in ISO format.
    """
    start_time = time.perf_counter()

    for user in users:
        user_data = client.get_user_detail(base_uri, user.get("id"), access_token)

        modified_date = user_data.get("userSettings", {}).get("modifiedDate")
        if modified_date:
            try:
                md_dt = datetime.strptime(modified_date, "%m/%d/%Y %I:%M:%S %p")
                user["_time"] = md_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
                user["source_log_type"] = "auditusers"

                # fine the latest modifiedDate
                if latest_modified_dt is None or md_dt > latest_modified_dt:
                    latest_modified_dt = md_dt

            except Exception as ex:
                demisto.debug(
                    f"{LOG_PREFIX}Failed to parse modifiedDate. user:{user.get('id')} with modifiedDate:{modified_date}"
                    f"error:{ex!s}"
                )

    latest_modified_iso = latest_modified_dt.strftime("%Y-%m-%dT%H:%M:%SZ") if latest_modified_dt else None

    end_time = time.perf_counter()
    demisto.debug(f"{LOG_PREFIX} get_user_details took {end_time - start_time}s, retrieved modifiedDate from {len(users)} users")

    return users, latest_modified_iso


def get_user_data(
    last_run: dict, limit: int, users_per_page: int, client: UserDataClient, access_token: str
) -> tuple[list, dict]:
    start = time.perf_counter()
    total_events: list = []
    remaining_to_fetch = 0

    try:
        # A next URL exists from the last response, indicating that fetching continues from the previous run.
        if last_run.get("continuing_fetch_info"):
            url = last_run.get("continuing_fetch_info", {}).get("url")
            request_params = {}
            demisto.debug(f"{LOG_PREFIX}Continuing fetch for Audit data from:\n {url}")
        # First request in the current time range.
        else:
            one_minute_ago = timestamp_to_datestring(int(time.time() - 60) * 1000, date_format="%Y-%m-%dT%H:%M:%SZ")
            url = ""
            request_params = {
                "start": 0,
                "take": users_per_page,  # api limit - max 250
                "last_modified_since": last_run.get("latest_modifiedDate") or one_minute_ago,
            }
        demisto.debug(f"{LOG_PREFIX}Starting new fetch range for Audit Users data.\n{request_params=}")

        """
        The loop exits when the first of the following conditions is met:
        1. len(total_events) >= limit
        2. next_url is None
        """
        while len(total_events) < limit:
            remaining_to_fetch = limit - len(total_events)
            demisto.debug(f"{LOG_PREFIX}Starting to fetch new request of users.\n{remaining_to_fetch=}")

            if url:  # when the last page returned a next_url
                resp = client.get_users_request(access_token, url)
            else:
                resp, url = client.get_users_first_request(access_token, request_params)

            next_url = resp.get("paging", {}).get("next", "")
            users = resp.get("users", [])
            total_events.extend(users)
            demisto.debug(f"{LOG_PREFIX}Successfully fetched {len(users)} users events.")

            if not next_url:  # last page reached
                demisto.debug(f"{LOG_PREFIX}No more pages in the current time range.")

                if len(users) > remaining_to_fetch:
                    demisto.debug(f"{LOG_PREFIX} There are more users than remaining to fetch.")
                    last_run["excess_users_info"] = {"offset": remaining_to_fetch, "url": url}
                    demisto.debug(
                        f"{LOG_PREFIX} Setting excess_users_info for next fetch: {last_run['excess_users_info']}\n"
                        f"{LOG_PREFIX} Truncated total fetched users from {len(total_events)} to limit: {limit}"
                    )
                    total_events = total_events[:limit]

                last_run["continuing_fetch_info"] = None
                end_time = time.perf_counter()
                demisto.debug(f"{LOG_PREFIX} get_user_data took {end_time - start}s, fetched {len(total_events)} users")
                return total_events, last_run

            prev_url = url
            url = next_url

        # At this point, the limit has been reached before next_url is None (there are more pages to fetch).
        last_run["continuing_fetch_info"] = {"url": url}
        demisto.debug(
            f"{LOG_PREFIX} limit is reached and there are more pages to fetch"
            f"setting continuing_fetch_info for next fetch: {last_run['continuing_fetch_info']}"
        )

        if len(users) > remaining_to_fetch:
            last_run["excess_users_info"] = {"offset": remaining_to_fetch, "url": prev_url}

            demisto.debug(
                f"{LOG_PREFIX}Limit is reached and only partial users were fetched from the last page scanning.\n"
                f"Setting excess_users_info: {last_run['excess_users_info']}"
            )

        end_time = time.perf_counter()
        demisto.debug(f"{LOG_PREFIX} get_user_data took {end_time - start}s, fetched {len(total_events)} users")
        return total_events[:limit], last_run

    except Exception as e:
        demisto.debug(f"{LOG_PREFIX}Exception during get audit users events. Exception is {e!s}")
        raise DemistoException(f"Exception during get audit users events. Exception is {e!s}")


def initiate_user_data_client() -> UserDataClient:
    """
    Create a UserDataClient for interacting with the Docusign Admin API to fetch audit users and user details.
    """
    params = demisto.params()
    proxy = params.get("proxy", False)
    verify = not params.get("insecure", False)
    account_id = params.get("account_id", "")
    organization_id = params.get("organization_id", "")
    server_url = params.get("url", DEFAULT_SERVER_DEV_URL)
    env = get_env_from_server_url(server_url)

    if not account_id or not organization_id:
        demisto.debug(f"{LOG_PREFIX} initiate_user_data_client function: Account ID or Organization ID is missing.")
        raise DemistoException("initiate_user_data_client function: Account ID or Organization ID is missing.")

    return UserDataClient(account_id=account_id, organization_id=organization_id, env=env, proxy=proxy, verify=verify)


def initiate_customer_events_client() -> CustomerEventsClient:
    """
    Create a CustomerEventsClient for interacting with the Docusign Monitor API to fetch customer events.
    """
    params = demisto.params()
    proxy = params.get("proxy", False)
    verify = not params.get("insecure", False)
    server_url = params.get("url", DEFAULT_SERVER_DEV_URL)

    return CustomerEventsClient(server_url=server_url, proxy=proxy, verify=verify)


def initiate_auth_client() -> AuthClient:
    """
    Create an AuthClient for making requests to the Docusign API authentication flow.

    Returns:
        AuthClient: AuthClient instance
    """

    params = demisto.params()
    server_url = params.get("url", DEFAULT_SERVER_DEV_URL)
    integration_key = params.get("integration_key", "")
    user_id = params.get("user_id", "")
    private_key_pem = params.get("credentials", {}).get("password", "")
    verify = not params.get("insecure", False)
    proxy = params.get("proxy", False)

    if not integration_key or not user_id or not private_key_pem:
        demisto.debug(f"{LOG_PREFIX}initiate_auth_client function: Integration Key, User ID  or  Private Key is missing.")
        raise DemistoException("initiate_auth_client function: Integration Key, User ID  or  Private Key is missing.")

    auth = AuthClient(
        server_url=server_url,
        integration_key=integration_key,
        user_id=user_id,
        private_key_pem=private_key_pem,
        verify=verify,
        proxy=proxy,
    )
    return auth


def fetch_customer_events(last_run: dict, access_token: str, limit: int = MAX_CUSTOMER_EVENTS_PER_FETCH) -> tuple[dict, list]:
    """Fetch customer events from Docusign Monitor API.

    Note to developer:
    MAX_CUSTOMER_EVENTS_PER_FETCH is set to 2000 due to API limitation.
    The limit parameter does not work as expected on the API side, so it is not currently supported in the configuration.

    Args:
        last_run: Previous fetch state containing cursor.
        access_token: Valid access token for the Docusign API.
        limit: Number of events to fetch. Defaults to MAX_CUSTOMER_EVENTS_PER_FETCH (2000).
    """
    limit = min(limit, MAX_CUSTOMER_EVENTS_PER_FETCH)
    try:
        demisto.debug(f"{LOG_PREFIX} last_run before fetching customer events: {last_run}")
        client = initiate_customer_events_client()
        customer_events, last_run = get_customer_events(last_run, limit, client, access_token)

    except Exception as e:
        demisto.debug(f"{LOG_PREFIX}Exception during fetch customer events.\n{e!s}")
        raise DemistoException(f"Exception during fetch customer events.\n{e!s}")

    add_fields_to_customer_events(customer_events)
    return last_run, customer_events


def add_fields_to_customer_events(customer_events: list) -> None:
    for event in customer_events:
        event["source_log_type"] = "customerevent"
        t_without_milliseconds = event.get("timestamp").split(".")[0]
        dt = datetime.strptime(t_without_milliseconds, "%Y-%m-%dT%H:%M:%S") if t_without_milliseconds else None
        time_value = dt.strftime("%Y-%m-%dT%H:%M:%SZ") if dt else None
        event["_time"] = time_value


def fetch_events(auth_client: AuthClient) -> tuple[dict, list]:
    """
    Fetch events from Docusign based on configuration. (Customer events and Audit users)

    Returns:
        tuple: (last_run, events) where last_run is the updated state and events are the fetched events.
    """
    total_start = time.perf_counter()
    events = []
    params = demisto.params()

    last_run = demisto.getLastRun()
    if not last_run:
        last_run = {CUSTOMER_EVENTS_TYPE: {}, USER_DATA_TYPE: {}}
        demisto.debug(f"{LOG_PREFIX}Empty last run object, initializing new last run object {last_run}")

    last_run_customer_events = last_run.get(CUSTOMER_EVENTS_TYPE, {})
    last_run_user_data = last_run.get(USER_DATA_TYPE, {})

    selected_fetch_types = params.get("event_types", "")
    demisto.debug(f"{LOG_PREFIX}Selected fetch types: {selected_fetch_types}")

    if CUSTOMER_EVENTS_TYPE in selected_fetch_types:
        start = time.perf_counter()
        demisto.info(f"{LOG_PREFIX}Start fetch customer events, Current customer events last_run:\n{last_run_customer_events}")
        last_run_customer_events, fetched_customer_events = fetch_customer_events(
            last_run_customer_events, auth_client.access_token
        )
        events.extend(fetched_customer_events)

        elapsed = time.perf_counter() - start
        demisto.debug(f"{LOG_PREFIX}finished fetching customer events: {len(fetched_customer_events)} in {elapsed:.3f}s")

    if USER_DATA_TYPE in selected_fetch_types:
        start = time.perf_counter()
        demisto.info(f"{LOG_PREFIX}Start fetch audit users, Current audit users last_run:\n{last_run_user_data}")
        user_data_limit = min(MAX_USER_DATA_PER_FETCH, int(params.get("max_user_events_per_fetch", MAX_USER_DATA_PER_FETCH)))
        last_run_user_data, fetched_user_data = fetch_audit_user_data(last_run_user_data, auth_client, limit=user_data_limit)
        events.extend(fetched_user_data)

        elapsed = time.perf_counter() - start
        demisto.debug(f"{LOG_PREFIX}finished fetching audit users: {len(fetched_user_data)} in {elapsed:.3f}s")

    elapsed = time.perf_counter() - total_start
    demisto.debug(f"{LOG_PREFIX}finished running fetch_events.\n total events: {len(events)} in {elapsed:.3f}s")
    last_run = {CUSTOMER_EVENTS_TYPE: last_run_customer_events, USER_DATA_TYPE: last_run_user_data}
    return last_run, events


def get_scopes_per_type(selected_fetch_types: str) -> list:
    scopes = []
    if CUSTOMER_EVENTS_TYPE in selected_fetch_types:
        scopes += SCOPES_PER_FETCH_TYPE[CUSTOMER_EVENTS_TYPE]
    if USER_DATA_TYPE in selected_fetch_types:
        scopes += SCOPES_PER_FETCH_TYPE[USER_DATA_TYPE]
    return scopes


def generate_consent_url() -> CommandResults:
    """Generate a consent URL for Docusign OAuth flow.

    Returns:
        CommandResults: Results to return to Demisto
    """
    demisto.debug(f"{LOG_PREFIX}Generating consent URL")
    params = demisto.params()

    selected_fetch_types = params.get("event_types", "")
    if not selected_fetch_types:
        demisto.debug(f"{LOG_PREFIX}Please select Event Types before running docusign-generate-consent-url.")
        raise DemistoException("Please select Event Types before running docusign-generate-consent-url.")

    integration_context = get_integration_context()
    required_scopes = get_scopes_per_type(selected_fetch_types)
    consent_scopes = integration_context.get("consent_scopes", [])

    is_all_required_scopes_consent = True
    for scope in required_scopes:
        if scope not in consent_scopes:
            is_all_required_scopes_consent = False
            consent_scopes.append(scope)

    # If all required scopes for the selected types are already set
    if is_all_required_scopes_consent:
        message = "All consent scopes are already set. No need to generate consent URL for those selected types."
        demisto.debug(message)
        return CommandResults(readable_output=message)

    # not all required scopes are set, validate authentication parameters before generating the consent URL
    server_url = params.get("url", DEFAULT_SERVER_DEV_URL).rstrip("/")
    integration_key = params.get("integration_key", "")
    redirect_url = params.get("redirect_url", "")

    if not server_url or not integration_key or not redirect_url:
        message = "Please provide Server URL, Integration Key and Redirect URL before running docusign-generate-consent-url."
        demisto.debug(f"{LOG_PREFIX}{message}")
        raise DemistoException(message)

    # generate the consent URL including the newly required scopes
    scopes = "%20".join(consent_scopes)
    params = f"response_type=code&scope={scopes}&client_id={integration_key}&redirect_uri={redirect_url}"
    consent_url = f"{server_url}/oauth/auth?" + params

    # set the new consent scopes for the next authentication step
    integration_context.update({"consent_scopes": consent_scopes})
    set_integration_context(integration_context)
    demisto.debug(f"{LOG_PREFIX}generated consent URL with the scopes: {consent_scopes}")

    return CommandResults(readable_output=f"### Docusign Consent URL\n[Click here to authorize]({consent_url})")


def validate_configuration_params() -> str:
    params = demisto.params()
    selected_fetch_types = params.get("event_types", "")

    # Validate authentication parameters
    if (
        not params.get("url")
        or not params.get("redirect_url")
        or not params.get("integration_key")
        or not params.get("user_id")
        or not params.get("credentials", {}).get("password", "")
    ):
        return "Please provide Server URL, Integration Key and Redirect URL for authentication flow."

    # Validate parameters for fetching audit users events
    if USER_DATA_TYPE in selected_fetch_types and (not params.get("account_id") or not params.get("organization_id")):
        return f"Please provide Account ID and Organization ID for fetching {USER_DATA_TYPE}."

    return "ok"


def test_module() -> str:
    """Test the Docusign integration configuration by validating parameters , test Auth and making an API call.
    Returns:
        str: 'ok' if the configuration is valid and the API call succeeds, otherwise an error message.
    """
    validation_result = validate_configuration_params()
    if validation_result != "ok":
        return validation_result

    try:
        auth_client = initiate_auth_client()
    except DemistoException as e:
        return str(e)

    # Verify the access token works by calling the /oauth/userinfo endpoint
    auth_client.get_user_info(auth_client.access_token)
    demisto.debug(f"{LOG_PREFIX}test-module: API call to /oauth/userinfo succeeded.")
    return "Test completed successfully."


def get_events_command(auth_client: AuthClient) -> CommandResults:
    """Manual command to fetch Docusign events and display them in the War Room.

    Args:
        auth_client: Authenticated AuthClient instance.

    Returns:
        CommandResults: Human-readable table and raw events.
    """
    args = demisto.args()
    event_type: str = args.get("event_type")
    limit: int = arg_to_number(args.get("limit", 10), required=True)  # type: ignore[assignment]

    events = []

    if event_type == CUSTOMER_EVENTS_TYPE:
        _, customer_events = fetch_customer_events(last_run={}, access_token=auth_client.access_token, limit=limit)
        events = customer_events

    elif event_type == USER_DATA_TYPE:
        _, user_events = fetch_audit_user_data(last_run={}, auth_client=auth_client, limit=limit, test_mode=True)
        events = user_events

    else:
        raise DemistoException(f"Unknown event type: '{event_type}'. Must be '{CUSTOMER_EVENTS_TYPE}' or '{USER_DATA_TYPE}'.")

    readable = tableToMarkdown(
        f"Docusign {event_type} (fetched {len(events)})",
        events,
        removeNull=True,
    )

    return CommandResults(
        readable_output=readable,
        raw_response=events,
    )


def reset_access_token() -> CommandResults:
    integration_context = get_integration_context()
    integration_context.pop("access_token", None)
    integration_context.pop("expired_at", None)
    integration_context.pop("access_token_scopes", None)
    set_integration_context(integration_context)
    return CommandResults(readable_output="Access token deleted successfully from integration context.")


def main() -> None:  # pragma: no cover
    try:
        command = demisto.command()
        demisto.debug(f"{LOG_PREFIX} Processing command: {command}")

        if command == "test-module":
            return_results(
                "Please run the command 'docusign-auth-test' to test the full authentication flow and API connectivity."
            )

        elif command == "docusign-auth-test":
            return_results(test_module())

        elif command == "fetch-events":
            auth_client = initiate_auth_client()
            last_run, events = fetch_events(auth_client)
            demisto.debug(f"{LOG_PREFIX}Sending {len(events)} events to XSIAM.")
            demisto.info(f"{LOG_PREFIX}Sending {len(events)} events to XSIAM.\n{events}")

            send_events_to_xsiam(events, vendor=VENDOR, product=PRODUCT)
            demisto.debug(f"{LOG_PREFIX}Sent events to XSIAM successfully")

            demisto.setLastRun(last_run)
            demisto.debug(f"{LOG_PREFIX}Updated last_run object after fetch: {last_run}")

        elif command == "docusign-generate-consent-url":
            return_results(generate_consent_url())

        elif command == "docusign-get-events":
            auth_client = initiate_auth_client()
            return_results(get_events_command(auth_client))

        elif command == "docusign-reset-access-token":
            return_results(reset_access_token())

        else:
            raise NotImplementedError(f"{command} command is not implemented.")

    except Exception as e:
        return_error(f"{LOG_PREFIX}Failed to execute {command} command.\nError:\n{str(e)}")


if __name__ in ("__main__", "builtin", "builtins"):
    main()