BeyondTrust Privilege Management Cloud

BeyondTrust Privilege Management Cloud (PM Cloud) integration for retrieving audit events and activity logs.

Endpoint · BeyondTrust

Details

IDBeyondTrust Privilege Management Cloud
ProviderBeyondTrust
CategoryEndpoint
From Version6.10.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesXSIAM

README

BeyondTrust Privilege Management Cloud

Overview

BeyondTrust Privilege Management Cloud (PM Cloud) integration for retrieving audit events and activity logs from the BeyondTrust PM Cloud Management API v3.

This integration supports:

  • Fetching Activity Audits (audit logs of configuration changes and administrative actions)
  • Fetching Events (endpoint privilege management events)
  • Event collection for XSIAM

Configure BeyondTrust Privilege Management Cloud on Cortex XSOAR

  1. Navigate to Settings > Integrations > Servers & Services.
  2. Search for BeyondTrust Privilege Management Cloud.
  3. Click Add instance to create and configure a new integration instance.

    Parameter Description Required
    Server URL The server URL in the format: https://[yourProductionSub-domainName]-services.pm.beyondtrustcloud.com True
    Client ID The OAuth client ID from the API account created in PM Cloud. True
    Client Secret The OAuth client secret from the API account created in PM Cloud. True
    Events types to fetch Select which event types to fetch: Activity Audits, Events, or both. False
    Trust any certificate (not secure) When selected, certificates are not verified. False
    Use system proxy settings Use the system proxy settings. False
    Fetch events Enable event fetching for XSIAM. False
    Maximum number of audit events (per type) Maximum number of events to fetch per fetch cycle (default: 6000). False
    First fetch timestamp The time from which to start fetching events (e.g., “3 days”, “1 week”). False
  4. Click Test to validate the URLs, token, and connection.

Prerequisites

To create an API account in BeyondTrust PM Cloud:

  1. Sign into app.beyondtrust.io.
  2. From the top left of the page, click > Endpoint Privilege Management for Windows and Mac > Configuration.
  3. Click the Configuration menu, and then click API Settings.
  4. Click Create an API Account.
  5. Enter a name and description.
  6. The Client ID and Client Secret are automatically generated. The secret is only visible when initially generated for security reasons.
  7. Copy the Client ID and Client Secret values to use in the integration configuration.

Important Notes:

  • The instance URL can be found at the top of the API Settings page.
  • The client secret cannot be modified, but it can be regenerated on the Configuration > Settings > API Settings page.
  • Regenerating a client secret immediately invalidates any OAuth tokens associated with the account.

Commands

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

beyondtrust-pm-cloud-get-events


Retrieves events or activity audits from BeyondTrust PM Cloud. Use the event_type argument to switch between Events and Activity Audits.

Base Command

beyondtrust-pm-cloud-get-events

Input

Argument Name Description Required
event_type The type of events to retrieve. Possible values are: Events, Activity Audits. Default is Events. Optional
start_date Start Date (UTC) to search events from (Elastic Ingestion Timestamp in UTC). Format: 2022-08-12T17:34:28.694Z. If not provided, defaults to 1 hour ago. Optional
limit Maximum records that can be returned. For Events: max 1000. For Activity Audits: max 200. Default is 50. Optional
should_push_events Set to true to push events to XSIAM. Used for debugging. Possible values are: true, false. Default is false. Optional

Context Output

Path Type Description
BeyondTrust.Event.id String The ID of the event.
BeyondTrust.Event.created Date The creation time of the event.
BeyondTrust.Event.@timestamp Date The timestamp of the event.
BeyondTrust.Event.auditType String Audit type name (Activity Audits only).
BeyondTrust.Event.details String Details of the activity (Activity Audits only).
BeyondTrust.Event.entity String Name of Activity Audit entity (Activity Audits only).
BeyondTrust.Event.user String Initiated user email or API client identifier (Activity Audits only).
BeyondTrust.Event.changedBy String Audit ChangedBy - API or Portal (Activity Audits only).

Command example (Events)

!beyondtrust-pm-cloud-get-events event_type="Events" start_date="2025-01-01T00:00:00.000Z" limit="10"

Context Example (Events)

{
    "BeyondTrust": {
        "Event": [
            {
                "id": "event-123",
                "created": "2025-01-01T00:00:00.000Z",
                "@timestamp": "2025-01-01T00:00:00.000Z"
            }
        ]
    }
}

Command example (Activity Audits)

!beyondtrust-pm-cloud-get-events event_type="Activity Audits" limit="10"

Context Example (Activity Audits)

{
    "BeyondTrust": {
        "Event": [
            {
                "id": 123,
                "created": "2025-01-01T00:00:00.000Z",
                "details": "User created",
                "user": "admin@example.com",
                "entity": "User",
                "auditType": "Create",
                "changedBy": "Portal"
            }
        ]
    }
}

Event Collection

This integration supports event collection for XSIAM. When configured with Fetch events enabled, the integration will:

  1. Fetch Activity Audits and/or Events based on the Events types to fetch configuration.
  2. Add required XSIAM fields to each event:
    • _time: Set from the event’s created or @timestamp field
    • source_log_type: Set to activity_audits or events depending on the event type
    • vendor: Set to beyondtrust
    • product: Set to pm_cloud

Continuous Event Coverage

Important: The integration uses a continuous coverage approach to prevent event gaps:

  • Each fetch cycle stores the fetch start time (not the last event’s timestamp) as the next run’s start time
  • This ensures events created between fetch cycles are never missed
  • Example: If Fetch 1 runs at 12:00 and finds events up to 11:50, it stores 12:00 as the next start time
  • When Fetch 2 runs at 12:05, it fetches from 12:00 to 12:05, capturing any events created in that window
  • Deduplication is performed based on event IDs to handle any overlapping events

Event Types

Activity Audits

Activity Audits track configuration changes and administrative actions in PM Cloud, including:

  • User management (create, modify, disable)
  • Group management
  • Policy changes
  • Computer management
  • API client changes
  • Installation key changes
  • Settings modifications

Dataset: beyondtrust_pm_cloud_raw
Source Log Type: activity_audits

Events

Events track endpoint privilege management activities, including:

  • Application execution events
  • Privilege elevation events
  • Authorization requests
  • Policy enforcement events
  • Security events

Dataset: beyondtrust_pm_cloud_raw
Source Log Type: events

API Documentation

This integration uses the BeyondTrust PM Cloud Management API v3. For more information, refer to:

Known Limitations

  • The Events endpoint (FromStartDate) has a maximum record size of 1000 per request.
  • The Activity Audits endpoint has a maximum page size of 200 records.
  • This integration is developed for blind deployment and will be tested in the customer’s environment.

Troubleshooting

Authentication Issues

  • Verify the Client ID and Client Secret are correct.
  • Ensure the API account has the necessary permissions in PM Cloud.
  • Check that the Server URL is in the correct format: https://[yourProductionSub-domainName]-services.pm.beyondtrustcloud.com

No Events Fetched

  • Verify the First fetch timestamp is set appropriately.
  • Check that events exist in the specified time range.
  • Ensure Events types to fetch is configured correctly.
  • Review the integration logs for any API errors.

Token Expiration

  • OAuth tokens expire after 3600 seconds (1 hour).
  • The integration automatically refreshes tokens as needed.
  • If you regenerate the client secret in PM Cloud, update the integration configuration immediately.

Configuration parameters

  • url — Server URL (required)
  • credentials — Client ID (required)
  • events_types_to_fetch — Events types to fetch
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • isFetch — Fetch events
  • max_fetch — Maximum number of events (per type)
  • first_fetch — First fetch timestamp

Commands (1)

  • beyondtrust-pm-cloud-get-events

    This command is used for developing/debugging and is to be used with caution, as it can create events, leading to events duplication and API request limitation exceeding. Retrieves events or activity audits from BeyondTrust PM Cloud.

import json
import pytest
from freezegun import freeze_time
from datetime import datetime, UTC
from BeyondTrustPrivilegeManagementCloud import (
    Client,
    get_events_command,
    fetch_events,
    fetch_pm_events,
    fetch_activity_audits,
    get_dedup_key,
    main,
)
import BeyondTrustPrivilegeManagementCloud

""" CONSTANTS """

BASE_URL = "https://example.com"
CLIENT_ID = "client_id"
CLIENT_SECRET = "client_secret"


@pytest.fixture
def client():
    return Client(base_url=BASE_URL, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, verify=False, proxy=False)


def load_json(path):
    with open(path) as f:
        return json.load(f)


""" TEST FUNCTIONS """


def test_get_token(client, mocker):
    """
    Given:
        - A client object
    When:
        - get_token is called
    Then:
        - The function should return an access token
    """
    mock_response = {"access_token": "test_token_123", "token_type": "Bearer", "expires_in": 3600}
    mocker.patch.object(client, "_http_request", return_value=mock_response)

    token = client.get_token()
    assert token == "test_token_123"


def test_module_success(client, mocker):
    """
    Given:
        - A client object
    When:
        - test_module is called
    Then:
        - The function should return 'ok' on success
    """
    mocker.patch.object(client, "get_token", return_value="test_token")
    result = BeyondTrustPrivilegeManagementCloud.test_module(client)
    assert result == "ok"


def test_module_failure(client, mocker):
    """
    Given:
        - A client object
    When:
        - test_module is called and authentication fails
    Then:
        - The function should return an error message
    """
    mocker.patch.object(client, "get_token", side_effect=Exception("Authentication failed"))
    result = BeyondTrustPrivilegeManagementCloud.test_module(client)
    assert "Failed to connect" in result


def test_get_events_command(client, mocker):
    """
    Given:
        - A client object
        - Arguments with start_date and limit
    When:
        - get_events_command is called
    Then:
        - The command should return the expected results
    """
    mock_response = {
        "events": [{"id": "1", "created": "2022-01-01T00:00:00.000Z"}, {"id": "2", "created": "2022-01-01T00:00:01.000Z"}],
        "totalRecordsReturned": 2,
    }
    mocker.patch.object(client, "get_events", return_value=mock_response)

    args = {"start_date": "2022-01-01T00:00:00.000Z", "limit": "2"}
    result = get_events_command(client, args)

    outputs = result.outputs
    assert isinstance(outputs, list)
    assert len(outputs) == 2
    assert outputs[0]["id"] == "1"
    assert outputs[1]["id"] == "2"


def test_get_events_command_default_start_date(client, mocker):
    """
    Given:
        - A client object
        - Arguments without start_date
    When:
        - get_events_command is called
    Then:
        - The command should use default start_date (1 hour ago)
    """
    mock_response = {"events": [{"id": "1", "created": "2022-01-01T00:00:00.000Z"}]}
    mocker.patch.object(client, "get_events", return_value=mock_response)

    args = {"limit": "1"}
    result = get_events_command(client, args)

    # Verify get_events was called with a start_date
    assert client.get_events.called
    assert isinstance(result.outputs, list)


def test_get_events_command_activity_audits(client, mocker):
    """
    Given:
        - A client object
        - Arguments with event_type set to Activity Audits
    When:
        - get_events_command is called with event_type="Activity Audits"
    Then:
        - The command should return audit activity results under BeyondTrust.Event prefix
    """
    mock_response = {
        "data": [
            {"id": 1, "created": "2022-01-01T00:00:00.000Z", "details": "User created"},
            {"id": 2, "created": "2022-01-01T00:00:01.000Z", "details": "Policy updated"},
        ],
        "pageCount": 1,
        "totalRecordCount": 2,
    }
    mocker.patch.object(client, "get_audit_activity", return_value=mock_response)

    args = {"event_type": "Activity Audits", "limit": "2"}
    result = get_events_command(client, args)

    outputs = result.outputs
    assert isinstance(outputs, list)
    assert len(outputs) == 2
    assert outputs[0]["id"] == 1
    assert outputs[1]["id"] == 2
    assert result.outputs_prefix == "BeyondTrust.Event"


def test_get_events_command_activity_audits_with_start_date(client, mocker):
    """
    Given:
        - A client object
        - Arguments with event_type Activity Audits and a start_date
    When:
        - get_events_command is called
    Then:
        - The command should pass the date filter to the audit API
    """
    mock_response = {"data": [{"id": 1, "created": "2022-01-01T00:00:00.000Z"}], "pageCount": 1}
    mocker.patch.object(client, "get_audit_activity", return_value=mock_response)

    args = {
        "event_type": "Activity Audits",
        "limit": "10",
        "start_date": "2022-01-01T00:00:00.000Z",
    }
    result = get_events_command(client, args)

    # Verify the method was called
    client.get_audit_activity.assert_called_once()
    assert isinstance(result.outputs, list)


@freeze_time("2024-01-01 12:00:00")
def test_fetch_events(client, mocker):
    """
    Given:
        - A client object
        - Last run context
    When:
        - fetch_events is called
    Then:
        - The function should return the next run context and events
        - next_run timestamps should be set to fetch_end_time (not last event time) to prevent gaps
    """
    mock_events_response = {
        "events": [{"id": "1", "created": "2024-01-01T10:00:00.000Z"}, {"id": "2", "created": "2024-01-01T11:00:00.000Z"}]
    }
    mock_audit_response = {
        "data": [{"id": 3, "created": "2024-01-01T10:30:00.000Z"}, {"id": 4, "created": "2024-01-01T11:30:00.000Z"}],
        "pageCount": 1,
    }

    mocker.patch.object(client, "get_events", return_value=mock_events_response)
    mocker.patch.object(client, "get_audit_activity", return_value=mock_audit_response)

    last_run: dict = {}
    first_fetch = "3 days"
    max_fetch = 10
    events_types_to_fetch = ["Events", "Activity Audits"]

    next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    assert len(events) == 4

    # CRITICAL: Verify next_run timestamps are set to fetch_end_time (frozen time), NOT the last event's timestamp
    # This ensures continuous coverage without gaps between fetch cycles
    expected_timestamp = "2024-01-01T12:00:00.000000Z"
    assert next_run["last_event_time"] == expected_timestamp
    assert next_run["last_audit_time"] == expected_timestamp

    # Verify XSIAM fields are added
    for event in events:
        assert "_time" in event
        assert "source_log_type" in event
        assert "vendor" in event
        assert "product" in event


@freeze_time("2024-01-01 12:00:00")
def test_fetch_events_only_events(client, mocker):
    """
    Given:
        - A client object configured to fetch only Events
    When:
        - fetch_events is called
    Then:
        - Only Events should be fetched
        - next_run should use fetch_end_time to prevent gaps
    """
    mock_events_response = {"events": [{"id": "1", "created": "2024-01-01T10:00:00.000Z"}]}
    mocker.patch.object(client, "get_events", return_value=mock_events_response)

    last_run: dict = {}
    first_fetch = "1 day"
    max_fetch = 10
    events_types_to_fetch = ["Events"]

    next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    assert len(events) == 1
    assert "last_event_time" in next_run
    assert "last_audit_time" not in next_run
    assert events[0]["source_log_type"] == "events"
    # Verify timestamp is set to fetch_end_time
    assert next_run["last_event_time"] == "2024-01-01T12:00:00.000000Z"


@freeze_time("2024-01-01 12:00:00")
def test_fetch_events_only_audits(client, mocker):
    """
    Given:
        - A client object configured to fetch only Activity Audits
    When:
        - fetch_events is called
    Then:
        - Only Activity Audits should be fetched
        - next_run should use fetch_end_time to prevent gaps
    """
    mock_audit_response = {"data": [{"id": 1, "created": "2024-01-01T10:00:00.000Z"}], "pageCount": 1}
    mocker.patch.object(client, "get_audit_activity", return_value=mock_audit_response)

    last_run: dict = {}
    first_fetch = "1 day"
    max_fetch = 10
    events_types_to_fetch = ["Activity Audits"]

    next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    assert len(events) == 1
    assert "last_audit_time" in next_run
    assert "last_event_time" not in next_run
    assert events[0]["source_log_type"] == "activity_audits"
    # Verify timestamp is set to fetch_end_time
    assert next_run["last_audit_time"] == "2024-01-01T12:00:00.000000Z"


def test_fetch_events_pagination(client, mocker):
    """
    Given:
        - A client object
        - Multiple pages of audit data
    When:
        - fetch_events is called
    Then:
        - All pages should be fetched until max_fetch is reached
    """
    mock_audit_response_page1 = {
        "data": [{"id": 1, "created": "2022-01-01T00:00:00.000Z"}, {"id": 2, "created": "2022-01-01T00:00:01.000Z"}],
        "pageCount": 2,
        "pageNumber": 1,
    }
    mock_audit_response_page2 = {"data": [{"id": 3, "created": "2022-01-01T00:00:02.000Z"}], "pageCount": 2, "pageNumber": 2}

    mocker.patch.object(client, "get_audit_activity", side_effect=[mock_audit_response_page1, mock_audit_response_page2])

    last_run: dict = {}
    first_fetch = "1 day"
    max_fetch = 10
    events_types_to_fetch = ["Activity Audits"]

    next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    assert len(events) == 3
    assert client.get_audit_activity.call_count == 2


def test_get_dedup_key():
    """
    Given:
        - An event dictionary with source_log_type and id
    When:
        - get_dedup_key is called
    Then:
        - The function should return a key prefixed with the event type
    """
    event_with_id = {"id": "123", "data": "test", "source_log_type": "events"}
    assert get_dedup_key(event_with_id) == "events_123"

    event_without_id = {"data": "test", "source_log_type": "events"}
    key = get_dedup_key(event_without_id)
    assert isinstance(key, str)
    assert key.startswith("events_")


def test_get_dedup_key_numeric_id():
    """
    Given:
        - An event with numeric ID and source_log_type
    When:
        - get_dedup_key is called
    Then:
        - The function should convert ID to string and prefix with event type
    """
    event_with_numeric_id = {"id": 456, "data": "test", "source_log_type": "activity_audits"}
    assert get_dedup_key(event_with_numeric_id) == "activity_audits_456"


@freeze_time("2024-01-01 12:00:00")
def test_fetch_events_no_gap_between_fetches_first(client, mocker):
    """
    Given:
        - A client object
        - First fetch cycle at 12:00:00
    When:
        - fetch_events is called
    Then:
        - next_run should store fetch_end_time (12:00:00), not last event time (11:50:00)
        - This ensures no events are missed between fetch cycles
    """
    mock_events_response = {
        "events": [
            {"id": "1", "created": "2024-01-01T11:50:00.000Z"},  # Event before fetch time
        ]
    }
    mocker.patch.object(client, "get_events", return_value=mock_events_response)

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 10
    events_types_to_fetch = ["Events"]

    next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    # CRITICAL: Verify fetch stored fetch_end_time (12:00:00), not last event time (11:50:00)
    # This prevents missing events created between 11:50:00 and the next fetch
    assert next_run["last_event_time"] == "2024-01-01T12:00:00.000000Z"
    assert len(events) == 1


@freeze_time("2024-01-01 12:05:00")
def test_fetch_events_no_gap_between_fetches_second(client, mocker):
    """
    Given:
        - A client object
        - Second fetch cycle at 12:05:00
        - Previous fetch ended at 12:00:00
    When:
        - fetch_events is called with last_run from previous fetch
    Then:
        - Fetch should start from 12:00:00 (previous fetch_end_time)
        - Events created between 12:00:00 and 12:05:00 should be captured
    """
    # Events created between first fetch (12:00:00) and second fetch (12:05:00)
    mock_events_response = {
        "events": [
            {"id": "2", "created": "2024-01-01T12:01:00.000Z"},  # Event after first fetch
            {"id": "3", "created": "2024-01-01T12:03:00.000Z"},  # Another event
        ]
    }
    mocker.patch.object(client, "get_events", return_value=mock_events_response)

    # Simulate last_run from previous fetch
    last_run = {"last_event_time": "2024-01-01T12:00:00.000000Z"}
    first_fetch = "1 hour"
    max_fetch = 10
    events_types_to_fetch = ["Events"]

    next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    # Verify events created between fetches are captured
    assert len(events) == 2

    # Verify second fetch stored its fetch_end_time (12:05:00)
    assert next_run["last_event_time"] == "2024-01-01T12:05:00.000000Z"

    # Verify the get_events call used the correct start_date (from first fetch's end time)
    call_args = client.get_events.call_args
    assert call_args[0][0] == "2024-01-01T12:00:00.000000Z"


def test_fetch_events_with_last_run(client, mocker):
    """
    Given:
        - A client object
        - Last run context with existing timestamps
    When:
        - fetch_events is called
    Then:
        - Should use last_run timestamps instead of first_fetch
    """
    mock_events_response = {"events": [{"id": "1", "created": "2024-01-01T12:30:00.000Z"}]}
    mocker.patch.object(client, "get_events", return_value=mock_events_response)

    last_run = {"last_event_time": "2024-01-01T12:00:00.000000Z"}
    first_fetch = "3 days"
    max_fetch = 10
    events_types_to_fetch = ["Events"]

    with freeze_time("2024-01-01 13:00:00"):
        next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    # Verify it used last_run timestamp, not first_fetch
    call_args = client.get_events.call_args
    assert call_args[0][0] == "2024-01-01T12:00:00.000000Z"
    assert len(events) == 1


def test_fetch_events_max_fetch_limit(client, mocker):
    """
    Given:
        - A client object
        - max_fetch limit of 2
    When:
        - fetch_events is called
    Then:
        - Should pass max_fetch as limit parameter to API
    """
    mock_events_response = {"events": [{"id": "1", "created": "2024-01-01T10:00:00.000Z"}]}
    mocker.patch.object(client, "get_events", return_value=mock_events_response)

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 2  # Limit to 2 events
    events_types_to_fetch = ["Events"]

    with freeze_time("2024-01-01 12:00:00"):
        next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    # Verify the parameter is passed correctly to the API
    call_args = client.get_events.call_args
    assert call_args[0][1] == 2  # Second positional argument is limit


def test_fetch_events_empty_response(client, mocker):
    """
    Given:
        - A client object
        - API returns empty events
    When:
        - fetch_events is called
    Then:
        - Should handle empty response gracefully
    """
    mock_events_response = {"events": []}
    mock_audit_response = {"data": [], "pageCount": 0}

    mocker.patch.object(client, "get_events", return_value=mock_events_response)
    mocker.patch.object(client, "get_audit_activity", return_value=mock_audit_response)

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 10
    events_types_to_fetch = ["Events", "Activity Audits"]

    with freeze_time("2024-01-01 12:00:00"):
        next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    assert len(events) == 0
    assert "last_event_time" in next_run
    assert "last_audit_time" in next_run


def test_http_request_with_authentication(client, mocker):
    """Test _http_request automatically gets token and adds Authorization header."""
    from BeyondTrustPrivilegeManagementCloud import BaseClient

    mock_token_response = {"access_token": "new_token_123"}
    mock_api_response = {"data": "test"}

    mock_http = mocker.patch.object(BaseClient, "_http_request")
    mock_http.side_effect = [mock_token_response, mock_api_response]

    client._http_request(method="GET", url_suffix="/management-api/v3/test")

    assert client.token == "new_token_123"
    assert mock_http.call_count == 2


def test_http_request_token_endpoint(client, mocker):
    """Test _http_request does NOT add Authorization header for token endpoint."""
    from BeyondTrustPrivilegeManagementCloud import BaseClient

    mock_response = {"access_token": "token_123"}
    mock_http = mocker.patch.object(BaseClient, "_http_request", return_value=mock_response)

    client._http_request(method="POST", url_suffix="/oauth/connect/token", data={})

    call_headers = mock_http.call_args[1]["headers"]
    assert "Authorization" not in call_headers


def test_get_audit_activity_without_filters(client, mocker):
    """Test get_audit_activity without filter parameters."""
    mock_response = {"data": [], "pageCount": 0}
    mock_http = mocker.patch.object(client, "_http_request", return_value=mock_response)

    client.get_audit_activity(page_size=50, page_number=1)

    call_params = mock_http.call_args[1]["params"]
    assert "Pagination.PageSize" in call_params
    assert "Pagination.PageNumber" in call_params
    assert "Filter.Created.Dates" not in call_params


@freeze_time("2024-01-01 12:00:00")
def test_fetch_pm_events(client, mocker):
    """
    Given:
        - A client object
        - Last run context
    When:
        - fetch_pm_events is called directly
    Then:
        - The function should return the next run context and events
        - Events should have XSIAM fields added
    """
    mock_events_response = {
        "events": [{"id": "1", "created": "2024-01-01T10:00:00.000Z"}, {"id": "2", "created": "2024-01-01T11:00:00.000Z"}]
    }
    mocker.patch.object(client, "get_events", return_value=mock_events_response)

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 10
    fetch_end_time = datetime.now(UTC)

    next_run, events = fetch_pm_events(client, last_run, first_fetch, max_fetch, fetch_end_time)

    assert len(events) == 2
    assert next_run["last_event_time"] == "2024-01-01T12:00:00.000000Z"

    # Verify XSIAM fields are added
    for event in events:
        assert event["source_log_type"] == "events"
        assert event["vendor"] == "beyondtrust"
        assert event["product"] == "pm_cloud"


@freeze_time("2024-01-01 12:00:00")
def test_fetch_activity_audits(client, mocker):
    """
    Given:
        - A client object
        - Last run context
    When:
        - fetch_activity_audits is called directly
    Then:
        - The function should return the next run context and audit events
        - Audit events should have XSIAM fields added
    """
    mock_audit_response = {
        "data": [{"id": 1, "created": "2024-01-01T10:00:00.000Z"}, {"id": 2, "created": "2024-01-01T11:00:00.000Z"}],
        "pageCount": 1,
    }
    mocker.patch.object(client, "get_audit_activity", return_value=mock_audit_response)

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 10
    fetch_end_time = datetime.now(UTC)

    next_run, events = fetch_activity_audits(client, last_run, first_fetch, max_fetch, fetch_end_time)

    assert len(events) == 2
    assert next_run["last_audit_time"] == "2024-01-01T12:00:00.000000Z"

    # Verify XSIAM fields are added
    for event in events:
        assert event["source_log_type"] == "activity_audits"
        assert event["vendor"] == "beyondtrust"
        assert event["product"] == "pm_cloud"


@freeze_time("2024-01-01 12:00:00")
def test_fetch_events_max_fetch_per_type(client, mocker):
    """
    Given:
        - A client object configured to fetch both Events and Activity Audits
        - max_fetch limit of 5
    When:
        - fetch_events is called
    Then:
        - Each event type should get its own max_fetch limit (5 each)
        - Total events could be up to 10 (5 Events + 5 Activity Audits)
    """
    # Mock 5 events
    mock_events_response = {"events": [{"id": f"event_{i}", "created": f"2024-01-01T10:0{i}:00.000Z"} for i in range(5)]}
    # Mock 5 audit events
    mock_audit_response = {"data": [{"id": i, "created": f"2024-01-01T10:0{i}:00.000Z"} for i in range(5)], "pageCount": 1}

    mocker.patch.object(client, "get_events", return_value=mock_events_response)
    mocker.patch.object(client, "get_audit_activity", return_value=mock_audit_response)

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 5  # 5 per event type
    events_types_to_fetch = ["Events", "Activity Audits"]

    next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    # Should have 10 total events (5 Events + 5 Activity Audits)
    assert len(events) == 10

    # Verify get_events was called with max_fetch=5
    events_call_args = client.get_events.call_args
    assert events_call_args[0][1] == 5

    # Count events by type
    event_count = sum(1 for e in events if e.get("source_log_type") == "events")
    audit_count = sum(1 for e in events if e.get("source_log_type") == "activity_audits")
    assert event_count == 5
    assert audit_count == 5


def test_get_dedup_key_cross_event_type_collision():
    """
    Given:
        - An event and an audit with the same numeric ID but different source_log_type
    When:
        - get_dedup_key is called for both
    Then:
        - The dedup keys should be different, preventing cross-type collisions
    """
    event = {"id": 1, "source_log_type": "events", "created": "2024-01-01T10:00:00.000Z"}
    audit = {"id": 1, "source_log_type": "activity_audits", "created": "2024-01-01T10:00:00.000Z"}

    event_key = get_dedup_key(event)
    audit_key = get_dedup_key(audit)

    assert event_key != audit_key
    assert event_key == "events_1"
    assert audit_key == "activity_audits_1"


@freeze_time("2024-01-01 12:00:00")
def test_fetch_pm_events_at_api_limit(client, mocker):
    """
    Given:
        - A client object
        - API returns exactly DEFAULT_LIMIT (1000) events in first batch
        - Second batch returns fewer events (pagination stops)
    When:
        - fetch_pm_events is called with max_fetch > 1000
    Then:
        - Should make multiple API calls to paginate
        - Should stop when fewer events than batch_size are returned
    """
    batch_1 = [{"id": str(i), "created": f"2024-01-01T10:{i % 60:02d}:00.000Z"} for i in range(1000)]
    batch_2 = [{"id": str(i), "created": f"2024-01-01T11:{i % 60:02d}:00.000Z"} for i in range(1000, 1050)]

    mocker.patch.object(
        client,
        "get_events",
        side_effect=[{"events": batch_1}, {"events": batch_2}],
    )

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 2000
    fetch_end_time = datetime.now(UTC)

    next_run, events = fetch_pm_events(client, last_run, first_fetch, max_fetch, fetch_end_time)

    assert len(events) == 1050
    assert client.get_events.call_count == 2


@freeze_time("2024-01-01 12:00:00")
def test_fetch_activity_audits_exceeds_max_fetch(client, mocker):
    """
    Given:
        - A client object
        - max_fetch is 3
        - API returns pages with 2 audits each
    When:
        - fetch_activity_audits is called
    Then:
        - Should stop fetching when max_fetch is reached
        - Should only request remaining_limit as page_size on second page
    """
    page1 = {
        "data": [
            {"id": 1, "created": "2024-01-01T10:00:00.000Z"},
            {"id": 2, "created": "2024-01-01T10:01:00.000Z"},
        ],
        "pageCount": 2,
    }
    page2 = {
        "data": [{"id": 3, "created": "2024-01-01T10:02:00.000Z"}],
        "pageCount": 2,
    }

    mocker.patch.object(client, "get_audit_activity", side_effect=[page1, page2])

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 3
    fetch_end_time = datetime.now(UTC)

    next_run, audits = fetch_activity_audits(client, last_run, first_fetch, max_fetch, fetch_end_time)

    assert len(audits) == 3
    # Second call should request page_size=1 (remaining_limit)
    second_call = client.get_audit_activity.call_args_list[1]
    assert second_call[1]["page_size"] == 1


@freeze_time("2024-01-01 12:00:00")
def test_fetch_events_dedup_across_types(client, mocker):
    """
    Given:
        - Events and audits with the same ID (e.g., id=1)
    When:
        - fetch_events is called and deduplication is applied
    Then:
        - Both events should be kept because they have different source_log_type prefixes
    """
    mock_events_response = {"events": [{"id": 1, "created": "2024-01-01T10:00:00.000Z"}]}
    mock_audit_response = {"data": [{"id": 1, "created": "2024-01-01T10:00:00.000Z"}], "pageCount": 1}

    mocker.patch.object(client, "get_events", return_value=mock_events_response)
    mocker.patch.object(client, "get_audit_activity", return_value=mock_audit_response)

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 10
    events_types_to_fetch = ["Events", "Activity Audits"]

    next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    # Deduplicate using get_dedup_key (same logic as main())
    deduped = {get_dedup_key(e): e for e in events}
    final_events = list(deduped.values())

    # Both should be kept because source_log_type differs
    assert len(final_events) == 2


def test_client_token_refresh_on_401(client, mocker):
    """
    Given:
        - A client with an expired/invalid token
    When:
        - An API call is made
    Then:
        - The client should attempt to get a new token
    """
    from BeyondTrustPrivilegeManagementCloud import BaseClient

    mock_token_response = {"access_token": "refreshed_token"}
    mock_api_response = {"data": "test"}

    mock_http = mocker.patch.object(BaseClient, "_http_request")
    mock_http.side_effect = [mock_token_response, mock_api_response]

    # Ensure token is None so it triggers get_token
    client.token = None
    client._http_request(method="GET", url_suffix="/management-api/v3/test")

    assert client.token == "refreshed_token"


@freeze_time("2024-01-01 12:00:00")
def test_fetch_pm_events_exceeds_max_fetch(client, mocker):
    """
    Given:
        - A client object
        - max_fetch is 500
        - API returns 500 events in first batch (batch_size=500 < DEFAULT_LIMIT)
    When:
        - fetch_pm_events is called
    Then:
        - Should stop after first batch since len(fetched) < batch_size won't trigger
          but total_fetched == max_fetch will stop the loop
    """
    events_batch = [{"id": str(i), "created": f"2024-01-01T10:{i % 60:02d}:00.000Z"} for i in range(500)]
    mocker.patch.object(client, "get_events", return_value={"events": events_batch})

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 500
    fetch_end_time = datetime.now(UTC)

    next_run, events = fetch_pm_events(client, last_run, first_fetch, max_fetch, fetch_end_time)

    assert len(events) == 500
    # Only one API call since batch_size (500) == max_fetch and len(fetched) == batch_size
    # but total_fetched == max_fetch stops the while loop
    assert client.get_events.call_count == 1


def test_get_events_api_error_handling(client, mocker):
    """
    Given:
        - A client object
        - API raises an exception
    When:
        - get_events is called
    Then:
        - The exception should propagate
    """
    mocker.patch.object(client, "_http_request", side_effect=Exception("API Error: 500 Internal Server Error"))

    with pytest.raises(Exception, match="API Error: 500 Internal Server Error"):
        client.get_events("2024-01-01T00:00:00.000Z", 100)


def test_get_audit_activity_api_error_handling(client, mocker):
    """
    Given:
        - A client object
        - API raises an exception
    When:
        - get_audit_activity is called
    Then:
        - The exception should propagate
    """
    mocker.patch.object(client, "_http_request", side_effect=Exception("API Error: 403 Forbidden"))

    with pytest.raises(Exception, match="API Error: 403 Forbidden"):
        client.get_audit_activity(page_size=10, page_number=1)


@freeze_time("2024-01-01 12:00:00")
def test_fetch_events_mixed_success_failure(client, mocker):
    """
    Given:
        - Events API succeeds but Audit API fails
    When:
        - fetch_events is called for both types
    Then:
        - The exception from the audit API should propagate
    """
    mock_events_response = {"events": [{"id": "1", "created": "2024-01-01T10:00:00.000Z"}]}
    mocker.patch.object(client, "get_events", return_value=mock_events_response)
    mocker.patch.object(client, "get_audit_activity", side_effect=Exception("Audit API Error"))

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 10
    events_types_to_fetch = ["Events", "Activity Audits"]

    with pytest.raises(Exception, match="Audit API Error"):
        fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)


@freeze_time("2024-01-01 12:00:00")
def test_get_events_command_with_push_events(client, mocker):
    """
    Given:
        - A client object
        - should_push_events is True
    When:
        - get_events_command is called
    Then:
        - Events should have XSIAM fields added
        - send_events_to_xsiam should be called
    """
    mock_response = {
        "events": [
            {"id": "1", "created": "2024-01-01T10:00:00.000Z"},
            {"id": "2", "created": "2024-01-01T11:00:00.000Z"},
        ]
    }
    mocker.patch.object(client, "get_events", return_value=mock_response)
    mock_send = mocker.patch("BeyondTrustPrivilegeManagementCloud.send_events_to_xsiam")

    args = {"start_date": "2024-01-01T00:00:00.000Z", "limit": "10", "should_push_events": "true"}
    result = get_events_command(client, args)

    # Verify XSIAM fields were added
    for event in result.outputs:
        assert event["source_log_type"] == "events"
        assert event["vendor"] == "beyondtrust"
        assert event["product"] == "pm_cloud"
        assert "_time" in event

    # Verify send_events_to_xsiam was called
    mock_send.assert_called_once()


@freeze_time("2024-01-01 12:00:00")
def test_fetch_activity_audits_empty_middle_page(client, mocker):
    """
    Given:
        - A client object
        - First page has data, second page is empty
    When:
        - fetch_activity_audits is called
    Then:
        - Should stop fetching when an empty page is encountered
    """
    page1 = {
        "data": [{"id": 1, "created": "2024-01-01T10:00:00.000Z"}],
        "pageCount": 3,
    }
    page2 = {"data": [], "pageCount": 3}

    mocker.patch.object(client, "get_audit_activity", side_effect=[page1, page2])

    last_run: dict = {}
    first_fetch = "1 hour"
    max_fetch = 100
    fetch_end_time = datetime.now(UTC)

    next_run, audits = fetch_activity_audits(client, last_run, first_fetch, max_fetch, fetch_end_time)

    assert len(audits) == 1
    assert client.get_audit_activity.call_count == 2


def test_get_dedup_key_missing_id_and_source_log_type():
    """
    Given:
        - An event without id and without source_log_type
    When:
        - get_dedup_key is called
    Then:
        - Should use 'N/A' as the event type prefix and hash the content
    """
    event = {"data": "some_data", "timestamp": "2024-01-01T10:00:00.000Z"}
    key = get_dedup_key(event)

    assert key.startswith("N/A_")
    assert isinstance(key, str)


@freeze_time("2024-01-01 12:00:00")
def test_fetch_events_boundary_timestamp(client, mocker):
    """
    Given:
        - Events with timestamps exactly at the boundary of the fetch window
    When:
        - fetch_events is called
    Then:
        - Boundary events should be included
        - next_run should use fetch_end_time
    """
    mock_events_response = {
        "events": [
            {"id": "1", "created": "2024-01-01T12:00:00.000000Z"},  # Exactly at fetch_end_time
        ]
    }
    mocker.patch.object(client, "get_events", return_value=mock_events_response)

    last_run = {"last_event_time": "2024-01-01T11:59:59.000000Z"}
    first_fetch = "1 hour"
    max_fetch = 10
    events_types_to_fetch = ["Events"]

    next_run, events = fetch_events(client, last_run, first_fetch, max_fetch, events_types_to_fetch)

    assert len(events) == 1
    assert next_run["last_event_time"] == "2024-01-01T12:00:00.000000Z"


def test_client_network_timeout(client, mocker):
    """
    Given:
        - A client object
        - Network request times out
    When:
        - An API call is made
    Then:
        - A timeout exception should propagate
    """
    from BeyondTrustPrivilegeManagementCloud import BaseClient

    mocker.patch.object(BaseClient, "_http_request", side_effect=Exception("Connection timed out"))

    # Token is None, so get_token will be called first and fail
    client.token = None
    with pytest.raises(Exception, match="Connection timed out"):
        client._http_request(method="GET", url_suffix="/management-api/v3/test")


def test_main_command_routing(mocker):
    """
    Given:
        - Various command names
    When:
        - main() is called
    Then:
        - The correct command handler should be invoked
    """
    mocker.patch.object(
        BeyondTrustPrivilegeManagementCloud.demisto,
        "params",
        return_value={
            "url": "https://example.com",
            "credentials": {"identifier": "test_id", "password": "test_secret"},
            "insecure": False,
            "proxy": False,
        },
    )
    mocker.patch.object(BeyondTrustPrivilegeManagementCloud.demisto, "args", return_value={})
    mocker.patch.object(BeyondTrustPrivilegeManagementCloud.demisto, "command", return_value="test-module")

    mock_test_module = mocker.patch("BeyondTrustPrivilegeManagementCloud.test_module", return_value="ok")
    mock_return_results = mocker.patch("BeyondTrustPrivilegeManagementCloud.return_results")

    main()

    mock_test_module.assert_called_once()
    mock_return_results.assert_called_once_with("ok")