diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 56ee145..6772f01 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.70.0" + ".": "0.71.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2018924..ef32d38 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 122 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-d9b82fc5346c9be1bf9c2b18792fdcdec6a80a86153ca765c9e93e597b46fa24.yml -openapi_spec_hash: 9cbaab975acfa421b795d11aa635c57e -config_hash: 99b2b2a25e8067ad9c9214e38e01d64c +configured_endpoints: 124 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-4fb45d71a99648425c84bdc8e5780920105cede4ee2d4eac67276d0609ac1e94.yml +openapi_spec_hash: 1f04cb5b36e92db81dfa264c2a59c32a +config_hash: fb167e754ebb3a14649463725891c9d0 diff --git a/CHANGELOG.md b/CHANGELOG.md index acc9883..35090e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.71.0 (2026-06-26) + +Full Changelog: [v0.70.0...v0.71.0](https://github.com/kernel/kernel-python-sdk/compare/v0.70.0...v0.71.0) + +### Features + +* Add auth connection event timeline endpoint ([871432f](https://github.com/kernel/kernel-python-sdk/commit/871432f827c95b11d132f60b3547c9986e266f6b)) +* Expose audit logs in public SDK ([9c8e9ac](https://github.com/kernel/kernel-python-sdk/commit/9c8e9ace1cdddc6ce727b38fec62beaba6c6fa20)) + ## 0.70.0 (2026-06-24) Full Changelog: [v0.69.0...v0.70.0](https://github.com/kernel/kernel-python-sdk/compare/v0.69.0...v0.70.0) diff --git a/api.md b/api.md index 88588c4..5c1c6b9 100644 --- a/api.md +++ b/api.md @@ -300,6 +300,7 @@ from kernel.types.auth import ( LoginResponse, ManagedAuth, ManagedAuthCreateRequest, + ManagedAuthTimelineEvent, ManagedAuthUpdateRequest, SubmitFieldsRequest, SubmitFieldsResponse, @@ -317,6 +318,7 @@ Methods: - client.auth.connections.follow(id) -> ConnectionFollowResponse - client.auth.connections.login(id, \*\*params) -> LoginResponse - client.auth.connections.submit(id, \*\*params) -> SubmitFieldsResponse +- client.auth.connections.timeline(id, \*\*params) -> SyncOffsetPagination[ManagedAuthTimelineEvent] # Proxies @@ -441,6 +443,18 @@ Methods: - client.organization.limits.retrieve() -> OrgLimits - client.organization.limits.update(\*\*params) -> OrgLimits +# AuditLogs + +Types: + +```python +from kernel.types import AuditLogEntry +``` + +Methods: + +- client.audit_logs.list(\*\*params) -> SyncPageTokenPagination[AuditLogEntry] + # APIKeys Types: diff --git a/pyproject.toml b/pyproject.toml index 84fce8f..9187ed7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kernel" -version = "0.70.0" +version = "0.71.0" description = "The official Python library for the kernel API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/kernel/_client.py b/src/kernel/_client.py index 977b83d..0238b20 100644 --- a/src/kernel/_client.py +++ b/src/kernel/_client.py @@ -54,6 +54,7 @@ browsers, profiles, projects, + audit_logs, extensions, credentials, deployments, @@ -67,6 +68,7 @@ from .resources.api_keys import APIKeysResource, AsyncAPIKeysResource from .resources.profiles import ProfilesResource, AsyncProfilesResource from .resources.auth.auth import AuthResource, AsyncAuthResource + from .resources.audit_logs import AuditLogsResource, AsyncAuditLogsResource from .resources.extensions import ExtensionsResource, AsyncExtensionsResource from .resources.credentials import CredentialsResource, AsyncCredentialsResource from .resources.deployments import DeploymentsResource, AsyncDeploymentsResource @@ -275,6 +277,13 @@ def organization(self) -> OrganizationResource: return OrganizationResource(self) + @cached_property + def audit_logs(self) -> AuditLogsResource: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AuditLogsResource + + return AuditLogsResource(self) + @cached_property def api_keys(self) -> APIKeysResource: """Create and manage API keys for organization and project-scoped access.""" @@ -620,6 +629,13 @@ def organization(self) -> AsyncOrganizationResource: return AsyncOrganizationResource(self) + @cached_property + def audit_logs(self) -> AsyncAuditLogsResource: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AsyncAuditLogsResource + + return AsyncAuditLogsResource(self) + @cached_property def api_keys(self) -> AsyncAPIKeysResource: """Create and manage API keys for organization and project-scoped access.""" @@ -873,6 +889,13 @@ def organization(self) -> organization.OrganizationResourceWithRawResponse: return OrganizationResourceWithRawResponse(self._client.organization) + @cached_property + def audit_logs(self) -> audit_logs.AuditLogsResourceWithRawResponse: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AuditLogsResourceWithRawResponse + + return AuditLogsResourceWithRawResponse(self._client.audit_logs) + @cached_property def api_keys(self) -> api_keys.APIKeysResourceWithRawResponse: """Create and manage API keys for organization and project-scoped access.""" @@ -976,6 +999,13 @@ def organization(self) -> organization.AsyncOrganizationResourceWithRawResponse: return AsyncOrganizationResourceWithRawResponse(self._client.organization) + @cached_property + def audit_logs(self) -> audit_logs.AsyncAuditLogsResourceWithRawResponse: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AsyncAuditLogsResourceWithRawResponse + + return AsyncAuditLogsResourceWithRawResponse(self._client.audit_logs) + @cached_property def api_keys(self) -> api_keys.AsyncAPIKeysResourceWithRawResponse: """Create and manage API keys for organization and project-scoped access.""" @@ -1079,6 +1109,13 @@ def organization(self) -> organization.OrganizationResourceWithStreamingResponse return OrganizationResourceWithStreamingResponse(self._client.organization) + @cached_property + def audit_logs(self) -> audit_logs.AuditLogsResourceWithStreamingResponse: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AuditLogsResourceWithStreamingResponse + + return AuditLogsResourceWithStreamingResponse(self._client.audit_logs) + @cached_property def api_keys(self) -> api_keys.APIKeysResourceWithStreamingResponse: """Create and manage API keys for organization and project-scoped access.""" @@ -1182,6 +1219,13 @@ def organization(self) -> organization.AsyncOrganizationResourceWithStreamingRes return AsyncOrganizationResourceWithStreamingResponse(self._client.organization) + @cached_property + def audit_logs(self) -> audit_logs.AsyncAuditLogsResourceWithStreamingResponse: + """Read audit log records for the authenticated organization.""" + from .resources.audit_logs import AsyncAuditLogsResourceWithStreamingResponse + + return AsyncAuditLogsResourceWithStreamingResponse(self._client.audit_logs) + @cached_property def api_keys(self) -> api_keys.AsyncAPIKeysResourceWithStreamingResponse: """Create and manage API keys for organization and project-scoped access.""" diff --git a/src/kernel/_version.py b/src/kernel/_version.py index 6d6da8f..0bf9994 100644 --- a/src/kernel/_version.py +++ b/src/kernel/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "kernel" -__version__ = "0.70.0" # x-release-please-version +__version__ = "0.71.0" # x-release-please-version diff --git a/src/kernel/pagination.py b/src/kernel/pagination.py index 39d5a08..98152da 100644 --- a/src/kernel/pagination.py +++ b/src/kernel/pagination.py @@ -9,13 +9,93 @@ from ._models import BaseModel from ._base_client import BasePage, PageInfo, BaseSyncPage, BaseAsyncPage -__all__ = ["SyncOffsetPagination", "AsyncOffsetPagination"] +__all__ = ["SyncPageTokenPagination", "AsyncPageTokenPagination", "SyncOffsetPagination", "AsyncOffsetPagination"] _BaseModelT = TypeVar("_BaseModelT", bound=BaseModel) _T = TypeVar("_T") +class SyncPageTokenPagination(BaseSyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + next_page_token: Optional[str] = None + has_more: Optional[bool] = None + + @override + def _get_page_items(self) -> List[_T]: + items = self.items + if not items: + return [] + return items + + @override + def has_next_page(self) -> bool: + has_more = self.has_more + if has_more is not None and has_more is False: + return False + + return super().has_next_page() + + @override + def next_page_info(self) -> Optional[PageInfo]: + next_page_token = self.next_page_token + if not next_page_token: + return None + + return PageInfo(params={"page_token": next_page_token}) + + @classmethod + def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003 + return cls.construct( + None, + **{ + **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + "next_page_token": response.headers.get("X-Next-Page-Token"), + "has_more": maybe_coerce_boolean(response.headers.get("X-Has-More")), + }, + ) + + +class AsyncPageTokenPagination(BaseAsyncPage[_T], BasePage[_T], Generic[_T]): + items: List[_T] + next_page_token: Optional[str] = None + has_more: Optional[bool] = None + + @override + def _get_page_items(self) -> List[_T]: + items = self.items + if not items: + return [] + return items + + @override + def has_next_page(self) -> bool: + has_more = self.has_more + if has_more is not None and has_more is False: + return False + + return super().has_next_page() + + @override + def next_page_info(self) -> Optional[PageInfo]: + next_page_token = self.next_page_token + if not next_page_token: + return None + + return PageInfo(params={"page_token": next_page_token}) + + @classmethod + def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003 + return cls.construct( + None, + **{ + **(cast(Mapping[str, Any], data) if is_mapping(data) else {"items": data}), + "next_page_token": response.headers.get("X-Next-Page-Token"), + "has_more": maybe_coerce_boolean(response.headers.get("X-Has-More")), + }, + ) + + class SyncOffsetPagination(BaseSyncPage[_T], BasePage[_T], Generic[_T]): items: List[_T] has_more: Optional[bool] = None diff --git a/src/kernel/resources/__init__.py b/src/kernel/resources/__init__.py index 1497ad6..586c499 100644 --- a/src/kernel/resources/__init__.py +++ b/src/kernel/resources/__init__.py @@ -56,6 +56,14 @@ ProjectsResourceWithStreamingResponse, AsyncProjectsResourceWithStreamingResponse, ) +from .audit_logs import ( + AuditLogsResource, + AsyncAuditLogsResource, + AuditLogsResourceWithRawResponse, + AsyncAuditLogsResourceWithRawResponse, + AuditLogsResourceWithStreamingResponse, + AsyncAuditLogsResourceWithStreamingResponse, +) from .extensions import ( ExtensionsResource, AsyncExtensionsResource, @@ -186,6 +194,12 @@ "AsyncOrganizationResourceWithRawResponse", "OrganizationResourceWithStreamingResponse", "AsyncOrganizationResourceWithStreamingResponse", + "AuditLogsResource", + "AsyncAuditLogsResource", + "AuditLogsResourceWithRawResponse", + "AsyncAuditLogsResourceWithRawResponse", + "AuditLogsResourceWithStreamingResponse", + "AsyncAuditLogsResourceWithStreamingResponse", "APIKeysResource", "AsyncAPIKeysResource", "APIKeysResourceWithRawResponse", diff --git a/src/kernel/resources/audit_logs.py b/src/kernel/resources/audit_logs.py new file mode 100644 index 0000000..bce8b71 --- /dev/null +++ b/src/kernel/resources/audit_logs.py @@ -0,0 +1,269 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import datetime + +import httpx + +from ..types import audit_log_list_params +from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from .._utils import maybe_transform +from .._compat import cached_property +from .._resource import SyncAPIResource, AsyncAPIResource +from .._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..pagination import SyncPageTokenPagination, AsyncPageTokenPagination +from .._base_client import AsyncPaginator, make_request_options +from ..types.audit_log_entry import AuditLogEntry + +__all__ = ["AuditLogsResource", "AsyncAuditLogsResource"] + + +class AuditLogsResource(SyncAPIResource): + """Read audit log records for the authenticated organization.""" + + @cached_property + def with_raw_response(self) -> AuditLogsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AuditLogsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AuditLogsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AuditLogsResourceWithStreamingResponse(self) + + def list( + self, + *, + end: Union[str, datetime], + start: Union[str, datetime], + auth_strategy: str | Omit = omit, + exclude_method: str | Omit = omit, + limit: int | Omit = omit, + method: str | Omit = omit, + page_token: str | Omit = omit, + search: str | Omit = omit, + search_user_id: SequenceNotStr[str] | Omit = omit, + service: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncPageTokenPagination[AuditLogEntry]: + """API for searching audit logs. + + Limited to at most 30 day search, returns up to + 100 records per page. Not recommended for bulk export. + + Args: + end: Upper bound (exclusive) for the audit record timestamp. + + start: Lower bound (inclusive) for the audit record timestamp. + + auth_strategy: Filter by authentication strategy. + + exclude_method: Filter out results by HTTP method. + + limit: Maximum number of results to return. + + method: Filter by HTTP method. + + page_token: Opaque page token from X-Next-Page-Token for the next page of older records. + + search: Free-text search over path, user ID, email, client IP, and status. + + search_user_id: Additional user IDs to OR into free-text search. + + service: Filter by service name. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/audit-logs", + page=SyncPageTokenPagination[AuditLogEntry], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "end": end, + "start": start, + "auth_strategy": auth_strategy, + "exclude_method": exclude_method, + "limit": limit, + "method": method, + "page_token": page_token, + "search": search, + "search_user_id": search_user_id, + "service": service, + }, + audit_log_list_params.AuditLogListParams, + ), + ), + model=AuditLogEntry, + ) + + +class AsyncAuditLogsResource(AsyncAPIResource): + """Read audit log records for the authenticated organization.""" + + @cached_property + def with_raw_response(self) -> AsyncAuditLogsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#accessing-raw-response-data-eg-headers + """ + return AsyncAuditLogsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncAuditLogsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/kernel/kernel-python-sdk#with_streaming_response + """ + return AsyncAuditLogsResourceWithStreamingResponse(self) + + def list( + self, + *, + end: Union[str, datetime], + start: Union[str, datetime], + auth_strategy: str | Omit = omit, + exclude_method: str | Omit = omit, + limit: int | Omit = omit, + method: str | Omit = omit, + page_token: str | Omit = omit, + search: str | Omit = omit, + search_user_id: SequenceNotStr[str] | Omit = omit, + service: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[AuditLogEntry, AsyncPageTokenPagination[AuditLogEntry]]: + """API for searching audit logs. + + Limited to at most 30 day search, returns up to + 100 records per page. Not recommended for bulk export. + + Args: + end: Upper bound (exclusive) for the audit record timestamp. + + start: Lower bound (inclusive) for the audit record timestamp. + + auth_strategy: Filter by authentication strategy. + + exclude_method: Filter out results by HTTP method. + + limit: Maximum number of results to return. + + method: Filter by HTTP method. + + page_token: Opaque page token from X-Next-Page-Token for the next page of older records. + + search: Free-text search over path, user ID, email, client IP, and status. + + search_user_id: Additional user IDs to OR into free-text search. + + service: Filter by service name. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + return self._get_api_list( + "/audit-logs", + page=AsyncPageTokenPagination[AuditLogEntry], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "end": end, + "start": start, + "auth_strategy": auth_strategy, + "exclude_method": exclude_method, + "limit": limit, + "method": method, + "page_token": page_token, + "search": search, + "search_user_id": search_user_id, + "service": service, + }, + audit_log_list_params.AuditLogListParams, + ), + ), + model=AuditLogEntry, + ) + + +class AuditLogsResourceWithRawResponse: + def __init__(self, audit_logs: AuditLogsResource) -> None: + self._audit_logs = audit_logs + + self.list = to_raw_response_wrapper( + audit_logs.list, + ) + + +class AsyncAuditLogsResourceWithRawResponse: + def __init__(self, audit_logs: AsyncAuditLogsResource) -> None: + self._audit_logs = audit_logs + + self.list = async_to_raw_response_wrapper( + audit_logs.list, + ) + + +class AuditLogsResourceWithStreamingResponse: + def __init__(self, audit_logs: AuditLogsResource) -> None: + self._audit_logs = audit_logs + + self.list = to_streamed_response_wrapper( + audit_logs.list, + ) + + +class AsyncAuditLogsResourceWithStreamingResponse: + def __init__(self, audit_logs: AsyncAuditLogsResource) -> None: + self._audit_logs = audit_logs + + self.list = async_to_streamed_response_wrapper( + audit_logs.list, + ) diff --git a/src/kernel/resources/auth/connections.py b/src/kernel/resources/auth/connections.py index c901fdb..24a560c 100644 --- a/src/kernel/resources/auth/connections.py +++ b/src/kernel/resources/auth/connections.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Dict, cast +from typing_extensions import Literal import httpx @@ -24,12 +25,14 @@ connection_create_params, connection_submit_params, connection_update_params, + connection_timeline_params, ) from ..._base_client import AsyncPaginator, make_request_options from ...types.auth.managed_auth import ManagedAuth from ...types.auth.login_response import LoginResponse from ...types.auth.submit_fields_response import SubmitFieldsResponse from ...types.auth.connection_follow_response import ConnectionFollowResponse +from ...types.auth.managed_auth_timeline_event import ManagedAuthTimelineEvent __all__ = ["ConnectionsResource", "AsyncConnectionsResource"] @@ -554,6 +557,62 @@ def submit( cast_to=SubmitFieldsResponse, ) + def timeline( + self, + id: str, + *, + limit: int | Omit = omit, + offset: int | Omit = omit, + type: Literal["login", "reauth", "health_check"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> SyncOffsetPagination[ManagedAuthTimelineEvent]: + """ + Returns a chronological timeline of events for an auth connection — login + attempts, automatic re-auth attempts, and health checks. Events are returned + newest-first. + + Args: + limit: Maximum number of events to return + + offset: Number of events to skip + + type: Filter the timeline to a single event type. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get_api_list( + path_template("/auth/connections/{id}/timeline", id=id), + page=SyncOffsetPagination[ManagedAuthTimelineEvent], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "offset": offset, + "type": type, + }, + connection_timeline_params.ConnectionTimelineParams, + ), + ), + model=ManagedAuthTimelineEvent, + ) + class AsyncConnectionsResource(AsyncAPIResource): """Create and manage auth connections for automated credential capture and login.""" @@ -1075,6 +1134,62 @@ async def submit( cast_to=SubmitFieldsResponse, ) + def timeline( + self, + id: str, + *, + limit: int | Omit = omit, + offset: int | Omit = omit, + type: Literal["login", "reauth", "health_check"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncPaginator[ManagedAuthTimelineEvent, AsyncOffsetPagination[ManagedAuthTimelineEvent]]: + """ + Returns a chronological timeline of events for an auth connection — login + attempts, automatic re-auth attempts, and health checks. Events are returned + newest-first. + + Args: + limit: Maximum number of events to return + + offset: Number of events to skip + + type: Filter the timeline to a single event type. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not id: + raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") + return self._get_api_list( + path_template("/auth/connections/{id}/timeline", id=id), + page=AsyncOffsetPagination[ManagedAuthTimelineEvent], + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "limit": limit, + "offset": offset, + "type": type, + }, + connection_timeline_params.ConnectionTimelineParams, + ), + ), + model=ManagedAuthTimelineEvent, + ) + class ConnectionsResourceWithRawResponse: def __init__(self, connections: ConnectionsResource) -> None: @@ -1104,6 +1219,9 @@ def __init__(self, connections: ConnectionsResource) -> None: self.submit = to_raw_response_wrapper( connections.submit, ) + self.timeline = to_raw_response_wrapper( + connections.timeline, + ) class AsyncConnectionsResourceWithRawResponse: @@ -1134,6 +1252,9 @@ def __init__(self, connections: AsyncConnectionsResource) -> None: self.submit = async_to_raw_response_wrapper( connections.submit, ) + self.timeline = async_to_raw_response_wrapper( + connections.timeline, + ) class ConnectionsResourceWithStreamingResponse: @@ -1164,6 +1285,9 @@ def __init__(self, connections: ConnectionsResource) -> None: self.submit = to_streamed_response_wrapper( connections.submit, ) + self.timeline = to_streamed_response_wrapper( + connections.timeline, + ) class AsyncConnectionsResourceWithStreamingResponse: @@ -1194,3 +1318,6 @@ def __init__(self, connections: AsyncConnectionsResource) -> None: self.submit = async_to_streamed_response_wrapper( connections.submit, ) + self.timeline = async_to_streamed_response_wrapper( + connections.timeline, + ) diff --git a/src/kernel/types/__init__.py b/src/kernel/types/__init__.py index 660875d..357afc9 100644 --- a/src/kernel/types/__init__.py +++ b/src/kernel/types/__init__.py @@ -24,6 +24,7 @@ from .browser_pool import BrowserPool as BrowserPool from .browser_usage import BrowserUsage as BrowserUsage from .app_list_params import AppListParams as AppListParams +from .audit_log_entry import AuditLogEntry as AuditLogEntry from .created_api_key import CreatedAPIKey as CreatedAPIKey from .browser_pool_ref import BrowserPoolRef as BrowserPoolRef from .app_list_response import AppListResponse as AppListResponse @@ -41,6 +42,7 @@ from .api_key_create_params import APIKeyCreateParams as APIKeyCreateParams from .api_key_rotate_params import APIKeyRotateParams as APIKeyRotateParams from .api_key_update_params import APIKeyUpdateParams as APIKeyUpdateParams +from .audit_log_list_params import AuditLogListParams as AuditLogListParams from .browser_create_params import BrowserCreateParams as BrowserCreateParams from .browser_curl_response import BrowserCurlResponse as BrowserCurlResponse from .browser_list_response import BrowserListResponse as BrowserListResponse diff --git a/src/kernel/types/audit_log_entry.py b/src/kernel/types/audit_log_entry.py new file mode 100644 index 0000000..dd99809 --- /dev/null +++ b/src/kernel/types/audit_log_entry.py @@ -0,0 +1,45 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from datetime import datetime + +from .._models import BaseModel + +__all__ = ["AuditLogEntry"] + + +class AuditLogEntry(BaseModel): + auth_strategy: str + """Authentication strategy used for the request.""" + + client_ip: str + """Client IP address.""" + + domain: str + """Request host.""" + + duration_ms: int + """Request duration in milliseconds.""" + + email: str + """Email of the authenticated user at request time, if any.""" + + method: str + """HTTP method.""" + + path: str + """Request path.""" + + route: str + """Matched API route pattern, if available.""" + + status: int + """HTTP response status code.""" + + timestamp: datetime + """UTC time when the request was received.""" + + user_agent: str + """User agent header.""" + + user_id: str + """ID of the authenticated user, if any.""" diff --git a/src/kernel/types/audit_log_list_params.py b/src/kernel/types/audit_log_list_params.py new file mode 100644 index 0000000..f1e18f5 --- /dev/null +++ b/src/kernel/types/audit_log_list_params.py @@ -0,0 +1,44 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from datetime import datetime +from typing_extensions import Required, Annotated, TypedDict + +from .._types import SequenceNotStr +from .._utils import PropertyInfo + +__all__ = ["AuditLogListParams"] + + +class AuditLogListParams(TypedDict, total=False): + end: Required[Annotated[Union[str, datetime], PropertyInfo(format="iso8601")]] + """Upper bound (exclusive) for the audit record timestamp.""" + + start: Required[Annotated[Union[str, datetime], PropertyInfo(format="iso8601")]] + """Lower bound (inclusive) for the audit record timestamp.""" + + auth_strategy: str + """Filter by authentication strategy.""" + + exclude_method: str + """Filter out results by HTTP method.""" + + limit: int + """Maximum number of results to return.""" + + method: str + """Filter by HTTP method.""" + + page_token: str + """Opaque page token from X-Next-Page-Token for the next page of older records.""" + + search: str + """Free-text search over path, user ID, email, client IP, and status.""" + + search_user_id: SequenceNotStr[str] + """Additional user IDs to OR into free-text search.""" + + service: str + """Filter by service name.""" diff --git a/src/kernel/types/auth/__init__.py b/src/kernel/types/auth/__init__.py index db89794..a70816f 100644 --- a/src/kernel/types/auth/__init__.py +++ b/src/kernel/types/auth/__init__.py @@ -11,3 +11,5 @@ from .connection_submit_params import ConnectionSubmitParams as ConnectionSubmitParams from .connection_update_params import ConnectionUpdateParams as ConnectionUpdateParams from .connection_follow_response import ConnectionFollowResponse as ConnectionFollowResponse +from .connection_timeline_params import ConnectionTimelineParams as ConnectionTimelineParams +from .managed_auth_timeline_event import ManagedAuthTimelineEvent as ManagedAuthTimelineEvent diff --git a/src/kernel/types/auth/connection_timeline_params.py b/src/kernel/types/auth/connection_timeline_params.py new file mode 100644 index 0000000..79c4f5d --- /dev/null +++ b/src/kernel/types/auth/connection_timeline_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, TypedDict + +__all__ = ["ConnectionTimelineParams"] + + +class ConnectionTimelineParams(TypedDict, total=False): + limit: int + """Maximum number of events to return""" + + offset: int + """Number of events to skip""" + + type: Literal["login", "reauth", "health_check"] + """Filter the timeline to a single event type.""" diff --git a/src/kernel/types/auth/managed_auth_timeline_event.py b/src/kernel/types/auth/managed_auth_timeline_event.py new file mode 100644 index 0000000..1f79a52 --- /dev/null +++ b/src/kernel/types/auth/managed_auth_timeline_event.py @@ -0,0 +1,77 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from ..._models import BaseModel + +__all__ = ["ManagedAuthTimelineEvent"] + + +class ManagedAuthTimelineEvent(BaseModel): + """ + A single event in an auth connection's history — a login attempt, an automatic re-auth attempt, or a health check. + """ + + id: str + """Identifier of the underlying login/reauth session or health check.""" + + status: Literal["IN_PROGRESS", "SUCCESS", "EXPIRED", "CANCELED", "FAILED", "AUTHENTICATED", "NEEDS_AUTH"] + """Outcome of the event. + + For login/reauth events this is the flow status (IN_PROGRESS, SUCCESS, EXPIRED, + CANCELED, FAILED). For health_check events it is the observed session state + (AUTHENTICATED, NEEDS_AUTH). + """ + + timestamp: datetime + """When the event occurred.""" + + type: Literal["login", "reauth", "health_check"] + """The kind of event. + + "login" and "reauth" are authentication attempts; "health_check" is a periodic + session-validity check. + """ + + error_code: Optional[str] = None + """Machine-readable error code. Present when a login/reauth event failed.""" + + error_message: Optional[str] = None + """Human-readable error message. Present when a login/reauth event failed.""" + + previous_status: Optional[Literal["AUTHENTICATED", "NEEDS_AUTH"]] = None + """The session state observed before this event. + + Present for health_check events that recorded a prior state. + """ + + replay_id: Optional[str] = None + """ + Replay recording ID for the event's browser session, if session recording was + enabled. + """ + + step: Optional[ + Literal[ + "INITIALIZED", + "DISCOVERING", + "AWAITING_INPUT", + "AWAITING_EXTERNAL_ACTION", + "AWAITING_HUMAN_INTERVENTION", + "SUBMITTING", + "COMPLETED", + "EXPIRED", + ] + ] = None + """The step the flow reached. Present for login/reauth events.""" + + updated_at: Optional[datetime] = None + """When the event was last updated. Present for login/reauth events.""" + + website_error: Optional[str] = None + """Visible error message from the website (e.g., 'Incorrect password'). + + Present when the website displayed an error during the attempt. + """ diff --git a/tests/api_resources/auth/test_connections.py b/tests/api_resources/auth/test_connections.py index 2b9ce1c..b92cd79 100644 --- a/tests/api_resources/auth/test_connections.py +++ b/tests/api_resources/auth/test_connections.py @@ -14,6 +14,7 @@ ManagedAuth, LoginResponse, SubmitFieldsResponse, + ManagedAuthTimelineEvent, ) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -430,6 +431,59 @@ def test_path_params_submit(self, client: Kernel) -> None: id="", ) + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_timeline(self, client: Kernel) -> None: + connection = client.auth.connections.timeline( + id="id", + ) + assert_matches_type(SyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_timeline_with_all_params(self, client: Kernel) -> None: + connection = client.auth.connections.timeline( + id="id", + limit=100, + offset=0, + type="login", + ) + assert_matches_type(SyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_timeline(self, client: Kernel) -> None: + response = client.auth.connections.with_raw_response.timeline( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = response.parse() + assert_matches_type(SyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_timeline(self, client: Kernel) -> None: + with client.auth.connections.with_streaming_response.timeline( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = response.parse() + assert_matches_type(SyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_path_params_timeline(self, client: Kernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + client.auth.connections.with_raw_response.timeline( + id="", + ) + class TestAsyncConnections: parametrize = pytest.mark.parametrize( @@ -843,3 +897,56 @@ async def test_path_params_submit(self, async_client: AsyncKernel) -> None: await async_client.auth.connections.with_raw_response.submit( id="", ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_timeline(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.timeline( + id="id", + ) + assert_matches_type(AsyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_timeline_with_all_params(self, async_client: AsyncKernel) -> None: + connection = await async_client.auth.connections.timeline( + id="id", + limit=100, + offset=0, + type="login", + ) + assert_matches_type(AsyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_timeline(self, async_client: AsyncKernel) -> None: + response = await async_client.auth.connections.with_raw_response.timeline( + id="id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + connection = await response.parse() + assert_matches_type(AsyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_timeline(self, async_client: AsyncKernel) -> None: + async with async_client.auth.connections.with_streaming_response.timeline( + id="id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + connection = await response.parse() + assert_matches_type(AsyncOffsetPagination[ManagedAuthTimelineEvent], connection, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_path_params_timeline(self, async_client: AsyncKernel) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): + await async_client.auth.connections.with_raw_response.timeline( + id="", + ) diff --git a/tests/api_resources/test_audit_logs.py b/tests/api_resources/test_audit_logs.py new file mode 100644 index 0000000..669a887 --- /dev/null +++ b/tests/api_resources/test_audit_logs.py @@ -0,0 +1,134 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from kernel import Kernel, AsyncKernel +from tests.utils import assert_matches_type +from kernel.types import AuditLogEntry +from kernel._utils import parse_datetime +from kernel.pagination import SyncPageTokenPagination, AsyncPageTokenPagination + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestAuditLogs: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list(self, client: Kernel) -> None: + audit_log = client.audit_logs.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + assert_matches_type(SyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_method_list_with_all_params(self, client: Kernel) -> None: + audit_log = client.audit_logs.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + auth_strategy="auth_strategy", + exclude_method="exclude_method", + limit=1, + method="method", + page_token="page_token", + search="search", + search_user_id=["string"], + service="service", + ) + assert_matches_type(SyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_raw_response_list(self, client: Kernel) -> None: + response = client.audit_logs.with_raw_response.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + audit_log = response.parse() + assert_matches_type(SyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + def test_streaming_response_list(self, client: Kernel) -> None: + with client.audit_logs.with_streaming_response.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + audit_log = response.parse() + assert_matches_type(SyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + assert cast(Any, response.is_closed) is True + + +class TestAsyncAuditLogs: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list(self, async_client: AsyncKernel) -> None: + audit_log = await async_client.audit_logs.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + assert_matches_type(AsyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncKernel) -> None: + audit_log = await async_client.audit_logs.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + auth_strategy="auth_strategy", + exclude_method="exclude_method", + limit=1, + method="method", + page_token="page_token", + search="search", + search_user_id=["string"], + service="service", + ) + assert_matches_type(AsyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_raw_response_list(self, async_client: AsyncKernel) -> None: + response = await async_client.audit_logs.with_raw_response.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + audit_log = await response.parse() + assert_matches_type(AsyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + @pytest.mark.skip(reason="Mock server tests are disabled") + @parametrize + async def test_streaming_response_list(self, async_client: AsyncKernel) -> None: + async with async_client.audit_logs.with_streaming_response.list( + end=parse_datetime("2026-01-02T00:00:00Z"), + start=parse_datetime("2026-01-01T00:00:00Z"), + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + audit_log = await response.parse() + assert_matches_type(AsyncPageTokenPagination[AuditLogEntry], audit_log, path=["response"]) + + assert cast(Any, response.is_closed) is True