diff --git a/src/workos/async_client.py b/src/workos/async_client.py index 38bf13fb..11cec994 100644 --- a/src/workos/async_client.py +++ b/src/workos/async_client.py @@ -2,7 +2,7 @@ from importlib.metadata import version from workos._base_client import BaseClient from workos.api_keys import AsyncApiKeys -from workos.audit_logs import AuditLogsModule +from workos.audit_logs import AsyncAuditLogs from workos.directory_sync import AsyncDirectorySync from workos.events import AsyncEvents from workos.fga import FGAModule @@ -64,10 +64,10 @@ def sso(self) -> AsyncSSO: return self._sso @property - def audit_logs(self) -> AuditLogsModule: - raise NotImplementedError( - "Audit logs APIs are not yet supported in the async client." - ) + def audit_logs(self) -> AsyncAuditLogs: + if not getattr(self, "_audit_logs", None): + self._audit_logs = AsyncAuditLogs(self._http_client) + return self._audit_logs @property def directory_sync(self) -> AsyncDirectorySync: diff --git a/src/workos/audit_logs.py b/src/workos/audit_logs.py index 73bacff8..b3e69d16 100644 --- a/src/workos/audit_logs.py +++ b/src/workos/audit_logs.py @@ -1,12 +1,38 @@ -from typing import Optional, Protocol, Sequence +from typing import Any, Dict, Literal, Mapping, Optional, Protocol, Sequence -from workos.types.audit_logs import AuditLogExport +from workos.types.audit_logs import ( + AuditLogAction, + AuditLogConfiguration, + AuditLogExport, + AuditLogRetention, + AuditLogSchema, + AuditLogSchemaListFilters, + AuditLogActionListFilters, +) from workos.types.audit_logs.audit_log_event import AuditLogEvent -from workos.utils.http_client import SyncHTTPClient -from workos.utils.request_helper import REQUEST_METHOD_GET, REQUEST_METHOD_POST +from workos.types.list_resource import ListMetadata, ListPage, WorkOSListResource +from workos.typing.sync_or_async import SyncOrAsync +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient +from workos.utils.pagination_order import PaginationOrder +from workos.utils.request_helper import ( + DEFAULT_LIST_RESPONSE_LIMIT, + REQUEST_METHOD_GET, + REQUEST_METHOD_POST, + REQUEST_METHOD_PUT, +) EVENTS_PATH = "audit_logs/events" EXPORTS_PATH = "audit_logs/exports" +ACTIONS_PATH = "audit_logs/actions" + + +AuditLogActionsListResource = WorkOSListResource[ + AuditLogAction, AuditLogActionListFilters, ListMetadata +] + +AuditLogSchemasListResource = WorkOSListResource[ + AuditLogSchema, AuditLogSchemaListFilters, ListMetadata +] class AuditLogsModule(Protocol): @@ -18,7 +44,7 @@ def create_event( organization_id: str, event: AuditLogEvent, idempotency_key: Optional[str] = None, - ) -> None: + ) -> SyncOrAsync[None]: """Create an Audit Logs event. Kwargs: @@ -40,7 +66,7 @@ def create_export( targets: Optional[Sequence[str]] = None, actor_names: Optional[Sequence[str]] = None, actor_ids: Optional[Sequence[str]] = None, - ) -> AuditLogExport: + ) -> SyncOrAsync[AuditLogExport]: """Trigger the creation of an export of audit logs. Kwargs: @@ -49,6 +75,7 @@ def create_export( range_end (str): End date of the date range filter. actions (list): Optional list of actions to filter. (Optional) actor_names (list): Optional list of actors to filter by name. (Optional) + actor_ids (list): Optional list of actors to filter by ID. (Optional) targets (list): Optional list of targets to filter. (Optional) Returns: @@ -56,15 +83,125 @@ def create_export( """ ... - def get_export(self, audit_log_export_id: str) -> AuditLogExport: - """Retrieve an created export. + def get_export(self, audit_log_export_id: str) -> SyncOrAsync[AuditLogExport]: + """Retrieve a created export. + Args: audit_log_export_id (str): Audit log export unique identifier. + Returns: AuditLogExport: Object that describes the audit log export """ ... + def create_schema( + self, + *, + action: str, + targets: Sequence[Mapping[str, Any]], + actor: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> SyncOrAsync[AuditLogSchema]: + """Create an Audit Log schema for an action. + + Kwargs: + action (str): The action name for the schema (e.g., 'user.signed_in'). + targets (list): List of target definitions with type and optional metadata. + actor (dict): Optional actor definition with metadata schema. (Optional) + metadata (dict): Optional event-level metadata schema. (Optional) + idempotency_key (str): Idempotency key. (Optional) + + Returns: + AuditLogSchema: The created audit log schema + """ + ... + + def list_schemas( + self, + *, + action: str, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsync[AuditLogSchemasListResource]: + """List all schemas for an Audit Log action. + + Kwargs: + action (str): The action name to list schemas for. + limit (int): Maximum number of records to return. (Optional) + before (str): Pagination cursor to receive records before a provided ID. (Optional) + after (str): Pagination cursor to receive records after a provided ID. (Optional) + order (Literal["asc","desc"]): Sort order by created_at timestamp. (Optional) + + Returns: + AuditLogSchemasListResource: Paginated list of audit log schemas + """ + ... + + def list_actions( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsync[AuditLogActionsListResource]: + """List all registered Audit Log actions. + + Kwargs: + limit (int): Maximum number of records to return. (Optional) + before (str): Pagination cursor to receive records before a provided ID. (Optional) + after (str): Pagination cursor to receive records after a provided ID. (Optional) + order (Literal["asc","desc"]): Sort order by created_at timestamp. (Optional) + + Returns: + AuditLogActionsListResource: Paginated list of audit log actions + """ + ... + + def get_retention(self, organization_id: str) -> SyncOrAsync[AuditLogRetention]: + """Get the event retention period for an organization. + + Args: + organization_id (str): Organization's unique identifier. + + Returns: + AuditLogRetention: The retention configuration + """ + ... + + def set_retention( + self, + *, + organization_id: str, + retention_period_in_days: Literal[30, 365], + ) -> SyncOrAsync[AuditLogRetention]: + """Set the event retention period for an organization. + + Kwargs: + organization_id (str): Organization's unique identifier. + retention_period_in_days (int): The number of days to retain events (30 or 365). + + Returns: + AuditLogRetention: The updated retention configuration + """ + ... + + def get_configuration( + self, organization_id: str + ) -> SyncOrAsync[AuditLogConfiguration]: + """Get the audit log configuration for an organization. + + Args: + organization_id (str): Organization's unique identifier. + + Returns: + AuditLogConfiguration: The complete audit log configuration + """ + ... + class AuditLogs(AuditLogsModule): _http_client: SyncHTTPClient @@ -81,7 +218,7 @@ def create_event( ) -> None: json = {"organization_id": organization_id, "event": event} - headers = {} + headers: Dict[str, str] = {} if idempotency_key: headers["idempotency-key"] = idempotency_key @@ -118,8 +255,309 @@ def create_export( def get_export(self, audit_log_export_id: str) -> AuditLogExport: response = self._http_client.request( - "{0}/{1}".format(EXPORTS_PATH, audit_log_export_id), + f"{EXPORTS_PATH}/{audit_log_export_id}", + method=REQUEST_METHOD_GET, + ) + + return AuditLogExport.model_validate(response) + + def create_schema( + self, + *, + action: str, + targets: Sequence[Mapping[str, Any]], + actor: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> AuditLogSchema: + json: Dict[str, Any] = { + "targets": list(targets), + } + if actor is not None: + json["actor"] = actor + if metadata is not None: + json["metadata"] = metadata + + headers: Dict[str, str] = {} + if idempotency_key: + headers["idempotency-key"] = idempotency_key + + response = self._http_client.request( + f"{ACTIONS_PATH}/{action}/schemas", + method=REQUEST_METHOD_POST, + json=json, + headers=headers, + ) + + return AuditLogSchema.model_validate(response) + + def list_schemas( + self, + *, + action: str, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AuditLogSchemasListResource: + list_params: AuditLogSchemaListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = self._http_client.request( + f"{ACTIONS_PATH}/{action}/schemas", + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ + AuditLogSchema, AuditLogSchemaListFilters, ListMetadata + ]( + list_method=lambda **kwargs: self.list_schemas(action=action, **kwargs), + list_args=list_params, + **ListPage[AuditLogSchema](**response).model_dump(), + ) + + def list_actions( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AuditLogActionsListResource: + list_params: AuditLogActionListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = self._http_client.request( + ACTIONS_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ + AuditLogAction, AuditLogActionListFilters, ListMetadata + ]( + list_method=self.list_actions, + list_args=list_params, + **ListPage[AuditLogAction](**response).model_dump(), + ) + + def get_retention(self, organization_id: str) -> AuditLogRetention: + response = self._http_client.request( + f"organizations/{organization_id}/audit_logs_retention", + method=REQUEST_METHOD_GET, + ) + + return AuditLogRetention.model_validate(response) + + def set_retention( + self, + *, + organization_id: str, + retention_period_in_days: Literal[30, 365], + ) -> AuditLogRetention: + json = {"retention_period_in_days": retention_period_in_days} + + response = self._http_client.request( + f"organizations/{organization_id}/audit_logs_retention", + method=REQUEST_METHOD_PUT, + json=json, + ) + + return AuditLogRetention.model_validate(response) + + def get_configuration(self, organization_id: str) -> AuditLogConfiguration: + response = self._http_client.request( + f"organizations/{organization_id}/audit_log_configuration", + method=REQUEST_METHOD_GET, + ) + + return AuditLogConfiguration.model_validate(response) + + +class AsyncAuditLogs(AuditLogsModule): + _http_client: AsyncHTTPClient + + def __init__(self, http_client: AsyncHTTPClient): + self._http_client = http_client + + async def create_event( + self, + *, + organization_id: str, + event: AuditLogEvent, + idempotency_key: Optional[str] = None, + ) -> None: + json = {"organization_id": organization_id, "event": event} + + headers: Dict[str, str] = {} + if idempotency_key: + headers["idempotency-key"] = idempotency_key + + await self._http_client.request( + EVENTS_PATH, method=REQUEST_METHOD_POST, json=json, headers=headers + ) + + async def create_export( + self, + *, + organization_id: str, + range_start: str, + range_end: str, + actions: Optional[Sequence[str]] = None, + targets: Optional[Sequence[str]] = None, + actor_names: Optional[Sequence[str]] = None, + actor_ids: Optional[Sequence[str]] = None, + ) -> AuditLogExport: + json = { + "actions": actions, + "actor_ids": actor_ids, + "actor_names": actor_names, + "organization_id": organization_id, + "range_start": range_start, + "range_end": range_end, + "targets": targets, + } + + response = await self._http_client.request( + EXPORTS_PATH, method=REQUEST_METHOD_POST, json=json + ) + + return AuditLogExport.model_validate(response) + + async def get_export(self, audit_log_export_id: str) -> AuditLogExport: + response = await self._http_client.request( + f"{EXPORTS_PATH}/{audit_log_export_id}", method=REQUEST_METHOD_GET, ) return AuditLogExport.model_validate(response) + + async def create_schema( + self, + *, + action: str, + targets: Sequence[Mapping[str, Any]], + actor: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> AuditLogSchema: + json: Dict[str, Any] = { + "targets": list(targets), + } + if actor is not None: + json["actor"] = actor + if metadata is not None: + json["metadata"] = metadata + + headers: Dict[str, str] = {} + if idempotency_key: + headers["idempotency-key"] = idempotency_key + + response = await self._http_client.request( + f"{ACTIONS_PATH}/{action}/schemas", + method=REQUEST_METHOD_POST, + json=json, + headers=headers, + ) + + return AuditLogSchema.model_validate(response) + + async def list_schemas( + self, + *, + action: str, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AuditLogSchemasListResource: + list_params: AuditLogSchemaListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = await self._http_client.request( + f"{ACTIONS_PATH}/{action}/schemas", + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ + AuditLogSchema, AuditLogSchemaListFilters, ListMetadata + ]( + list_method=lambda **kwargs: self.list_schemas(action=action, **kwargs), + list_args=list_params, + **ListPage[AuditLogSchema](**response).model_dump(), + ) + + async def list_actions( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AuditLogActionsListResource: + list_params: AuditLogActionListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = await self._http_client.request( + ACTIONS_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[ + AuditLogAction, AuditLogActionListFilters, ListMetadata + ]( + list_method=self.list_actions, + list_args=list_params, + **ListPage[AuditLogAction](**response).model_dump(), + ) + + async def get_retention(self, organization_id: str) -> AuditLogRetention: + response = await self._http_client.request( + f"organizations/{organization_id}/audit_logs_retention", + method=REQUEST_METHOD_GET, + ) + + return AuditLogRetention.model_validate(response) + + async def set_retention( + self, + *, + organization_id: str, + retention_period_in_days: Literal[30, 365], + ) -> AuditLogRetention: + json = {"retention_period_in_days": retention_period_in_days} + + response = await self._http_client.request( + f"organizations/{organization_id}/audit_logs_retention", + method=REQUEST_METHOD_PUT, + json=json, + ) + + return AuditLogRetention.model_validate(response) + + async def get_configuration(self, organization_id: str) -> AuditLogConfiguration: + response = await self._http_client.request( + f"organizations/{organization_id}/audit_log_configuration", + method=REQUEST_METHOD_GET, + ) + + return AuditLogConfiguration.model_validate(response) diff --git a/src/workos/types/audit_logs/__init__.py b/src/workos/types/audit_logs/__init__.py index ed83cdb7..edf14112 100644 --- a/src/workos/types/audit_logs/__init__.py +++ b/src/workos/types/audit_logs/__init__.py @@ -1,6 +1,11 @@ +from .audit_log_action import * +from .audit_log_configuration import * from .audit_log_event_actor import * from .audit_log_event_context import * from .audit_log_event_target import * from .audit_log_event import * from .audit_log_export import * from .audit_log_metadata import * +from .audit_log_retention import * +from .audit_log_schema import * +from .list_filters import * diff --git a/src/workos/types/audit_logs/audit_log_action.py b/src/workos/types/audit_logs/audit_log_action.py new file mode 100644 index 00000000..a342f143 --- /dev/null +++ b/src/workos/types/audit_logs/audit_log_action.py @@ -0,0 +1,28 @@ +import warnings +from typing import Literal + +from workos.types.audit_logs.audit_log_schema import AuditLogSchema +from workos.types.workos_model import WorkOSModel + +# Suppress Pydantic warning about 'schema' shadowing BaseModel.schema() +# (a deprecated method replaced by model_json_schema() in Pydantic v2) +warnings.filterwarnings( + "ignore", + message='Field name "schema" in "AuditLogAction" shadows an attribute', + category=UserWarning, +) + + +class AuditLogAction(WorkOSModel): + """Representation of a WorkOS audit log action. + + An audit log action represents a configured action type that can be + used in audit log events. Each action has an associated schema that + defines the structure of events for that action. + """ + + object: Literal["audit_log_action"] + name: str + schema: AuditLogSchema # type: ignore[assignment] + created_at: str + updated_at: str diff --git a/src/workos/types/audit_logs/audit_log_configuration.py b/src/workos/types/audit_logs/audit_log_configuration.py new file mode 100644 index 00000000..3bff5a03 --- /dev/null +++ b/src/workos/types/audit_logs/audit_log_configuration.py @@ -0,0 +1,41 @@ +from typing import Literal, Optional + +from workos.types.workos_model import WorkOSModel +from workos.typing.literals import LiteralOrUntyped + + +AuditLogStreamType = Literal[ + "Datadog", "Splunk", "S3", "GoogleCloudStorage", "GenericHttps" +] + +AuditLogStreamState = Literal["active", "inactive", "error", "invalid"] + +AuditLogTrailState = Literal["active", "inactive", "disabled"] + + +class AuditLogStream(WorkOSModel): + """Representation of a WorkOS audit log stream. + + An audit log stream sends audit log events to an external destination + such as Datadog, Splunk, S3, Google Cloud Storage, or a custom HTTPS endpoint. + """ + + id: str + type: LiteralOrUntyped[AuditLogStreamType] + state: LiteralOrUntyped[AuditLogStreamState] + last_synced_at: Optional[str] = None + created_at: str + + +class AuditLogConfiguration(WorkOSModel): + """Representation of a WorkOS audit log configuration for an organization. + + The audit log configuration provides a single view of an organization's + audit logging setup, including retention settings, state, and optional + log stream configuration. + """ + + organization_id: str + retention_period_in_days: int + state: LiteralOrUntyped[AuditLogTrailState] + log_stream: Optional[AuditLogStream] = None diff --git a/src/workos/types/audit_logs/audit_log_retention.py b/src/workos/types/audit_logs/audit_log_retention.py new file mode 100644 index 00000000..1864f5fc --- /dev/null +++ b/src/workos/types/audit_logs/audit_log_retention.py @@ -0,0 +1,11 @@ +from workos.types.workos_model import WorkOSModel + + +class AuditLogRetention(WorkOSModel): + """Representation of a WorkOS audit log retention configuration. + + Specifies how long audit log events are retained for an organization. + Valid values are 30 and 365 days. + """ + + retention_period_in_days: int diff --git a/src/workos/types/audit_logs/audit_log_schema.py b/src/workos/types/audit_logs/audit_log_schema.py new file mode 100644 index 00000000..775ac5cb --- /dev/null +++ b/src/workos/types/audit_logs/audit_log_schema.py @@ -0,0 +1,49 @@ +from typing import Dict, Literal, Optional, Sequence + +from workos.types.workos_model import WorkOSModel + + +class AuditLogSchemaMetadataProperty(WorkOSModel): + """A property definition within an audit log schema metadata object.""" + + type: Literal["string", "boolean", "number"] + + +class AuditLogSchemaMetadata(WorkOSModel): + """The metadata definition for an audit log schema. + + Represents a JSON Schema object type with property definitions. + """ + + type: Literal["object"] + properties: Optional[Dict[str, AuditLogSchemaMetadataProperty]] = None + + +class AuditLogSchemaTarget(WorkOSModel): + """A target definition within an audit log schema.""" + + type: str + metadata: Optional[AuditLogSchemaMetadata] = None + + +class AuditLogSchemaActor(WorkOSModel): + """The actor definition within an audit log schema.""" + + metadata: AuditLogSchemaMetadata + + +class AuditLogSchema(WorkOSModel): + """Representation of a WorkOS audit log schema. + + Audit log schemas define the structure and validation rules + for audit log events, including the allowed targets, actor metadata, + and event-level metadata. + """ + + object: Literal["audit_log_schema"] = "audit_log_schema" + version: int + targets: Sequence[AuditLogSchemaTarget] + actor: AuditLogSchemaActor + metadata: Optional[AuditLogSchemaMetadata] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None diff --git a/src/workos/types/audit_logs/list_filters.py b/src/workos/types/audit_logs/list_filters.py new file mode 100644 index 00000000..705c7cc6 --- /dev/null +++ b/src/workos/types/audit_logs/list_filters.py @@ -0,0 +1,13 @@ +from workos.types.list_resource import ListArgs + + +class AuditLogActionListFilters(ListArgs, total=False): + """Filters for listing audit log actions.""" + + pass + + +class AuditLogSchemaListFilters(ListArgs, total=False): + """Filters for listing audit log schemas.""" + + pass diff --git a/src/workos/types/list_resource.py b/src/workos/types/list_resource.py index e2ece480..19820cbd 100644 --- a/src/workos/types/list_resource.py +++ b/src/workos/types/list_resource.py @@ -17,6 +17,7 @@ cast, ) from typing_extensions import Required, TypedDict +from workos.types.audit_logs import AuditLogAction, AuditLogSchema from workos.types.directory_sync import ( Directory, DirectoryGroup, @@ -42,6 +43,8 @@ ListableResource = TypeVar( # add all possible generics of List Resource "ListableResource", + AuditLogAction, + AuditLogSchema, AuthenticationFactor, ConnectionWithDomains, Directory, diff --git a/tests/conftest.py b/tests/conftest.py index 9ebe4a14..b7dfdb35 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,9 +13,10 @@ from unittest.mock import AsyncMock, MagicMock import urllib.parse +import inspect + import httpx import pytest -import asyncio from functools import wraps from tests.utils.client_configuration import ClientConfiguration @@ -345,6 +346,6 @@ def sync_wrapper(*args, **kwargs): return func(*args, **kwargs) # Return appropriate wrapper based on whether the function is async or not - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): return async_wrapper return sync_wrapper diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py index 390536f8..bae24783 100644 --- a/tests/test_audit_logs.py +++ b/tests/test_audit_logs.py @@ -1,17 +1,15 @@ from datetime import datetime +from typing import Union import pytest -from workos.audit_logs import AuditLogEvent, AuditLogs +from tests.utils.syncify import syncify +from workos.audit_logs import AuditLogEvent, AuditLogs, AsyncAuditLogs from workos.exceptions import AuthenticationException, BadRequestException -class _TestSetup: - @pytest.fixture(autouse=True) - def setup(self, sync_http_client_for_test): - self.http_client = sync_http_client_for_test - self.audit_logs = AuditLogs(http_client=self.http_client) - +@pytest.mark.sync_and_async(AuditLogs, AsyncAuditLogs) +class TestAuditLogs: @pytest.fixture def mock_audit_log_event(self) -> AuditLogEvent: return { @@ -37,10 +35,12 @@ def mock_audit_log_event(self) -> AuditLogEvent: }, } - -class TestAuditLogs: - class TestCreateEvent(_TestSetup): - def test_succeeds(self, capture_and_mock_http_client_request): + class TestCreateEvent: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): organization_id = "org_123456789" event: AuditLogEvent = { @@ -67,15 +67,17 @@ def test_succeeds(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - http_client=self.http_client, + http_client=module_instance._http_client, response_dict={"success": True}, status_code=200, ) - response = self.audit_logs.create_event( - organization_id=organization_id, - event=event, - idempotency_key="test_123456", + response = syncify( + module_instance.create_event( + organization_id=organization_id, + event=event, + idempotency_key="test_123456", + ) ) assert request_kwargs["url"].endswith("/audit_logs/events") @@ -87,52 +89,65 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert response is None def test_sends_idempotency_key( - self, mock_audit_log_event, capture_and_mock_http_client_request + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_audit_log_event, + capture_and_mock_http_client_request, ): idempotency_key = "test_123456789" organization_id = "org_123456789" request_kwargs = capture_and_mock_http_client_request( - self.http_client, {"success": True}, 200 + module_instance._http_client, {"success": True}, 200 ) - response = self.audit_logs.create_event( - organization_id=organization_id, - event=mock_audit_log_event, - idempotency_key=idempotency_key, + response = syncify( + module_instance.create_event( + organization_id=organization_id, + event=mock_audit_log_event, + idempotency_key=idempotency_key, + ) ) assert request_kwargs["headers"]["idempotency-key"] == idempotency_key assert response is None def test_throws_unauthorized_exception( - self, mock_audit_log_event, mock_http_client_with_response + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_audit_log_event, + mock_http_client_with_response, ): organization_id = "org_123456789" mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.create_event( - organization_id=organization_id, event=mock_audit_log_event + syncify( + module_instance.create_event( + organization_id=organization_id, event=mock_audit_log_event + ) ) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) def test_throws_badrequest_excpetion( - self, mock_audit_log_event, mock_http_client_with_response + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_audit_log_event, + mock_http_client_with_response, ): organization_id = "org_123456789" mock_http_client_with_response( - self.http_client, + module_instance._http_client, { "message": "Audit Log could not be processed due to missing or incorrect data.", "code": "invalid_audit_log", @@ -142,8 +157,10 @@ def test_throws_badrequest_excpetion( ) with pytest.raises(BadRequestException) as excinfo: - self.audit_logs.create_event( - organization_id=organization_id, event=mock_audit_log_event + syncify( + module_instance.create_event( + organization_id=organization_id, event=mock_audit_log_event + ) ) assert excinfo.code == "invalid_audit_log" assert excinfo.errors == ["error in a field"] @@ -152,8 +169,12 @@ def test_throws_badrequest_excpetion( == "Audit Log could not be processed due to missing or incorrect data." ) - class TestCreateExport(_TestSetup): - def test_succeeds(self, mock_http_client_with_response): + class TestCreateExport: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): organization_id = "org_123456789" now = datetime.now().isoformat() range_start = now @@ -168,18 +189,24 @@ def test_succeeds(self, mock_http_client_with_response): "updated_at": now, } - mock_http_client_with_response(self.http_client, expected_payload, 201) + mock_http_client_with_response( + module_instance._http_client, expected_payload, 201 + ) - response = self.audit_logs.create_export( - organization_id=organization_id, - range_start=range_start, - range_end=range_end, + response = syncify( + module_instance.create_export( + organization_id=organization_id, + range_start=range_start, + range_end=range_end, + ) ) assert response.dict() == expected_payload def test_succeeds_with_additional_filters( - self, capture_and_mock_http_client_request + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, ): now = datetime.now().isoformat() organization_id = "org_123456789" @@ -200,17 +227,19 @@ def test_succeeds_with_additional_filters( } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 201 + module_instance._http_client, expected_payload, 201 ) - response = self.audit_logs.create_export( - actions=actions, - organization_id=organization_id, - range_end=range_end, - range_start=range_start, - targets=targets, - actor_names=actor_names, - actor_ids=actor_ids, + response = syncify( + module_instance.create_export( + actions=actions, + organization_id=organization_id, + range_end=range_end, + range_start=range_start, + targets=targets, + actor_names=actor_names, + actor_ids=actor_ids, + ) ) assert request_kwargs["url"].endswith("/audit_logs/exports") @@ -226,30 +255,40 @@ def test_succeeds_with_additional_filters( } assert response.dict() == expected_payload - def test_throws_unauthorized_excpetion(self, mock_http_client_with_response): + def test_throws_unauthorized_excpetion( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): organization_id = "org_123456789" range_start = datetime.now().isoformat() range_end = datetime.now().isoformat() mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.create_export( - organization_id=organization_id, - range_start=range_start, - range_end=range_end, + syncify( + module_instance.create_export( + organization_id=organization_id, + range_start=range_start, + range_end=range_end, + ) ) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) - class TestGetExport(_TestSetup): - def test_succeeds(self, capture_and_mock_http_client_request): + class TestGetExport: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): now = datetime.now().isoformat() expected_payload = { "object": "audit_log_export", @@ -261,11 +300,13 @@ def test_succeeds(self, capture_and_mock_http_client_request): } request_kwargs = capture_and_mock_http_client_request( - self.http_client, expected_payload, 200 + module_instance._http_client, expected_payload, 200 ) - response = self.audit_logs.get_export( - expected_payload["id"], + response = syncify( + module_instance.get_export( + expected_payload["id"], + ) ) assert request_kwargs["url"].endswith( @@ -274,16 +315,510 @@ def test_succeeds(self, capture_and_mock_http_client_request): assert request_kwargs["method"] == "get" assert response.dict() == expected_payload - def test_throws_unauthorized_excpetion(self, mock_http_client_with_response): + def test_throws_unauthorized_excpetion( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): + mock_http_client_with_response( + module_instance._http_client, + {"message": "Unauthorized"}, + 401, + {"X-Request-ID": "a-request-id"}, + ) + + with pytest.raises(AuthenticationException) as excinfo: + syncify(module_instance.get_export("audit_log_export_1234")) + + assert "(message=Unauthorized, request_id=a-request-id)" == str( + excinfo.value + ) + + class TestCreateSchema: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + action = "user.signed_in" + + expected_payload = { + "object": "audit_log_schema", + "version": 1, + "targets": [{"type": "user"}], + "actor": {"metadata": {"type": "object", "properties": {}}}, + "metadata": None, + "created_at": "2024-10-14T15:09:44.537Z", + } + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 201 + ) + + response = syncify( + module_instance.create_schema( + action=action, + targets=[{"type": "user"}], + ) + ) + + assert request_kwargs["url"].endswith( + f"/audit_logs/actions/{action}/schemas" + ) + assert request_kwargs["method"] == "post" + assert request_kwargs["json"] == {"targets": [{"type": "user"}]} + assert response.version == 1 + assert response.targets[0].type == "user" + + def test_sends_idempotency_key( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + action = "user.signed_in" + idempotency_key = "test_123456789" + + expected_payload = { + "object": "audit_log_schema", + "version": 1, + "targets": [{"type": "user"}], + "actor": {"metadata": {"type": "object", "properties": {}}}, + "created_at": "2024-10-14T15:09:44.537Z", + } + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 201 + ) + + syncify( + module_instance.create_schema( + action=action, + targets=[{"type": "user"}], + idempotency_key=idempotency_key, + ) + ) + + assert request_kwargs["headers"]["idempotency-key"] == idempotency_key + + def test_with_actor_and_metadata( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + action = "user.viewed_invoice" + + expected_payload = { + "object": "audit_log_schema", + "version": 1, + "targets": [ + { + "type": "invoice", + "metadata": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + } + ], + "actor": { + "metadata": { + "type": "object", + "properties": {"role": {"type": "string"}}, + } + }, + "metadata": { + "type": "object", + "properties": {"transactionId": {"type": "string"}}, + }, + "created_at": "2024-10-14T15:09:44.537Z", + } + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 201 + ) + + response = syncify( + module_instance.create_schema( + action=action, + targets=[ + { + "type": "invoice", + "metadata": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + } + ], + actor={ + "metadata": { + "type": "object", + "properties": {"role": {"type": "string"}}, + } + }, + metadata={ + "type": "object", + "properties": {"transactionId": {"type": "string"}}, + }, + ) + ) + + assert request_kwargs["json"]["actor"] is not None + assert request_kwargs["json"]["metadata"] is not None + assert response.metadata is not None + + def test_throws_unauthorized_exception( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): + mock_http_client_with_response( + module_instance._http_client, + {"message": "Unauthorized"}, + 401, + {"X-Request-ID": "a-request-id"}, + ) + + with pytest.raises(AuthenticationException) as excinfo: + syncify( + module_instance.create_schema( + action="user.signed_in", + targets=[{"type": "user"}], + ) + ) + + assert "(message=Unauthorized, request_id=a-request-id)" == str( + excinfo.value + ) + + class TestListSchemas: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + action = "user.viewed_invoice" + + expected_payload = { + "object": "list", + "data": [ + { + "version": 1, + "actor": { + "metadata": { + "type": "object", + "properties": {"role": {"type": "string"}}, + } + }, + "targets": [ + { + "type": "invoice", + "metadata": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + } + ], + "metadata": { + "type": "object", + "properties": {"transactionId": {"type": "string"}}, + }, + "updated_at": "2021-06-25T19:07:33.155Z", + } + ], + "list_metadata": {"before": None, "after": None}, + } + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 200 + ) + + response = syncify(module_instance.list_schemas(action=action)) + + assert request_kwargs["url"].endswith( + f"/audit_logs/actions/{action}/schemas" + ) + assert request_kwargs["method"] == "get" + assert len(response.data) == 1 + assert response.data[0].version == 1 + + def test_with_pagination_params( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + action = "user.signed_in" + + expected_payload = { + "object": "list", + "data": [], + "list_metadata": {"before": None, "after": None}, + } + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 200 + ) + + syncify( + module_instance.list_schemas( + action=action, + limit=5, + order="asc", + ) + ) + + assert request_kwargs["params"]["limit"] == 5 + assert request_kwargs["params"]["order"] == "asc" + + class TestListActions: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + expected_payload = { + "object": "list", + "data": [ + { + "object": "audit_log_action", + "name": "user.viewed_invoice", + "schema": { + "object": "audit_log_schema", + "version": 1, + "actor": { + "metadata": { + "type": "object", + "properties": {"role": {"type": "string"}}, + } + }, + "targets": [ + { + "type": "invoice", + "metadata": { + "type": "object", + "properties": {"status": {"type": "string"}}, + }, + } + ], + "metadata": { + "type": "object", + "properties": {"transactionId": {"type": "string"}}, + }, + "updated_at": "2021-06-25T19:07:33.155Z", + }, + "created_at": "2021-06-25T19:07:33.155Z", + "updated_at": "2021-06-25T19:07:33.155Z", + } + ], + "list_metadata": {"before": None, "after": None}, + } + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 200 + ) + + response = syncify(module_instance.list_actions()) + + assert request_kwargs["url"].endswith("/audit_logs/actions") + assert request_kwargs["method"] == "get" + assert len(response.data) == 1 + assert response.data[0].name == "user.viewed_invoice" + assert response.data[0].schema.version == 1 + + def test_with_pagination_params( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + expected_payload = { + "object": "list", + "data": [], + "list_metadata": {"before": None, "after": None}, + } + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 200 + ) + + syncify( + module_instance.list_actions( + limit=10, + order="asc", + after="cursor_123", + ) + ) + + assert request_kwargs["params"]["limit"] == 10 + assert request_kwargs["params"]["order"] == "asc" + assert request_kwargs["params"]["after"] == "cursor_123" + + class TestGetRetention: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + organization_id = "org_123456789" + + expected_payload = {"retention_period_in_days": 30} + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 200 + ) + + response = syncify(module_instance.get_retention(organization_id)) + + assert request_kwargs["url"].endswith( + f"/organizations/{organization_id}/audit_logs_retention" + ) + assert request_kwargs["method"] == "get" + assert response.retention_period_in_days == 30 + + def test_throws_unauthorized_exception( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): + mock_http_client_with_response( + module_instance._http_client, + {"message": "Unauthorized"}, + 401, + {"X-Request-ID": "a-request-id"}, + ) + + with pytest.raises(AuthenticationException) as excinfo: + syncify(module_instance.get_retention("org_123456789")) + + assert "(message=Unauthorized, request_id=a-request-id)" == str( + excinfo.value + ) + + class TestSetRetention: + def test_succeeds( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + organization_id = "org_123456789" + + expected_payload = {"retention_period_in_days": 365} + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 200 + ) + + response = syncify( + module_instance.set_retention( + organization_id=organization_id, + retention_period_in_days=365, + ) + ) + + assert request_kwargs["url"].endswith( + f"/organizations/{organization_id}/audit_logs_retention" + ) + assert request_kwargs["method"] == "put" + assert request_kwargs["json"] == {"retention_period_in_days": 365} + assert response.retention_period_in_days == 365 + + def test_throws_unauthorized_exception( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): + mock_http_client_with_response( + module_instance._http_client, + {"message": "Unauthorized"}, + 401, + {"X-Request-ID": "a-request-id"}, + ) + + with pytest.raises(AuthenticationException) as excinfo: + syncify( + module_instance.set_retention( + organization_id="org_123456789", + retention_period_in_days=30, + ) + ) + + assert "(message=Unauthorized, request_id=a-request-id)" == str( + excinfo.value + ) + + class TestGetConfiguration: + def test_succeeds_with_log_stream( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + organization_id = "org_123456789" + + expected_payload = { + "organization_id": organization_id, + "retention_period_in_days": 30, + "state": "active", + "log_stream": { + "id": "audit_log_stream_01HQJW5XBQZ8Y4R9S3T5V6W7X8", + "type": "Datadog", + "state": "active", + "last_synced_at": "2024-01-15T10:30:00.000Z", + "created_at": "2024-01-15T10:30:00.000Z", + }, + } + + request_kwargs = capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 200 + ) + + response = syncify(module_instance.get_configuration(organization_id)) + + assert request_kwargs["url"].endswith( + f"/organizations/{organization_id}/audit_log_configuration" + ) + assert request_kwargs["method"] == "get" + assert response.organization_id == organization_id + assert response.retention_period_in_days == 30 + assert response.state == "active" + assert response.log_stream is not None + assert response.log_stream.type == "Datadog" + assert response.log_stream.state == "active" + + def test_succeeds_without_log_stream( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + capture_and_mock_http_client_request, + ): + organization_id = "org_123456789" + + expected_payload = { + "organization_id": organization_id, + "retention_period_in_days": 30, + "state": "inactive", + } + + capture_and_mock_http_client_request( + module_instance._http_client, expected_payload, 200 + ) + + response = syncify(module_instance.get_configuration(organization_id)) + + assert response.organization_id == organization_id + assert response.retention_period_in_days == 30 + assert response.state == "inactive" + assert response.log_stream is None + + def test_throws_unauthorized_exception( + self, + module_instance: Union[AuditLogs, AsyncAuditLogs], + mock_http_client_with_response, + ): mock_http_client_with_response( - self.http_client, + module_instance._http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.get_export("audit_log_export_1234") + syncify(module_instance.get_configuration("org_123456789")) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value