From 846f24efdb2492e4ea0741167ebc7153af5d0d1e Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 2 Jul 2026 20:27:46 +0000
Subject: [PATCH 01/21] fix: exact-match identifier filter on resources
management list
---
.stats.yml | 4 ++--
.../resources/zones/resources.py | 24 +++++++++++++++----
.../types/zones/resource_list_params.py | 4 ++++
tests/api_resources/zones/test_resources.py | 2 ++
4 files changed, 28 insertions(+), 6 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index ffd5937..a0bced9 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-cdf14bee924cea2eaca3c1aaed26521cb91369d9462d22c21a0306b5786f4706.yml
-openapi_spec_hash: d8349acc1adec880977b6167a1e866bc
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-696e4495fd5227ecf639c524180a82f002db3409861d767742b66d4afde90f0f.yml
+openapi_spec_hash: afa19bf42339c70761f4092d283a95fb
config_hash: 7adc6b24545570dcc4a1bf0f714aa3e0
diff --git a/src/keycardai_api/resources/zones/resources.py b/src/keycardai_api/resources/zones/resources.py
index 2941cfe..5b19dad 100644
--- a/src/keycardai_api/resources/zones/resources.py
+++ b/src/keycardai_api/resources/zones/resources.py
@@ -271,6 +271,7 @@ def list(
before: str | Omit = omit,
credential_provider_id: str | Omit = omit,
expand: Union[Literal["total_count"], List[Literal["total_count"]]] | Omit = omit,
+ filter_identifier: Union[str, SequenceNotStr[str]] | Omit = omit,
identifier: str | Omit = omit,
limit: int | Omit = omit,
slug: str | Omit = omit,
@@ -281,8 +282,12 @@ def list(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ResourceListResponse:
- """
- Returns a list of resources in the specified zone
+ """Returns a list of resources in the specified zone.
+
+ Filter by exact identifier
+ via `filter[identifier]` (repeatable, OR'd). Matching is exact: identifiers are
+ unique per zone, so a filter returns at most one resource per value and never
+ performs URL prefix resolution.
Args:
after: Cursor for forward pagination
@@ -291,6 +296,8 @@ def list(
credential_provider_id: Filter resources by credential provider ID
+ filter_identifier: Filter by exact resource identifier
+
identifier: Filter resources by identifier
limit: Maximum number of items to return
@@ -318,6 +325,7 @@ def list(
"before": before,
"credential_provider_id": credential_provider_id,
"expand": expand,
+ "filter_identifier": filter_identifier,
"identifier": identifier,
"limit": limit,
"slug": slug,
@@ -606,6 +614,7 @@ async def list(
before: str | Omit = omit,
credential_provider_id: str | Omit = omit,
expand: Union[Literal["total_count"], List[Literal["total_count"]]] | Omit = omit,
+ filter_identifier: Union[str, SequenceNotStr[str]] | Omit = omit,
identifier: str | Omit = omit,
limit: int | Omit = omit,
slug: str | Omit = omit,
@@ -616,8 +625,12 @@ async def list(
extra_body: Body | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> ResourceListResponse:
- """
- Returns a list of resources in the specified zone
+ """Returns a list of resources in the specified zone.
+
+ Filter by exact identifier
+ via `filter[identifier]` (repeatable, OR'd). Matching is exact: identifiers are
+ unique per zone, so a filter returns at most one resource per value and never
+ performs URL prefix resolution.
Args:
after: Cursor for forward pagination
@@ -626,6 +639,8 @@ async def list(
credential_provider_id: Filter resources by credential provider ID
+ filter_identifier: Filter by exact resource identifier
+
identifier: Filter resources by identifier
limit: Maximum number of items to return
@@ -653,6 +668,7 @@ async def list(
"before": before,
"credential_provider_id": credential_provider_id,
"expand": expand,
+ "filter_identifier": filter_identifier,
"identifier": identifier,
"limit": limit,
"slug": slug,
diff --git a/src/keycardai_api/types/zones/resource_list_params.py b/src/keycardai_api/types/zones/resource_list_params.py
index 05e841a..04dacee 100644
--- a/src/keycardai_api/types/zones/resource_list_params.py
+++ b/src/keycardai_api/types/zones/resource_list_params.py
@@ -5,6 +5,7 @@
from typing import List, Union
from typing_extensions import Literal, Annotated, TypedDict
+from ..._types import SequenceNotStr
from ..._utils import PropertyInfo
__all__ = ["ResourceListParams"]
@@ -22,6 +23,9 @@ class ResourceListParams(TypedDict, total=False):
expand: Annotated[Union[Literal["total_count"], List[Literal["total_count"]]], PropertyInfo(alias="expand[]")]
+ filter_identifier: Annotated[Union[str, SequenceNotStr[str]], PropertyInfo(alias="filter[identifier]")]
+ """Filter by exact resource identifier"""
+
identifier: str
"""Filter resources by identifier"""
diff --git a/tests/api_resources/zones/test_resources.py b/tests/api_resources/zones/test_resources.py
index da273d4..4e820a7 100644
--- a/tests/api_resources/zones/test_resources.py
+++ b/tests/api_resources/zones/test_resources.py
@@ -234,6 +234,7 @@ def test_method_list_with_all_params(self, client: KeycardAPI) -> None:
before="x",
credential_provider_id="credentialProviderId",
expand="total_count",
+ filter_identifier="string",
identifier="identifier",
limit=1,
slug="slug",
@@ -546,6 +547,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncKeycardAPI)
before="x",
credential_provider_id="credentialProviderId",
expand="total_count",
+ filter_identifier="string",
identifier="identifier",
limit=1,
slug="slug",
From 0eb81628d06f0d66aee5bde1742d3e391125bc5d Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 2 Jul 2026 23:33:43 +0000
Subject: [PATCH 02/21] codegen metadata
---
.stats.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index a0bced9..78392e5 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-696e4495fd5227ecf639c524180a82f002db3409861d767742b66d4afde90f0f.yml
-openapi_spec_hash: afa19bf42339c70761f4092d283a95fb
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-ebe6508e5cb4cedf787250a62f76e51101a86baaed68ab5ad76037e89a66c77e.yml
+openapi_spec_hash: 29367391510c01665c9d4294c36d1b9a
config_hash: 7adc6b24545570dcc4a1bf0f714aa3e0
From 147af020e8e8c31678928137fb85386b54dd4ec6 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 3 Jul 2026 13:43:05 +0000
Subject: [PATCH 03/21] feat: filter users by identifier in management list
---
.stats.yml | 4 +-
src/keycardai_api/resources/zones/users.py | 66 ++++++++++---------
.../types/zones/user_list_params.py | 3 +
tests/api_resources/zones/test_users.py | 2 +
4 files changed, 43 insertions(+), 32 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 78392e5..e42cb14 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-ebe6508e5cb4cedf787250a62f76e51101a86baaed68ab5ad76037e89a66c77e.yml
-openapi_spec_hash: 29367391510c01665c9d4294c36d1b9a
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-2a41d90b97d650f0f035b04ed14ba664160dd36d49b6557f3e65f89ac8df26db.yml
+openapi_spec_hash: eeaf621033bd0038aa8456c1902f926c
config_hash: 7adc6b24545570dcc4a1bf0f714aa3e0
diff --git a/src/keycardai_api/resources/zones/users.py b/src/keycardai_api/resources/zones/users.py
index 81cd5a1..399df0f 100644
--- a/src/keycardai_api/resources/zones/users.py
+++ b/src/keycardai_api/resources/zones/users.py
@@ -94,6 +94,7 @@ def list(
| Omit = omit,
filter_email: Union[str, SequenceNotStr[str]] | Omit = omit,
filter_id: Union[str, SequenceNotStr[str]] | Omit = omit,
+ filter_identifier: Union[str, SequenceNotStr[str]] | Omit = omit,
limit: int | Omit = omit,
query: Union[str, SequenceNotStr[str]] | Omit = omit,
query_email: Union[str, SequenceNotStr[str]] | Omit = omit,
@@ -109,27 +110,26 @@ def list(
"""
Returns a list of users in the specified zone.
- **Rollout note:** the paginated/searchable/sortable behavior described below is
- gated behind the `user-pagination` feature flag and is currently disabled for
- most zones. While the flag is off, the response returns every user in the zone
- (capped at 100) in `items` and a fixed pagination envelope where `after_cursor`
- and `before_cursor` are `null` and `total_count` is `0`. The query parameters
- below are accepted but ignored. The flag is rolled out per-zone in Datadog and
- will become the default once Console adopts the paginated contract.
+ Note: cursor pagination, search, and sort are not yet enabled for all zones.
+ Where they are not enabled, the response returns all users in the zone (capped
+ at 100) in `items`, with `after_cursor` and `before_cursor` set to `null` and
+ `total_count` of `0`; `filter[email]` and `filter[identifier]` are still
+ applied, while the pagination, search, and sort parameters below are accepted
+ but ignored.
Use cursor pagination via `after`/`before`. Sort: comma-separated field list;
prefix with `-` for descending. Use `expand[]=total_count` to include the
matching row count, `expand[]=session_count` to include per-user session counts,
`expand[]=grant_count` to include per-user delegated-grant counts, and
`expand[]=role-assignments` to include each user's structured role grants.
- Filter by exact email via `filter[email]`; search via `query[email]` /
- `query[subject]` / `query[]` (substring match, OR'd across repeated values).
- `query[]` matches against email and federation credential subject. Pass
- `filter[id]` (repeatable, max 100) to restrict results to a known set of users —
- mutually exclusive with `after`/`before` (returns 400 if combined). When
- `filter[id]` is set, `limit` is ignored and the response contains every
- requested user that exists in the zone, in a single page. IDs not in the zone
- are silently omitted.
+ Filter by exact email via `filter[email]` and by exact identifier via
+ `filter[identifier]`; search via `query[email]` / `query[subject]` / `query[]`
+ (substring match, OR'd across repeated values). `query[]` matches against email
+ and federation credential subject. Pass `filter[id]` (repeatable, max 100) to
+ restrict results to a known set of users — mutually exclusive with
+ `after`/`before` (returns 400 if combined). When `filter[id]` is set, `limit` is
+ ignored and the response contains every requested user that exists in the zone,
+ in a single page. IDs not in the zone are silently omitted.
Args:
after: Cursor for forward pagination
@@ -141,6 +141,8 @@ def list(
filter_id: Restrict results to users with this publicId. Repeatable, max 100. Mutually
exclusive with after/before.
+ filter_identifier: Filter by exact user identifier
+
limit: Maximum number of items to return
query: Search across email and credential subject (substring match)
@@ -176,6 +178,7 @@ def list(
"expand": expand,
"filter_email": filter_email,
"filter_id": filter_id,
+ "filter_identifier": filter_identifier,
"limit": limit,
"query": query,
"query_email": query_email,
@@ -258,6 +261,7 @@ async def list(
| Omit = omit,
filter_email: Union[str, SequenceNotStr[str]] | Omit = omit,
filter_id: Union[str, SequenceNotStr[str]] | Omit = omit,
+ filter_identifier: Union[str, SequenceNotStr[str]] | Omit = omit,
limit: int | Omit = omit,
query: Union[str, SequenceNotStr[str]] | Omit = omit,
query_email: Union[str, SequenceNotStr[str]] | Omit = omit,
@@ -273,27 +277,26 @@ async def list(
"""
Returns a list of users in the specified zone.
- **Rollout note:** the paginated/searchable/sortable behavior described below is
- gated behind the `user-pagination` feature flag and is currently disabled for
- most zones. While the flag is off, the response returns every user in the zone
- (capped at 100) in `items` and a fixed pagination envelope where `after_cursor`
- and `before_cursor` are `null` and `total_count` is `0`. The query parameters
- below are accepted but ignored. The flag is rolled out per-zone in Datadog and
- will become the default once Console adopts the paginated contract.
+ Note: cursor pagination, search, and sort are not yet enabled for all zones.
+ Where they are not enabled, the response returns all users in the zone (capped
+ at 100) in `items`, with `after_cursor` and `before_cursor` set to `null` and
+ `total_count` of `0`; `filter[email]` and `filter[identifier]` are still
+ applied, while the pagination, search, and sort parameters below are accepted
+ but ignored.
Use cursor pagination via `after`/`before`. Sort: comma-separated field list;
prefix with `-` for descending. Use `expand[]=total_count` to include the
matching row count, `expand[]=session_count` to include per-user session counts,
`expand[]=grant_count` to include per-user delegated-grant counts, and
`expand[]=role-assignments` to include each user's structured role grants.
- Filter by exact email via `filter[email]`; search via `query[email]` /
- `query[subject]` / `query[]` (substring match, OR'd across repeated values).
- `query[]` matches against email and federation credential subject. Pass
- `filter[id]` (repeatable, max 100) to restrict results to a known set of users —
- mutually exclusive with `after`/`before` (returns 400 if combined). When
- `filter[id]` is set, `limit` is ignored and the response contains every
- requested user that exists in the zone, in a single page. IDs not in the zone
- are silently omitted.
+ Filter by exact email via `filter[email]` and by exact identifier via
+ `filter[identifier]`; search via `query[email]` / `query[subject]` / `query[]`
+ (substring match, OR'd across repeated values). `query[]` matches against email
+ and federation credential subject. Pass `filter[id]` (repeatable, max 100) to
+ restrict results to a known set of users — mutually exclusive with
+ `after`/`before` (returns 400 if combined). When `filter[id]` is set, `limit` is
+ ignored and the response contains every requested user that exists in the zone,
+ in a single page. IDs not in the zone are silently omitted.
Args:
after: Cursor for forward pagination
@@ -305,6 +308,8 @@ async def list(
filter_id: Restrict results to users with this publicId. Repeatable, max 100. Mutually
exclusive with after/before.
+ filter_identifier: Filter by exact user identifier
+
limit: Maximum number of items to return
query: Search across email and credential subject (substring match)
@@ -340,6 +345,7 @@ async def list(
"expand": expand,
"filter_email": filter_email,
"filter_id": filter_id,
+ "filter_identifier": filter_identifier,
"limit": limit,
"query": query,
"query_email": query_email,
diff --git a/src/keycardai_api/types/zones/user_list_params.py b/src/keycardai_api/types/zones/user_list_params.py
index dfcc914..b95f1d2 100644
--- a/src/keycardai_api/types/zones/user_list_params.py
+++ b/src/keycardai_api/types/zones/user_list_params.py
@@ -35,6 +35,9 @@ class UserListParams(TypedDict, total=False):
Repeatable, max 100. Mutually exclusive with after/before.
"""
+ filter_identifier: Annotated[Union[str, SequenceNotStr[str]], PropertyInfo(alias="filter[identifier]")]
+ """Filter by exact user identifier"""
+
limit: int
"""Maximum number of items to return"""
diff --git a/tests/api_resources/zones/test_users.py b/tests/api_resources/zones/test_users.py
index 1a256ef..ffc908a 100644
--- a/tests/api_resources/zones/test_users.py
+++ b/tests/api_resources/zones/test_users.py
@@ -87,6 +87,7 @@ def test_method_list_with_all_params(self, client: KeycardAPI) -> None:
expand="total_count",
filter_email="dev@stainless.com",
filter_id="string",
+ filter_identifier="string",
limit=1,
query="x",
query_email="x",
@@ -205,6 +206,7 @@ async def test_method_list_with_all_params(self, async_client: AsyncKeycardAPI)
expand="total_count",
filter_email="dev@stainless.com",
filter_id="string",
+ filter_identifier="string",
limit=1,
query="x",
query_email="x",
From 526b22958acc91a11569ba3f8453a004eb03f6f5 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 3 Jul 2026 18:52:31 +0000
Subject: [PATCH 04/21] feat(ID-365): support additional SSO provider
configuration options
---
.stats.yml | 4 +-
.../resources/organizations/sso_connection.py | 10 ++-
.../organizations/sso_connection_protocol.py | 21 ++++-
.../sso_connection_protocol_param.py | 21 ++++-
.../sso_connection_update_params.py | 89 +++++++++++++++++--
.../organizations/test_sso_connection.py | 28 +++++-
6 files changed, 156 insertions(+), 17 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index e42cb14..3aa8d03 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-2a41d90b97d650f0f035b04ed14ba664160dd36d49b6557f3e65f89ac8df26db.yml
-openapi_spec_hash: eeaf621033bd0038aa8456c1902f926c
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-3958577bf4b6967d84a9f7b9efe371c8bb9e8b62b4d142b2cefb79465da9b177.yml
+openapi_spec_hash: 4524ccd252fece1935897d9096160fbc
config_hash: 7adc6b24545570dcc4a1bf0f714aa3e0
diff --git a/src/keycardai_api/resources/organizations/sso_connection.py b/src/keycardai_api/resources/organizations/sso_connection.py
index 4a8a44f..002dbed 100644
--- a/src/keycardai_api/resources/organizations/sso_connection.py
+++ b/src/keycardai_api/resources/organizations/sso_connection.py
@@ -104,7 +104,7 @@ def update(
client_id: str | Omit = omit,
client_secret: str | Omit = omit,
identifier: str | Omit = omit,
- protocols: Optional[SSOConnectionProtocolParam] | Omit = omit,
+ protocols: Optional[sso_connection_update_params.Protocols] | Omit = omit,
x_client_request_id: 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.
@@ -125,7 +125,8 @@ def update(
identifier: SSO provider identifier (e.g., issuer URL)
- protocols: Protocol configuration for SSO connection
+ protocols: Protocol configuration for an SSO connection update. Omit a protocol to leave it
+ unchanged.
extra_headers: Send extra headers
@@ -329,7 +330,7 @@ async def update(
client_id: str | Omit = omit,
client_secret: str | Omit = omit,
identifier: str | Omit = omit,
- protocols: Optional[SSOConnectionProtocolParam] | Omit = omit,
+ protocols: Optional[sso_connection_update_params.Protocols] | Omit = omit,
x_client_request_id: 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.
@@ -350,7 +351,8 @@ async def update(
identifier: SSO provider identifier (e.g., issuer URL)
- protocols: Protocol configuration for SSO connection
+ protocols: Protocol configuration for an SSO connection update. Omit a protocol to leave it
+ unchanged.
extra_headers: Send extra headers
diff --git a/src/keycardai_api/types/organizations/sso_connection_protocol.py b/src/keycardai_api/types/organizations/sso_connection_protocol.py
index 5767f80..42ae9b7 100644
--- a/src/keycardai_api/types/organizations/sso_connection_protocol.py
+++ b/src/keycardai_api/types/organizations/sso_connection_protocol.py
@@ -1,6 +1,6 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-from typing import List, Optional
+from typing import Dict, List, Optional
from ..._models import BaseModel
@@ -13,6 +13,13 @@ class Oauth2(BaseModel):
authorization_endpoint: Optional[str] = None
"""OAuth 2.0 authorization endpoint"""
+ authorization_parameters: Optional[Dict[str, str]] = None
+ """Custom query parameters appended to authorization redirect URLs.
+
+ Use for non-standard providers (e.g. Google prompt=consent,
+ access_type=offline).
+ """
+
code_challenge_methods_supported: Optional[List[str]] = None
"""Supported PKCE code challenge methods"""
@@ -32,6 +39,18 @@ class Oauth2(BaseModel):
class Openid(BaseModel):
"""OpenID Connect protocol configuration for SSO connection"""
+ scopes: Optional[List[str]] = None
+ """Additional OIDC scopes to request from this provider during authentication (e.g.
+
+ "groups"). Merged with the default scopes (openid, profile, email).
+ """
+
+ user_identifier_claim: Optional[str] = None
+ """
+ Name of a top-level string claim in the provider's ID Token to use as the user
+ identifier on user creation. When not set, the user's Keycard ID is used.
+ """
+
userinfo_endpoint: Optional[str] = None
"""OpenID Connect UserInfo endpoint"""
diff --git a/src/keycardai_api/types/organizations/sso_connection_protocol_param.py b/src/keycardai_api/types/organizations/sso_connection_protocol_param.py
index 84d6074..2183279 100644
--- a/src/keycardai_api/types/organizations/sso_connection_protocol_param.py
+++ b/src/keycardai_api/types/organizations/sso_connection_protocol_param.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Optional
+from typing import Dict, Optional
from typing_extensions import TypedDict
from ..._types import SequenceNotStr
@@ -16,6 +16,13 @@ class Oauth2(TypedDict, total=False):
authorization_endpoint: Optional[str]
"""OAuth 2.0 authorization endpoint"""
+ authorization_parameters: Optional[Dict[str, str]]
+ """Custom query parameters appended to authorization redirect URLs.
+
+ Use for non-standard providers (e.g. Google prompt=consent,
+ access_type=offline).
+ """
+
code_challenge_methods_supported: Optional[SequenceNotStr[str]]
"""Supported PKCE code challenge methods"""
@@ -35,6 +42,18 @@ class Oauth2(TypedDict, total=False):
class Openid(TypedDict, total=False):
"""OpenID Connect protocol configuration for SSO connection"""
+ scopes: Optional[SequenceNotStr[str]]
+ """Additional OIDC scopes to request from this provider during authentication (e.g.
+
+ "groups"). Merged with the default scopes (openid, profile, email).
+ """
+
+ user_identifier_claim: Optional[str]
+ """
+ Name of a top-level string claim in the provider's ID Token to use as the user
+ identifier on user creation. When not set, the user's Keycard ID is used.
+ """
+
userinfo_endpoint: Optional[str]
"""OpenID Connect UserInfo endpoint"""
diff --git a/src/keycardai_api/types/organizations/sso_connection_update_params.py b/src/keycardai_api/types/organizations/sso_connection_update_params.py
index e8d852e..93c8454 100644
--- a/src/keycardai_api/types/organizations/sso_connection_update_params.py
+++ b/src/keycardai_api/types/organizations/sso_connection_update_params.py
@@ -2,13 +2,13 @@
from __future__ import annotations
-from typing import Optional
+from typing import Dict, Optional
from typing_extensions import Annotated, TypedDict
+from ..._types import SequenceNotStr
from ..._utils import PropertyInfo
-from .sso_connection_protocol_param import SSOConnectionProtocolParam
-__all__ = ["SSOConnectionUpdateParams"]
+__all__ = ["SSOConnectionUpdateParams", "Protocols", "ProtocolsOauth2", "ProtocolsOpenid"]
class SSOConnectionUpdateParams(TypedDict, total=False):
@@ -21,7 +21,86 @@ class SSOConnectionUpdateParams(TypedDict, total=False):
identifier: str
"""SSO provider identifier (e.g., issuer URL)"""
- protocols: Optional[SSOConnectionProtocolParam]
- """Protocol configuration for SSO connection"""
+ protocols: Optional[Protocols]
+ """Protocol configuration for an SSO connection update.
+
+ Omit a protocol to leave it unchanged.
+ """
x_client_request_id: Annotated[str, PropertyInfo(alias="X-Client-Request-ID")]
+
+
+class ProtocolsOauth2(TypedDict, total=False):
+ """OAuth 2.0 protocol configuration for an SSO connection update.
+
+ Each field is tri-state, omit to leave unchanged, send null to clear, send a value to set.
+ """
+
+ authorization_endpoint: Optional[str]
+ """OAuth 2.0 authorization endpoint. Set to null to clear."""
+
+ authorization_parameters: Optional[Dict[str, str]]
+ """Custom query parameters appended to authorization redirect URLs.
+
+ Use for non-standard providers (e.g. Google prompt=consent,
+ access_type=offline). Set to null to clear.
+ """
+
+ code_challenge_methods_supported: Optional[SequenceNotStr[str]]
+ """Supported PKCE code challenge methods. Set to null to clear."""
+
+ jwks_uri: Optional[str]
+ """JSON Web Key Set endpoint. Set to null to clear."""
+
+ registration_endpoint: Optional[str]
+ """OAuth 2.0 registration endpoint. Set to null to clear."""
+
+ scopes_supported: Optional[SequenceNotStr[str]]
+ """Supported OAuth 2.0 scopes. Set to null to clear."""
+
+ token_endpoint: Optional[str]
+ """OAuth 2.0 token endpoint. Set to null to clear."""
+
+
+class ProtocolsOpenid(TypedDict, total=False):
+ """OpenID Connect protocol configuration for an SSO connection update.
+
+ Each field is tri-state, omit to leave unchanged, send null to clear, send a value to set.
+ """
+
+ scopes: Optional[SequenceNotStr[str]]
+ """Additional OIDC scopes to request from this provider during authentication (e.g.
+
+ "groups"). Merged with the default scopes (openid, profile, email). Set to null
+ to clear.
+ """
+
+ user_identifier_claim: Optional[str]
+ """
+ Name of a top-level string claim in the provider's ID Token to use as the user
+ identifier on user creation. Set to null to clear.
+ """
+
+ userinfo_endpoint: Optional[str]
+ """OpenID Connect UserInfo endpoint. Set to null to clear."""
+
+
+class Protocols(TypedDict, total=False):
+ """Protocol configuration for an SSO connection update.
+
+ Omit a protocol to leave it unchanged.
+ """
+
+ oauth2: Optional[ProtocolsOauth2]
+ """OAuth 2.0 protocol configuration for an SSO connection update.
+
+ Each field is tri-state, omit to leave unchanged, send null to clear, send a
+ value to set.
+ """
+
+ openid: Optional[ProtocolsOpenid]
+ """OpenID Connect protocol configuration for an SSO connection update.
+
+ Each field is tri-state, omit to leave unchanged, send null to clear, send a
+ value to set.
+ """
diff --git a/tests/api_resources/organizations/test_sso_connection.py b/tests/api_resources/organizations/test_sso_connection.py
index 8be8143..dee8785 100644
--- a/tests/api_resources/organizations/test_sso_connection.py
+++ b/tests/api_resources/organizations/test_sso_connection.py
@@ -90,13 +90,18 @@ def test_method_update_with_all_params(self, client: KeycardAPI) -> None:
protocols={
"oauth2": {
"authorization_endpoint": "https://example.com",
+ "authorization_parameters": {"foo": "string"},
"code_challenge_methods_supported": ["string"],
"jwks_uri": "https://example.com",
"registration_endpoint": "https://example.com",
"scopes_supported": ["string"],
"token_endpoint": "https://example.com",
},
- "openid": {"userinfo_endpoint": "https://example.com"},
+ "openid": {
+ "scopes": ["string"],
+ "user_identifier_claim": "user_identifier_claim",
+ "userinfo_endpoint": "https://example.com",
+ },
},
x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
)
@@ -208,13 +213,18 @@ def test_method_enable_with_all_params(self, client: KeycardAPI) -> None:
protocols={
"oauth2": {
"authorization_endpoint": "https://example.com",
+ "authorization_parameters": {"foo": "string"},
"code_challenge_methods_supported": ["string"],
"jwks_uri": "https://example.com",
"registration_endpoint": "https://example.com",
"scopes_supported": ["string"],
"token_endpoint": "https://example.com",
},
- "openid": {"userinfo_endpoint": "https://example.com"},
+ "openid": {
+ "scopes": ["string"],
+ "user_identifier_claim": "user_identifier_claim",
+ "userinfo_endpoint": "https://example.com",
+ },
},
x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
)
@@ -337,13 +347,18 @@ async def test_method_update_with_all_params(self, async_client: AsyncKeycardAPI
protocols={
"oauth2": {
"authorization_endpoint": "https://example.com",
+ "authorization_parameters": {"foo": "string"},
"code_challenge_methods_supported": ["string"],
"jwks_uri": "https://example.com",
"registration_endpoint": "https://example.com",
"scopes_supported": ["string"],
"token_endpoint": "https://example.com",
},
- "openid": {"userinfo_endpoint": "https://example.com"},
+ "openid": {
+ "scopes": ["string"],
+ "user_identifier_claim": "user_identifier_claim",
+ "userinfo_endpoint": "https://example.com",
+ },
},
x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
)
@@ -455,13 +470,18 @@ async def test_method_enable_with_all_params(self, async_client: AsyncKeycardAPI
protocols={
"oauth2": {
"authorization_endpoint": "https://example.com",
+ "authorization_parameters": {"foo": "string"},
"code_challenge_methods_supported": ["string"],
"jwks_uri": "https://example.com",
"registration_endpoint": "https://example.com",
"scopes_supported": ["string"],
"token_endpoint": "https://example.com",
},
- "openid": {"userinfo_endpoint": "https://example.com"},
+ "openid": {
+ "scopes": ["string"],
+ "user_identifier_claim": "user_identifier_claim",
+ "userinfo_endpoint": "https://example.com",
+ },
},
x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
)
From 6d27d213ac66fb9f1890409a380924c3edb44b99 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 6 Jul 2026 17:34:21 +0000
Subject: [PATCH 05/21] fix(ACC-613): preserve source order of policies in
draft/convert cedar_json
---
.stats.yml | 4 ++--
src/keycardai_api/types/zones/policies/policy_version.py | 4 +++-
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 3aa8d03..80060ec 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-3958577bf4b6967d84a9f7b9efe371c8bb9e8b62b4d142b2cefb79465da9b177.yml
-openapi_spec_hash: 4524ccd252fece1935897d9096160fbc
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-9a353824f6b68547b91d52205e052e4a5428ae35f37a10fb898aa8a38458efeb.yml
+openapi_spec_hash: 846e320252df771be0cb6c47e876bcf1
config_hash: 7adc6b24545570dcc4a1bf0f714aa3e0
diff --git a/src/keycardai_api/types/zones/policies/policy_version.py b/src/keycardai_api/types/zones/policies/policy_version.py
index f9982be..d8683af 100644
--- a/src/keycardai_api/types/zones/policies/policy_version.py
+++ b/src/keycardai_api/types/zones/policies/policy_version.py
@@ -43,7 +43,9 @@ class PolicyVersion(BaseModel):
"""Cedar policy in JSON representation.
Populated by default and when `format=json` is passed; null when `format=cedar`
- narrows the response to the text representation only.
+ narrows the response to the text representation only. Serialized verbatim from
+ the stored Cedar so the order of `staticPolicies` matches the source policy
+ order (ACC-613).
"""
cedar_raw: Optional[str] = None
From fea05f5033a9cb6e06026cbe66015fb717b46915 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 13 Jul 2026 17:35:46 +0000
Subject: [PATCH 06/21] codegen metadata
---
.stats.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 80060ec..972d05a 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-9a353824f6b68547b91d52205e052e4a5428ae35f37a10fb898aa8a38458efeb.yml
-openapi_spec_hash: 846e320252df771be0cb6c47e876bcf1
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-1a5dce38c35deb19561fd779b8b9d4d790eda43c705e0e6fa2465dcb2bbef856.yml
+openapi_spec_hash: cc068ed9e0550f854e310e9f1d4862f7
config_hash: 7adc6b24545570dcc4a1bf0f714aa3e0
From 15cb317c1d1ea8462b30bff1411e52a554df0fb0 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 13 Jul 2026 18:38:35 +0000
Subject: [PATCH 07/21] fix(internal): resolve build failures
---
scripts/lint | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/lint b/scripts/lint
index 97303ed..7b9389c 100755
--- a/scripts/lint
+++ b/scripts/lint
@@ -13,7 +13,7 @@ else
fi
echo "==> Running pyright"
-uv run pyright
+uv run pyright -p .
echo "==> Running mypy"
uv run mypy .
From 79865c371d5231fbad641a8e14c10a5d5ecd7ace Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 14 Jul 2026 00:00:52 +0000
Subject: [PATCH 08/21] codegen metadata
---
.stats.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 972d05a..a5f73c4 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-1a5dce38c35deb19561fd779b8b9d4d790eda43c705e0e6fa2465dcb2bbef856.yml
-openapi_spec_hash: cc068ed9e0550f854e310e9f1d4862f7
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-e207c5fd62b63a334482baecbb26a29e3321883d04e60f5a1d3cef0dc30c2b84.yml
+openapi_spec_hash: 17cc8c1e295127243a94a616dc3fa64d
config_hash: 7adc6b24545570dcc4a1bf0f714aa3e0
From 8a01934b6ceb8636db998f0498d86ccb810cf639 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 14 Jul 2026 17:46:49 +0000
Subject: [PATCH 09/21] fix(ci): resolve Stainless error diagnostics and
enforce fail_on: error
---
.stats.yml | 4 +-
README.md | 17 ++-
api.md | 40 -------
src/keycardai_api/_client.py | 30 +++++
src/keycardai_api/_models.py | 6 +-
src/keycardai_api/types/zones/__init__.py | 3 -
.../types/zones/install_status.py | 7 --
.../types/zones/packages/__init__.py | 3 -
.../types/zones/task_operation.py | 7 --
src/keycardai_api/types/zones/task_status.py | 7 --
tests/conftest.py | 8 +-
tests/test_client.py | 103 ++++++++++++++++--
12 files changed, 152 insertions(+), 83 deletions(-)
delete mode 100644 src/keycardai_api/types/zones/install_status.py
delete mode 100644 src/keycardai_api/types/zones/packages/__init__.py
delete mode 100644 src/keycardai_api/types/zones/task_operation.py
delete mode 100644 src/keycardai_api/types/zones/task_status.py
diff --git a/.stats.yml b/.stats.yml
index a5f73c4..fdb32d1 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-e207c5fd62b63a334482baecbb26a29e3321883d04e60f5a1d3cef0dc30c2b84.yml
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-86ecdf280dafe23581fcd39dc510c8be1034d346b6cf45f92aa4fd86d286d7a7.yml
openapi_spec_hash: 17cc8c1e295127243a94a616dc3fa64d
-config_hash: 7adc6b24545570dcc4a1bf0f714aa3e0
+config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
diff --git a/README.md b/README.md
index cf222f1..e364722 100644
--- a/README.md
+++ b/README.md
@@ -25,23 +25,34 @@ pip install keycardai_api
The full API of this library can be found in [api.md](api.md).
```python
+import os
from keycardai_api import KeycardAPI
-client = KeycardAPI()
+client = KeycardAPI(
+ api_key=os.environ.get("KEYCARD_API_API_KEY"), # This is the default and can be omitted
+)
zones = client.zones.list()
print(zones.items)
```
+While you can provide an `api_key` keyword argument,
+we recommend using [python-dotenv](https://pypi.org/project/python-dotenv/)
+to add `KEYCARD_API_API_KEY="My API Key"` to your `.env` file
+so that your API Key is not stored in source control.
+
## Async usage
Simply import `AsyncKeycardAPI` instead of `KeycardAPI` and use `await` with each API call:
```python
+import os
import asyncio
from keycardai_api import AsyncKeycardAPI
-client = AsyncKeycardAPI()
+client = AsyncKeycardAPI(
+ api_key=os.environ.get("KEYCARD_API_API_KEY"), # This is the default and can be omitted
+)
async def main() -> None:
@@ -68,6 +79,7 @@ pip install keycardai_api[aiohttp]
Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`:
```python
+import os
import asyncio
from keycardai_api import DefaultAioHttpClient
from keycardai_api import AsyncKeycardAPI
@@ -75,6 +87,7 @@ from keycardai_api import AsyncKeycardAPI
async def main() -> None:
async with AsyncKeycardAPI(
+ api_key=os.environ.get("KEYCARD_API_API_KEY"), # This is the default and can be omitted
http_client=DefaultAioHttpClient(),
) as client:
zones = await client.zones.list()
diff --git a/api.md b/api.md
index 48d50a6..de50220 100644
--- a/api.md
+++ b/api.md
@@ -261,46 +261,6 @@ Methods:
- client.zones.policies.versions.list(policy_id, \*, zone_id, \*\*params) -> VersionListResponse
- client.zones.policies.versions.archive(version_id, \*, zone_id, policy_id) -> PolicyVersion
-## Packages
-
-Types:
-
-```python
-from keycardai_api.types.zones import (
- InputState,
- Package,
- PackageDraft,
- PackageInputBinding,
- PackageList,
- PackageOutputBinding,
- PackageSource,
-)
-```
-
-### Versions
-
-Types:
-
-```python
-from keycardai_api.types.zones.packages import PackageVersion, PackageVersionList
-```
-
-## Installs
-
-Types:
-
-```python
-from keycardai_api.types.zones import Install, InstallList, InstallStatus
-```
-
-## CatalogTasks
-
-Types:
-
-```python
-from keycardai_api.types.zones import Task, TaskOperation, TaskStatus
-```
-
## PolicySets
Types:
diff --git a/src/keycardai_api/_client.py b/src/keycardai_api/_client.py
index 103db47..4aa915e 100644
--- a/src/keycardai_api/_client.py
+++ b/src/keycardai_api/_client.py
@@ -160,12 +160,27 @@ def with_streaming_response(self) -> KeycardAPIWithStreamedResponse:
def qs(self) -> Querystring:
return Querystring(array_format="repeat")
+ @override
+ def _auth_headers(self, security: SecurityOptions) -> dict[str, str]:
+ headers: dict[str, str] = {}
+ if security.get("bearer_auth", False):
+ for key, value in self._bearer_auth.items():
+ headers.setdefault(key, value)
+ return headers
+
@override
def _custom_auth(self, security: SecurityOptions) -> httpx.Auth | None:
if security.get("o_auth2", False) and self._o_auth2 is not None:
return self._o_auth2
return None
+ @property
+ def _bearer_auth(self) -> dict[str, str]:
+ api_key = self.api_key
+ if api_key is None:
+ return {}
+ return {"Authorization": f"Bearer {api_key}"}
+
@property
def _o_auth2(self) -> httpx.Auth | None:
if self.client_id and self.client_secret:
@@ -390,12 +405,27 @@ def with_streaming_response(self) -> AsyncKeycardAPIWithStreamedResponse:
def qs(self) -> Querystring:
return Querystring(array_format="repeat")
+ @override
+ def _auth_headers(self, security: SecurityOptions) -> dict[str, str]:
+ headers: dict[str, str] = {}
+ if security.get("bearer_auth", False):
+ for key, value in self._bearer_auth.items():
+ headers.setdefault(key, value)
+ return headers
+
@override
def _custom_auth(self, security: SecurityOptions) -> httpx.Auth | None:
if security.get("o_auth2", False) and self._o_auth2 is not None:
return self._o_auth2
return None
+ @property
+ def _bearer_auth(self) -> dict[str, str]:
+ api_key = self.api_key
+ if api_key is None:
+ return {}
+ return {"Authorization": f"Bearer {api_key}"}
+
@property
def _o_auth2(self) -> httpx.Auth | None:
if self.client_id and self.client_secret:
diff --git a/src/keycardai_api/_models.py b/src/keycardai_api/_models.py
index 13d0794..90bf844 100644
--- a/src/keycardai_api/_models.py
+++ b/src/keycardai_api/_models.py
@@ -872,6 +872,7 @@ def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]:
class SecurityOptions(TypedDict, total=False):
+ bearer_auth: bool
o_auth2: bool
@@ -903,7 +904,10 @@ class FinalRequestOptions(pydantic.BaseModel):
idempotency_key: Union[str, None] = None
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
follow_redirects: Union[bool, None] = None
- security: SecurityOptions = {"o_auth2": True}
+ security: SecurityOptions = {
+ "bearer_auth": True,
+ "o_auth2": True,
+ }
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None
# It should be noted that we cannot use `json` here as that would override
diff --git a/src/keycardai_api/types/zones/__init__.py b/src/keycardai_api/types/zones/__init__.py
index 03942fa..283dad0 100644
--- a/src/keycardai_api/types/zones/__init__.py
+++ b/src/keycardai_api/types/zones/__init__.py
@@ -20,12 +20,9 @@
from .user_agent import UserAgent as UserAgent
from .application import Application as Application
from .base_fields import BaseFields as BaseFields
-from .task_status import TaskStatus as TaskStatus
from .zone_member import ZoneMember as ZoneMember
-from .install_status import InstallStatus as InstallStatus
from .metadata_param import MetadataParam as MetadataParam
from .schema_version import SchemaVersion as SchemaVersion
-from .task_operation import TaskOperation as TaskOperation
from .user_list_params import UserListParams as UserListParams
from .application_trait import ApplicationTrait as ApplicationTrait
from .member_add_params import MemberAddParams as MemberAddParams
diff --git a/src/keycardai_api/types/zones/install_status.py b/src/keycardai_api/types/zones/install_status.py
deleted file mode 100644
index 835b898..0000000
--- a/src/keycardai_api/types/zones/install_status.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-
-from typing_extensions import Literal, TypeAlias
-
-__all__ = ["InstallStatus"]
-
-InstallStatus: TypeAlias = Literal["pending", "active", "deleting", "failed", "deleted"]
diff --git a/src/keycardai_api/types/zones/packages/__init__.py b/src/keycardai_api/types/zones/packages/__init__.py
deleted file mode 100644
index f8ee8b1..0000000
--- a/src/keycardai_api/types/zones/packages/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-
-from __future__ import annotations
diff --git a/src/keycardai_api/types/zones/task_operation.py b/src/keycardai_api/types/zones/task_operation.py
deleted file mode 100644
index d7278a8..0000000
--- a/src/keycardai_api/types/zones/task_operation.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-
-from typing_extensions import Literal, TypeAlias
-
-__all__ = ["TaskOperation"]
-
-TaskOperation: TypeAlias = Literal["create", "delete"]
diff --git a/src/keycardai_api/types/zones/task_status.py b/src/keycardai_api/types/zones/task_status.py
deleted file mode 100644
index de09b18..0000000
--- a/src/keycardai_api/types/zones/task_status.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-
-from typing_extensions import Literal, TypeAlias
-
-__all__ = ["TaskStatus"]
-
-TaskStatus: TypeAlias = Literal["pending", "running", "completed", "failed"]
diff --git a/tests/conftest.py b/tests/conftest.py
index 850730c..e57ccc4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -46,6 +46,7 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None:
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
+api_key = "My API Key"
client_id = "My Client ID"
client_secret = "My Client Secret"
@@ -57,7 +58,11 @@ def client(request: FixtureRequest) -> Iterator[KeycardAPI]:
raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}")
with KeycardAPI(
- base_url=base_url, client_id=client_id, client_secret=client_secret, _strict_response_validation=strict
+ base_url=base_url,
+ api_key=api_key,
+ client_id=client_id,
+ client_secret=client_secret,
+ _strict_response_validation=strict,
) as client:
yield client
@@ -84,6 +89,7 @@ async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncKeycardAPI
async with AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=strict,
diff --git a/tests/test_client.py b/tests/test_client.py
index a174064..a570787 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -39,6 +39,7 @@
T = TypeVar("T")
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
+api_key = "My API Key"
client_id = "My Client ID"
client_secret = "My Client Secret"
@@ -137,6 +138,10 @@ def test_copy(self, client: KeycardAPI) -> None:
copied = client.copy()
assert id(copied) != id(client)
+ copied = client.copy(api_key="another My API Key")
+ assert copied.api_key == "another My API Key"
+ assert client.api_key == "My API Key"
+
copied = client.copy(client_id="another My Client ID")
assert copied.client_id == "another My Client ID"
assert client.client_id == "My Client ID"
@@ -164,6 +169,7 @@ def test_copy_default_options(self, client: KeycardAPI) -> None:
def test_copy_default_headers(self) -> None:
client = KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -203,6 +209,7 @@ def test_copy_default_headers(self) -> None:
def test_copy_default_query(self) -> None:
client = KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -333,6 +340,7 @@ def test_request_timeout(self, client: KeycardAPI) -> None:
def test_client_timeout_option(self) -> None:
client = KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -350,6 +358,7 @@ def test_http_client_timeout_option(self) -> None:
with httpx.Client(timeout=None) as http_client:
client = KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -366,6 +375,7 @@ def test_http_client_timeout_option(self) -> None:
with httpx.Client() as http_client:
client = KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -382,6 +392,7 @@ def test_http_client_timeout_option(self) -> None:
with httpx.Client(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
client = KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -399,6 +410,7 @@ async def test_invalid_http_client(self) -> None:
async with httpx.AsyncClient() as http_client:
KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -408,6 +420,7 @@ async def test_invalid_http_client(self) -> None:
def test_default_headers_option(self) -> None:
test_client = KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -419,6 +432,7 @@ def test_default_headers_option(self) -> None:
test_client2 = KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -437,6 +451,7 @@ def test_default_headers_option(self) -> None:
def test_default_query_option(self) -> None:
client = KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -636,6 +651,7 @@ def mock_handler(request: httpx.Request) -> httpx.Response:
with KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -733,6 +749,7 @@ class Model(BaseModel):
def test_base_url_setter(self) -> None:
client = KeycardAPI(
base_url="https://example.com/from_init",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -747,7 +764,9 @@ def test_base_url_setter(self) -> None:
def test_base_url_env(self) -> None:
with update_env(KEYCARD_API_BASE_URL="http://localhost:5000/from/env"):
- client = KeycardAPI(client_id=client_id, client_secret=client_secret, _strict_response_validation=True)
+ client = KeycardAPI(
+ api_key=api_key, client_id=client_id, client_secret=client_secret, _strict_response_validation=True
+ )
assert client.base_url == "http://localhost:5000/from/env/"
@pytest.mark.parametrize(
@@ -755,12 +774,14 @@ def test_base_url_env(self) -> None:
[
KeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
),
KeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -785,12 +806,14 @@ def test_base_url_trailing_slash(self, client: KeycardAPI) -> None:
[
KeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
),
KeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -815,12 +838,14 @@ def test_base_url_no_trailing_slash(self, client: KeycardAPI) -> None:
[
KeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
),
KeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -842,7 +867,11 @@ def test_absolute_request_url(self, client: KeycardAPI) -> None:
def test_copied_client_does_not_close_http(self) -> None:
test_client = KeycardAPI(
- base_url=base_url, client_id=client_id, client_secret=client_secret, _strict_response_validation=True
+ base_url=base_url,
+ api_key=api_key,
+ client_id=client_id,
+ client_secret=client_secret,
+ _strict_response_validation=True,
)
assert not test_client.is_closed()
@@ -855,7 +884,11 @@ def test_copied_client_does_not_close_http(self) -> None:
def test_client_context_manager(self) -> None:
test_client = KeycardAPI(
- base_url=base_url, client_id=client_id, client_secret=client_secret, _strict_response_validation=True
+ base_url=base_url,
+ api_key=api_key,
+ client_id=client_id,
+ client_secret=client_secret,
+ _strict_response_validation=True,
)
with test_client as c2:
assert c2 is test_client
@@ -879,6 +912,7 @@ def test_client_max_retries_validation(self) -> None:
with pytest.raises(TypeError, match=r"max_retries cannot be None"):
KeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -893,14 +927,22 @@ class Model(BaseModel):
respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
strict_client = KeycardAPI(
- base_url=base_url, client_id=client_id, client_secret=client_secret, _strict_response_validation=True
+ base_url=base_url,
+ api_key=api_key,
+ client_id=client_id,
+ client_secret=client_secret,
+ _strict_response_validation=True,
)
with pytest.raises(APIResponseValidationError):
strict_client.get("/foo", cast_to=Model)
non_strict_client = KeycardAPI(
- base_url=base_url, client_id=client_id, client_secret=client_secret, _strict_response_validation=False
+ base_url=base_url,
+ api_key=api_key,
+ client_id=client_id,
+ client_secret=client_secret,
+ _strict_response_validation=False,
)
response = non_strict_client.get("/foo", cast_to=Model)
@@ -1116,6 +1158,10 @@ def test_copy(self, async_client: AsyncKeycardAPI) -> None:
copied = async_client.copy()
assert id(copied) != id(async_client)
+ copied = async_client.copy(api_key="another My API Key")
+ assert copied.api_key == "another My API Key"
+ assert async_client.api_key == "My API Key"
+
copied = async_client.copy(client_id="another My Client ID")
assert copied.client_id == "another My Client ID"
assert async_client.client_id == "My Client ID"
@@ -1143,6 +1189,7 @@ def test_copy_default_options(self, async_client: AsyncKeycardAPI) -> None:
async def test_copy_default_headers(self) -> None:
client = AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1182,6 +1229,7 @@ async def test_copy_default_headers(self) -> None:
async def test_copy_default_query(self) -> None:
client = AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1314,6 +1362,7 @@ async def test_request_timeout(self, async_client: AsyncKeycardAPI) -> None:
async def test_client_timeout_option(self) -> None:
client = AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1331,6 +1380,7 @@ async def test_http_client_timeout_option(self) -> None:
async with httpx.AsyncClient(timeout=None) as http_client:
client = AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1347,6 +1397,7 @@ async def test_http_client_timeout_option(self) -> None:
async with httpx.AsyncClient() as http_client:
client = AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1363,6 +1414,7 @@ async def test_http_client_timeout_option(self) -> None:
async with httpx.AsyncClient(timeout=HTTPX_DEFAULT_TIMEOUT) as http_client:
client = AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1380,6 +1432,7 @@ def test_invalid_http_client(self) -> None:
with httpx.Client() as http_client:
AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1389,6 +1442,7 @@ def test_invalid_http_client(self) -> None:
async def test_default_headers_option(self) -> None:
test_client = AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1400,6 +1454,7 @@ async def test_default_headers_option(self) -> None:
test_client2 = AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1418,6 +1473,7 @@ async def test_default_headers_option(self) -> None:
async def test_default_query_option(self) -> None:
client = AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1617,6 +1673,7 @@ async def mock_handler(request: httpx.Request) -> httpx.Response:
async with AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1718,6 +1775,7 @@ class Model(BaseModel):
async def test_base_url_setter(self) -> None:
client = AsyncKeycardAPI(
base_url="https://example.com/from_init",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1732,7 +1790,9 @@ async def test_base_url_setter(self) -> None:
async def test_base_url_env(self) -> None:
with update_env(KEYCARD_API_BASE_URL="http://localhost:5000/from/env"):
- client = AsyncKeycardAPI(client_id=client_id, client_secret=client_secret, _strict_response_validation=True)
+ client = AsyncKeycardAPI(
+ api_key=api_key, client_id=client_id, client_secret=client_secret, _strict_response_validation=True
+ )
assert client.base_url == "http://localhost:5000/from/env/"
@pytest.mark.parametrize(
@@ -1740,12 +1800,14 @@ async def test_base_url_env(self) -> None:
[
AsyncKeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
),
AsyncKeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1770,12 +1832,14 @@ async def test_base_url_trailing_slash(self, client: AsyncKeycardAPI) -> None:
[
AsyncKeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
),
AsyncKeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1800,12 +1864,14 @@ async def test_base_url_no_trailing_slash(self, client: AsyncKeycardAPI) -> None
[
AsyncKeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
),
AsyncKeycardAPI(
base_url="http://localhost:5000/custom/path/",
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1827,7 +1893,11 @@ async def test_absolute_request_url(self, client: AsyncKeycardAPI) -> None:
async def test_copied_client_does_not_close_http(self) -> None:
test_client = AsyncKeycardAPI(
- base_url=base_url, client_id=client_id, client_secret=client_secret, _strict_response_validation=True
+ base_url=base_url,
+ api_key=api_key,
+ client_id=client_id,
+ client_secret=client_secret,
+ _strict_response_validation=True,
)
assert not test_client.is_closed()
@@ -1841,7 +1911,11 @@ async def test_copied_client_does_not_close_http(self) -> None:
async def test_client_context_manager(self) -> None:
test_client = AsyncKeycardAPI(
- base_url=base_url, client_id=client_id, client_secret=client_secret, _strict_response_validation=True
+ base_url=base_url,
+ api_key=api_key,
+ client_id=client_id,
+ client_secret=client_secret,
+ _strict_response_validation=True,
)
async with test_client as c2:
assert c2 is test_client
@@ -1867,6 +1941,7 @@ async def test_client_max_retries_validation(self) -> None:
with pytest.raises(TypeError, match=r"max_retries cannot be None"):
AsyncKeycardAPI(
base_url=base_url,
+ api_key=api_key,
client_id=client_id,
client_secret=client_secret,
_strict_response_validation=True,
@@ -1881,14 +1956,22 @@ class Model(BaseModel):
respx_mock.get("/foo").mock(return_value=httpx.Response(200, text="my-custom-format"))
strict_client = AsyncKeycardAPI(
- base_url=base_url, client_id=client_id, client_secret=client_secret, _strict_response_validation=True
+ base_url=base_url,
+ api_key=api_key,
+ client_id=client_id,
+ client_secret=client_secret,
+ _strict_response_validation=True,
)
with pytest.raises(APIResponseValidationError):
await strict_client.get("/foo", cast_to=Model)
non_strict_client = AsyncKeycardAPI(
- base_url=base_url, client_id=client_id, client_secret=client_secret, _strict_response_validation=False
+ base_url=base_url,
+ api_key=api_key,
+ client_id=client_id,
+ client_secret=client_secret,
+ _strict_response_validation=False,
)
response = await non_strict_client.get("/foo", cast_to=Model)
From 8389f304ffae7c4bbe512de541a6896469be273f Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 15 Jul 2026 23:39:38 +0000
Subject: [PATCH 10/21] chore: Fixes found during Terraform work
---
.stats.yml | 4 ++--
.../types/zones/policy_sets/policy_set_version.py | 14 +++++++++++++-
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index fdb32d1..3aebeea 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-86ecdf280dafe23581fcd39dc510c8be1034d346b6cf45f92aa4fd86d286d7a7.yml
-openapi_spec_hash: 17cc8c1e295127243a94a616dc3fa64d
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-5585400a3ab72c7300888c1ba789c320ed95e2a2983fee1c9f56f3b569cbcbda.yml
+openapi_spec_hash: 12b2b3b3b70d372ad48b2e41ecf7a8ba
config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
diff --git a/src/keycardai_api/types/zones/policy_sets/policy_set_version.py b/src/keycardai_api/types/zones/policy_sets/policy_set_version.py
index 5b89efc..996c5e2 100644
--- a/src/keycardai_api/types/zones/policy_sets/policy_set_version.py
+++ b/src/keycardai_api/types/zones/policy_sets/policy_set_version.py
@@ -41,11 +41,23 @@ class PolicySetVersion(BaseModel):
version: int
active: Optional[bool] = None
- """Whether this policy set version is currently bound with mode='active'"""
+ """Whether this policy set version is currently bound with mode='active'.
+
+ Always populated in responses; clients must treat absence as unknown rather than
+ inferring 'not bound'.
+ """
archived_at: Optional[datetime] = None
+ """Timestamp when the version was archived.
+
+ Non-null only for archived versions; null or absent means not archived.
+ """
archived_by: Optional[str] = None
+ """Identifier of the actor that archived the version.
+
+ Null or absent means not archived.
+ """
attestation: Optional[AttestationStatement] = None
"""Decoded content of an Attestation JWS payload.
From 414ac68a77bb5c54837b7d4146c2e8b8735f6370 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 17 Jul 2026 14:29:34 +0000
Subject: [PATCH 11/21] feat(applications): allow unified-gateway and
mcp-server traits (ECO-128)
---
.stats.yml | 4 +-
src/keycardai_api/resources/zones/users.py | 76 +++++++++++++------
.../types/zones/application_trait.py | 2 +-
src/keycardai_api/types/zones/user.py | 69 ++++++++++++++++-
.../types/zones/user_list_params.py | 15 +++-
5 files changed, 134 insertions(+), 32 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 3aebeea..9ce5de0 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-5585400a3ab72c7300888c1ba789c320ed95e2a2983fee1c9f56f3b569cbcbda.yml
-openapi_spec_hash: 12b2b3b3b70d372ad48b2e41ecf7a8ba
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-90639d9cdd73de5a8cbc63e3819edd672870e2ea7227a9c1a3265bceeee70bd9.yml
+openapi_spec_hash: 5787e18f04fbf00fe9830309145cf54a
config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
diff --git a/src/keycardai_api/resources/zones/users.py b/src/keycardai_api/resources/zones/users.py
index 399df0f..52b71d8 100644
--- a/src/keycardai_api/resources/zones/users.py
+++ b/src/keycardai_api/resources/zones/users.py
@@ -88,8 +88,19 @@ def list(
after: str | Omit = omit,
before: str | Omit = omit,
expand: Union[
- Literal["total_count", "session_count", "grant_count", "role-assignments"],
- List[Literal["total_count", "session_count", "grant_count", "role-assignments"]],
+ Literal[
+ "total_count", "session_count", "grant_count", "role-assignments", "credentials", "credentials.provider"
+ ],
+ List[
+ Literal[
+ "total_count",
+ "session_count",
+ "grant_count",
+ "role-assignments",
+ "credentials",
+ "credentials.provider",
+ ]
+ ],
]
| Omit = omit,
filter_email: Union[str, SequenceNotStr[str]] | Omit = omit,
@@ -120,16 +131,19 @@ def list(
Use cursor pagination via `after`/`before`. Sort: comma-separated field list;
prefix with `-` for descending. Use `expand[]=total_count` to include the
matching row count, `expand[]=session_count` to include per-user session counts,
- `expand[]=grant_count` to include per-user delegated-grant counts, and
- `expand[]=role-assignments` to include each user's structured role grants.
- Filter by exact email via `filter[email]` and by exact identifier via
- `filter[identifier]`; search via `query[email]` / `query[subject]` / `query[]`
- (substring match, OR'd across repeated values). `query[]` matches against email
- and federation credential subject. Pass `filter[id]` (repeatable, max 100) to
- restrict results to a known set of users — mutually exclusive with
- `after`/`before` (returns 400 if combined). When `filter[id]` is set, `limit` is
- ignored and the response contains every requested user that exists in the zone,
- in a single page. IDs not in the zone are silently omitted.
+ `expand[]=grant_count` to include per-user delegated-grant counts,
+ `expand[]=role-assignments` to include each user's structured role grants,
+ `expand[]=credentials` to include each user's authentication credentials (each
+ with its `provider_id`), and `expand[]=credentials.provider` to additionally
+ inline the full identity provider on each federation credential. Filter by exact
+ email via `filter[email]` and by exact identifier via `filter[identifier]`;
+ search via `query[email]` / `query[subject]` / `query[]` (substring match, OR'd
+ across repeated values). `query[]` matches against email and federation
+ credential subject. Pass `filter[id]` (repeatable, max 100) to restrict results
+ to a known set of users — mutually exclusive with `after`/`before` (returns 400
+ if combined). When `filter[id]` is set, `limit` is ignored and the response
+ contains every requested user that exists in the zone, in a single page. IDs not
+ in the zone are silently omitted.
Args:
after: Cursor for forward pagination
@@ -255,8 +269,19 @@ async def list(
after: str | Omit = omit,
before: str | Omit = omit,
expand: Union[
- Literal["total_count", "session_count", "grant_count", "role-assignments"],
- List[Literal["total_count", "session_count", "grant_count", "role-assignments"]],
+ Literal[
+ "total_count", "session_count", "grant_count", "role-assignments", "credentials", "credentials.provider"
+ ],
+ List[
+ Literal[
+ "total_count",
+ "session_count",
+ "grant_count",
+ "role-assignments",
+ "credentials",
+ "credentials.provider",
+ ]
+ ],
]
| Omit = omit,
filter_email: Union[str, SequenceNotStr[str]] | Omit = omit,
@@ -287,16 +312,19 @@ async def list(
Use cursor pagination via `after`/`before`. Sort: comma-separated field list;
prefix with `-` for descending. Use `expand[]=total_count` to include the
matching row count, `expand[]=session_count` to include per-user session counts,
- `expand[]=grant_count` to include per-user delegated-grant counts, and
- `expand[]=role-assignments` to include each user's structured role grants.
- Filter by exact email via `filter[email]` and by exact identifier via
- `filter[identifier]`; search via `query[email]` / `query[subject]` / `query[]`
- (substring match, OR'd across repeated values). `query[]` matches against email
- and federation credential subject. Pass `filter[id]` (repeatable, max 100) to
- restrict results to a known set of users — mutually exclusive with
- `after`/`before` (returns 400 if combined). When `filter[id]` is set, `limit` is
- ignored and the response contains every requested user that exists in the zone,
- in a single page. IDs not in the zone are silently omitted.
+ `expand[]=grant_count` to include per-user delegated-grant counts,
+ `expand[]=role-assignments` to include each user's structured role grants,
+ `expand[]=credentials` to include each user's authentication credentials (each
+ with its `provider_id`), and `expand[]=credentials.provider` to additionally
+ inline the full identity provider on each federation credential. Filter by exact
+ email via `filter[email]` and by exact identifier via `filter[identifier]`;
+ search via `query[email]` / `query[subject]` / `query[]` (substring match, OR'd
+ across repeated values). `query[]` matches against email and federation
+ credential subject. Pass `filter[id]` (repeatable, max 100) to restrict results
+ to a known set of users — mutually exclusive with `after`/`before` (returns 400
+ if combined). When `filter[id]` is set, `limit` is ignored and the response
+ contains every requested user that exists in the zone, in a single page. IDs not
+ in the zone are silently omitted.
Args:
after: Cursor for forward pagination
diff --git a/src/keycardai_api/types/zones/application_trait.py b/src/keycardai_api/types/zones/application_trait.py
index 293bb0f..c266be7 100644
--- a/src/keycardai_api/types/zones/application_trait.py
+++ b/src/keycardai_api/types/zones/application_trait.py
@@ -4,4 +4,4 @@
__all__ = ["ApplicationTrait"]
-ApplicationTrait: TypeAlias = Literal["gateway", "mcp-provider"]
+ApplicationTrait: TypeAlias = Literal["gateway", "mcp-provider", "unified-gateway", "mcp-server"]
diff --git a/src/keycardai_api/types/zones/user.py b/src/keycardai_api/types/zones/user.py
index a9f39ac..f99d086 100644
--- a/src/keycardai_api/types/zones/user.py
+++ b/src/keycardai_api/types/zones/user.py
@@ -1,12 +1,68 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
-from typing import List, Optional
+from typing import List, Union, Optional
from datetime import datetime
-from typing_extensions import Literal
+from typing_extensions import Literal, TypeAlias
+from .provider import Provider
from ..._models import BaseModel
-__all__ = ["User", "RoleAssignment", "RoleAssignmentScope"]
+__all__ = [
+ "User",
+ "Credential",
+ "CredentialIamUserCredentialFederation",
+ "CredentialIamUserCredentialPassword",
+ "RoleAssignment",
+ "RoleAssignmentScope",
+]
+
+
+class CredentialIamUserCredentialFederation(BaseModel):
+ """Federation credential: the user authenticates through an identity provider."""
+
+ created_at: datetime
+ """Entity creation timestamp"""
+
+ provider_id: Optional[str] = None
+ """ID of the identity provider backing this credential.
+
+ `null` when the source provider has been deleted.
+ """
+
+ type: Literal["federation"]
+
+ updated_at: datetime
+ """Entity update timestamp"""
+
+ issuer: Optional[str] = None
+ """Issuer identifier of the identity provider."""
+
+ provider: Optional[Provider] = None
+ """
+ A Provider is a system that supplies access to Resources and allows actors
+ (Users or Applications) to authenticate.
+ """
+
+ subject: Optional[str] = None
+ """Subject identifier from the identity provider."""
+
+
+class CredentialIamUserCredentialPassword(BaseModel):
+ """Password credential: the user authenticates with email and password.
+
+ The email lives on the user.
+ """
+
+ created_at: datetime
+ """Entity creation timestamp"""
+
+ type: Literal["password"]
+
+ updated_at: datetime
+ """Entity update timestamp"""
+
+
+Credential: TypeAlias = Union[CredentialIamUserCredentialFederation, CredentialIamUserCredentialPassword]
class RoleAssignmentScope(BaseModel):
@@ -77,6 +133,13 @@ class User(BaseModel):
authenticated_at: Optional[str] = None
"""Date when the user was last authenticated"""
+ credentials: Optional[List[Credential]] = None
+ """
+ Authentication credentials for this user, each carrying its identity provider
+ for federation credentials. Populated only when `expand[]=credentials` is set on
+ the listing endpoint.
+ """
+
grant_count: Optional[int] = None
"""Delegated-grant count for this user.
diff --git a/src/keycardai_api/types/zones/user_list_params.py b/src/keycardai_api/types/zones/user_list_params.py
index b95f1d2..99dbba6 100644
--- a/src/keycardai_api/types/zones/user_list_params.py
+++ b/src/keycardai_api/types/zones/user_list_params.py
@@ -20,8 +20,19 @@ class UserListParams(TypedDict, total=False):
expand: Annotated[
Union[
- Literal["total_count", "session_count", "grant_count", "role-assignments"],
- List[Literal["total_count", "session_count", "grant_count", "role-assignments"]],
+ Literal[
+ "total_count", "session_count", "grant_count", "role-assignments", "credentials", "credentials.provider"
+ ],
+ List[
+ Literal[
+ "total_count",
+ "session_count",
+ "grant_count",
+ "role-assignments",
+ "credentials",
+ "credentials.provider",
+ ]
+ ],
],
PropertyInfo(alias="expand[]"),
]
From 1c22f7ea4ae904c4864a794dc4aaa46afa7d7e0e Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 17 Jul 2026 17:16:55 +0000
Subject: [PATCH 12/21] feat(stlc): configurable CI runner and
private-production-repo support in workflow templates
---
.github/workflows/ci.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 69d849f..4b521a4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,7 +18,7 @@ jobs:
lint:
timeout-minutes: 10
name: lint
- runs-on: ${{ github.repository == 'stainless-sdks/keycard-api-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
+ runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -41,7 +41,7 @@ jobs:
permissions:
contents: read
id-token: write
- runs-on: ${{ github.repository == 'stainless-sdks/keycard-api-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
+ runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -78,7 +78,7 @@ jobs:
test:
timeout-minutes: 10
name: test
- runs-on: ${{ github.repository == 'stainless-sdks/keycard-api-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
+ runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
From af681157892a4df402b367cd34339c78d6aa2c2d Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 17 Jul 2026 17:57:44 +0000
Subject: [PATCH 13/21] codegen metadata
---
.stats.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 9ce5de0..eec7f86 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-90639d9cdd73de5a8cbc63e3819edd672870e2ea7227a9c1a3265bceeee70bd9.yml
-openapi_spec_hash: 5787e18f04fbf00fe9830309145cf54a
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-754d77c6666d28bbc6e4f816e941923c725a18240b058820bc258f1754286ba4.yml
+openapi_spec_hash: f63aa68390574114689235bfccb2e0c5
config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
From bf161039858f079eabd6abf9102fa439633b9bc9 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 21 Jul 2026 17:04:51 +0000
Subject: [PATCH 14/21] chore: de-dup and align types across API specs
---
.stats.yml | 4 ++--
src/keycardai_api/resources/zones/policy_schemas.py | 4 ++--
src/keycardai_api/resources/zones/zones.py | 12 ++++++------
3 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index eec7f86..0db68ed 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-754d77c6666d28bbc6e4f816e941923c725a18240b058820bc258f1754286ba4.yml
-openapi_spec_hash: f63aa68390574114689235bfccb2e0c5
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-300da5154e80e025be0d6dc22d4b05728b0607112d9555142b4bd6c2680b80c0.yml
+openapi_spec_hash: fd2619219b2363cca72c2e56ad89d224
config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
diff --git a/src/keycardai_api/resources/zones/policy_schemas.py b/src/keycardai_api/resources/zones/policy_schemas.py
index eb5b9fd..a5a4f5b 100644
--- a/src/keycardai_api/resources/zones/policy_schemas.py
+++ b/src/keycardai_api/resources/zones/policy_schemas.py
@@ -36,7 +36,7 @@ class PolicySchemasResource(SyncAPIResource):
- **Keycard::RegistrationMethod** — enum entity: `"managed"`, `"dcr"`
- **Keycard::CredentialType** — enum entity: `"token"`, `"password"`, `"public-key"`, `"url"`, `"public"`
- **Keycard::Resource** — `id` (String), `name` (String), `scopes` (Set of String)
- - **Keycard::Claims** — `email` (String), `groups` (Set of String), plus arbitrary additional fields
+ - **Keycard::Claims** — `email` (String), `groups` (Set of String), `issuer_claims` (issuer-specific claims record), plus arbitrary additional fields
Enum-like attributes use Cedar enum entity types (schema version `2026-03-16`+).
In policies, reference values as `RegistrationMethod::"managed"` or `CredentialType::"token"`.
@@ -284,7 +284,7 @@ class AsyncPolicySchemasResource(AsyncAPIResource):
- **Keycard::RegistrationMethod** — enum entity: `"managed"`, `"dcr"`
- **Keycard::CredentialType** — enum entity: `"token"`, `"password"`, `"public-key"`, `"url"`, `"public"`
- **Keycard::Resource** — `id` (String), `name` (String), `scopes` (Set of String)
- - **Keycard::Claims** — `email` (String), `groups` (Set of String), plus arbitrary additional fields
+ - **Keycard::Claims** — `email` (String), `groups` (Set of String), `issuer_claims` (issuer-specific claims record), plus arbitrary additional fields
Enum-like attributes use Cedar enum entity types (schema version `2026-03-16`+).
In policies, reference values as `RegistrationMethod::"managed"` or `CredentialType::"token"`.
diff --git a/src/keycardai_api/resources/zones/zones.py b/src/keycardai_api/resources/zones/zones.py
index fcbdb8a..aef2478 100644
--- a/src/keycardai_api/resources/zones/zones.py
+++ b/src/keycardai_api/resources/zones/zones.py
@@ -188,7 +188,7 @@ def policy_schemas(self) -> PolicySchemasResource:
- **Keycard::RegistrationMethod** — enum entity: `"managed"`, `"dcr"`
- **Keycard::CredentialType** — enum entity: `"token"`, `"password"`, `"public-key"`, `"url"`, `"public"`
- **Keycard::Resource** — `id` (String), `name` (String), `scopes` (Set of String)
- - **Keycard::Claims** — `email` (String), `groups` (Set of String), plus arbitrary additional fields
+ - **Keycard::Claims** — `email` (String), `groups` (Set of String), `issuer_claims` (issuer-specific claims record), plus arbitrary additional fields
Enum-like attributes use Cedar enum entity types (schema version `2026-03-16`+).
In policies, reference values as `RegistrationMethod::"managed"` or `CredentialType::"token"`.
@@ -556,7 +556,7 @@ def policy_schemas(self) -> AsyncPolicySchemasResource:
- **Keycard::RegistrationMethod** — enum entity: `"managed"`, `"dcr"`
- **Keycard::CredentialType** — enum entity: `"token"`, `"password"`, `"public-key"`, `"url"`, `"public"`
- **Keycard::Resource** — `id` (String), `name` (String), `scopes` (Set of String)
- - **Keycard::Claims** — `email` (String), `groups` (Set of String), plus arbitrary additional fields
+ - **Keycard::Claims** — `email` (String), `groups` (Set of String), `issuer_claims` (issuer-specific claims record), plus arbitrary additional fields
Enum-like attributes use Cedar enum entity types (schema version `2026-03-16`+).
In policies, reference values as `RegistrationMethod::"managed"` or `CredentialType::"token"`.
@@ -943,7 +943,7 @@ def policy_schemas(self) -> PolicySchemasResourceWithRawResponse:
- **Keycard::RegistrationMethod** — enum entity: `"managed"`, `"dcr"`
- **Keycard::CredentialType** — enum entity: `"token"`, `"password"`, `"public-key"`, `"url"`, `"public"`
- **Keycard::Resource** — `id` (String), `name` (String), `scopes` (Set of String)
- - **Keycard::Claims** — `email` (String), `groups` (Set of String), plus arbitrary additional fields
+ - **Keycard::Claims** — `email` (String), `groups` (Set of String), `issuer_claims` (issuer-specific claims record), plus arbitrary additional fields
Enum-like attributes use Cedar enum entity types (schema version `2026-03-16`+).
In policies, reference values as `RegistrationMethod::"managed"` or `CredentialType::"token"`.
@@ -1034,7 +1034,7 @@ def policy_schemas(self) -> AsyncPolicySchemasResourceWithRawResponse:
- **Keycard::RegistrationMethod** — enum entity: `"managed"`, `"dcr"`
- **Keycard::CredentialType** — enum entity: `"token"`, `"password"`, `"public-key"`, `"url"`, `"public"`
- **Keycard::Resource** — `id` (String), `name` (String), `scopes` (Set of String)
- - **Keycard::Claims** — `email` (String), `groups` (Set of String), plus arbitrary additional fields
+ - **Keycard::Claims** — `email` (String), `groups` (Set of String), `issuer_claims` (issuer-specific claims record), plus arbitrary additional fields
Enum-like attributes use Cedar enum entity types (schema version `2026-03-16`+).
In policies, reference values as `RegistrationMethod::"managed"` or `CredentialType::"token"`.
@@ -1125,7 +1125,7 @@ def policy_schemas(self) -> PolicySchemasResourceWithStreamingResponse:
- **Keycard::RegistrationMethod** — enum entity: `"managed"`, `"dcr"`
- **Keycard::CredentialType** — enum entity: `"token"`, `"password"`, `"public-key"`, `"url"`, `"public"`
- **Keycard::Resource** — `id` (String), `name` (String), `scopes` (Set of String)
- - **Keycard::Claims** — `email` (String), `groups` (Set of String), plus arbitrary additional fields
+ - **Keycard::Claims** — `email` (String), `groups` (Set of String), `issuer_claims` (issuer-specific claims record), plus arbitrary additional fields
Enum-like attributes use Cedar enum entity types (schema version `2026-03-16`+).
In policies, reference values as `RegistrationMethod::"managed"` or `CredentialType::"token"`.
@@ -1216,7 +1216,7 @@ def policy_schemas(self) -> AsyncPolicySchemasResourceWithStreamingResponse:
- **Keycard::RegistrationMethod** — enum entity: `"managed"`, `"dcr"`
- **Keycard::CredentialType** — enum entity: `"token"`, `"password"`, `"public-key"`, `"url"`, `"public"`
- **Keycard::Resource** — `id` (String), `name` (String), `scopes` (Set of String)
- - **Keycard::Claims** — `email` (String), `groups` (Set of String), plus arbitrary additional fields
+ - **Keycard::Claims** — `email` (String), `groups` (Set of String), `issuer_claims` (issuer-specific claims record), plus arbitrary additional fields
Enum-like attributes use Cedar enum entity types (schema version `2026-03-16`+).
In policies, reference values as `RegistrationMethod::"managed"` or `CredentialType::"token"`.
From 09fa718c152e865fb364f1e30ee0952b51c8d4de Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 21 Jul 2026 22:24:36 +0000
Subject: [PATCH 15/21] codegen metadata
---
.stats.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 0db68ed..246604f 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-300da5154e80e025be0d6dc22d4b05728b0607112d9555142b4bd6c2680b80c0.yml
-openapi_spec_hash: fd2619219b2363cca72c2e56ad89d224
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-97105c1697fcff44c449b6065a51f9c3485af56701253f034fdbbac4dd151548.yml
+openapi_spec_hash: 142c903435dd1b1d162357c6da7b2a2c
config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
From 614306e8b6ed05e5d41cdc474ff98bd023afbbd0 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:40:17 +0000
Subject: [PATCH 16/21] codegen metadata
---
.stats.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 246604f..46d9bc4 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-97105c1697fcff44c449b6065a51f9c3485af56701253f034fdbbac4dd151548.yml
-openapi_spec_hash: 142c903435dd1b1d162357c6da7b2a2c
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-9b9213d37e4c3bd8cc50837b029b81f39aa0dbb91f8c1b4001eab26767340c4f.yml
+openapi_spec_hash: b194b9216a444e80100f6261f217960f
config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
From ccecea8daf510c99a6cdd08ca04222130ce37a39 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 22 Jul 2026 15:21:51 +0000
Subject: [PATCH 17/21] codegen metadata
---
.stats.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 46d9bc4..6d52be0 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-9b9213d37e4c3bd8cc50837b029b81f39aa0dbb91f8c1b4001eab26767340c4f.yml
-openapi_spec_hash: b194b9216a444e80100f6261f217960f
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-9033c94c3389ccc8501a1403313165fc2985801e98b166f21766cc601e33bcea.yml
+openapi_spec_hash: 85d79369d2b5eb95a49751607792b987
config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
From 6a76dbf04cbd9d16c1054a262a7a42a01668f035 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 23 Jul 2026 19:32:30 +0000
Subject: [PATCH 18/21] feat(ACC-709): application assignees + expose role
assignments
---
.stats.yml | 4 ++--
src/keycardai_api/types/zones/user.py | 12 ++++++++++--
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 6d52be0..6f2bc82 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-9033c94c3389ccc8501a1403313165fc2985801e98b166f21766cc601e33bcea.yml
-openapi_spec_hash: 85d79369d2b5eb95a49751607792b987
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-d2732fb41ab7b3de75794a4597eddb233c42c50f47c0d416d45c20d4cf44c8e3.yml
+openapi_spec_hash: 49c3651c217697ea8899b58c8f2bb046
config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
diff --git a/src/keycardai_api/types/zones/user.py b/src/keycardai_api/types/zones/user.py
index f99d086..77590e5 100644
--- a/src/keycardai_api/types/zones/user.py
+++ b/src/keycardai_api/types/zones/user.py
@@ -84,9 +84,17 @@ class RoleAssignment(BaseModel):
"""ID of the assigned role"""
role_identifier: str
- """Opaque role identifier.
+ """
+ Role identifier: a lowercase slug (letters and digits separated by single
+ hyphens or underscores), unique per owner type within a zone. Role identifiers
+ surface in policy evaluation, so the slug restriction keeps them unambiguous in
+ policy text.
+ """
+
+ role_owner_type: Literal["platform", "customer"]
+ """Owner type of the granted role.
- Treated as an opaque identifier by the API and unique within a zone.
+ Disambiguates roles that share an identifier across owner types.
"""
scope: Optional[RoleAssignmentScope] = None
From 518c473dcb23f0d68e849838a070187addf422a9 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 24 Jul 2026 14:29:29 +0000
Subject: [PATCH 19/21] feat(sdk): generate a client for the existing
/policy/bundle endpoint
---
.stats.yml | 6 +-
api.md | 8 +
src/keycardai_api/_client.py | 177 +++++-
src/keycardai_api/resources/__init__.py | 14 +
src/keycardai_api/resources/policy_bundle.py | 526 ++++++++++++++++++
src/keycardai_api/types/__init__.py | 1 +
.../types/policy_bundle_update_params.py | 30 +
tests/api_resources/test_policy_bundle.py | 312 +++++++++++
8 files changed, 1070 insertions(+), 4 deletions(-)
create mode 100644 src/keycardai_api/resources/policy_bundle.py
create mode 100644 src/keycardai_api/types/policy_bundle_update_params.py
create mode 100644 tests/api_resources/test_policy_bundle.py
diff --git a/.stats.yml b/.stats.yml
index 6f2bc82..59a7ccf 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 106
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-d2732fb41ab7b3de75794a4597eddb233c42c50f47c0d416d45c20d4cf44c8e3.yml
+configured_endpoints: 109
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-79558666a6b9db6f96e38787e23a61d167f02806b4874b2142e20eafeda79a01.yml
openapi_spec_hash: 49c3651c217697ea8899b58c8f2bb046
-config_hash: e1aa3a147cff2ff69e1f0bf2c7d0e6bb
+config_hash: 7bcd9f04550ca2b9d8d8272528e6fa37
diff --git a/api.md b/api.md
index de50220..85da9f7 100644
--- a/api.md
+++ b/api.md
@@ -430,3 +430,11 @@ Methods:
- client.invitations.retrieve(token) -> InvitationRetrieveResponse
- client.invitations.accept(token) -> InvitationAcceptResponse
+
+# PolicyBundle
+
+Methods:
+
+- client.policy_bundle.retrieve() -> BinaryAPIResponse
+- client.policy_bundle.update(\*\*params) -> BinaryAPIResponse
+- client.policy_bundle.reset() -> None
diff --git a/src/keycardai_api/_client.py b/src/keycardai_api/_client.py
index 4aa915e..d76b6ee 100644
--- a/src/keycardai_api/_client.py
+++ b/src/keycardai_api/_client.py
@@ -37,9 +37,10 @@
)
if TYPE_CHECKING:
- from .resources import zones, invitations, organizations
+ from .resources import zones, invitations, organizations, policy_bundle
from .resources.invitations import InvitationsResource, AsyncInvitationsResource
from .resources.zones.zones import ZonesResource, AsyncZonesResource
+ from .resources.policy_bundle import PolicyBundleResource, AsyncPolicyBundleResource
from .resources.organizations.organizations import OrganizationsResource, AsyncOrganizationsResource
__all__ = [
@@ -147,6 +148,35 @@ def invitations(self) -> InvitationsResource:
return InvitationsResource(self)
+ @cached_property
+ def policy_bundle(self) -> PolicyBundleResource:
+ """Per-user Policy Bundle resource.
+
+ Allows clients (typically the Keycard CLI)
+ to GET, PUT, and DELETE the effective Policy Set for the calling user
+ on a zone. The bundle is encoded with a content-negotiated codec (currently
+ only `application/vnd.keycard.policy-bundle.v1+tar+gzip`).
+
+ ## Archive layout
+
+ The bundle is a gzip-compressed tar archive with this logical layout:
+
+ | Entry | Required on PUT | Notes |
+ |-------|-----------------|-------|
+ | `manifest.json` | **Yes** | See `PolicyBundleManifest`. The only source of the authoritative `schema.version`. |
+ | `schema.cedarschema` | No | Convenience snapshot of the Cedar schema. **Ignored on PUT** — the server validates policies against its own attested schema for `manifest.schema.version`. **Always present on GET.** |
+ | `policies/.cedar` | — | One Cedar policy per file; the filename stem is the policy's public ID. |
+
+ Decode rules: duplicate entries and unrecognized/nested entries are
+ rejected (`bundle_invalid`). On PUT the manifest's `sha` fields and
+ `policies[]` list are advisory — the server recomputes every digest from
+ the archived bytes and derives the policy set from the `policies/` files.
+ On GET every digest is authoritative.
+ """
+ from .resources.policy_bundle import PolicyBundleResource
+
+ return PolicyBundleResource(self)
+
@cached_property
def with_raw_response(self) -> KeycardAPIWithRawResponse:
return KeycardAPIWithRawResponse(self)
@@ -392,6 +422,35 @@ def invitations(self) -> AsyncInvitationsResource:
return AsyncInvitationsResource(self)
+ @cached_property
+ def policy_bundle(self) -> AsyncPolicyBundleResource:
+ """Per-user Policy Bundle resource.
+
+ Allows clients (typically the Keycard CLI)
+ to GET, PUT, and DELETE the effective Policy Set for the calling user
+ on a zone. The bundle is encoded with a content-negotiated codec (currently
+ only `application/vnd.keycard.policy-bundle.v1+tar+gzip`).
+
+ ## Archive layout
+
+ The bundle is a gzip-compressed tar archive with this logical layout:
+
+ | Entry | Required on PUT | Notes |
+ |-------|-----------------|-------|
+ | `manifest.json` | **Yes** | See `PolicyBundleManifest`. The only source of the authoritative `schema.version`. |
+ | `schema.cedarschema` | No | Convenience snapshot of the Cedar schema. **Ignored on PUT** — the server validates policies against its own attested schema for `manifest.schema.version`. **Always present on GET.** |
+ | `policies/.cedar` | — | One Cedar policy per file; the filename stem is the policy's public ID. |
+
+ Decode rules: duplicate entries and unrecognized/nested entries are
+ rejected (`bundle_invalid`). On PUT the manifest's `sha` fields and
+ `policies[]` list are advisory — the server recomputes every digest from
+ the archived bytes and derives the policy set from the `policies/` files.
+ On GET every digest is authoritative.
+ """
+ from .resources.policy_bundle import AsyncPolicyBundleResource
+
+ return AsyncPolicyBundleResource(self)
+
@cached_property
def with_raw_response(self) -> AsyncKeycardAPIWithRawResponse:
return AsyncKeycardAPIWithRawResponse(self)
@@ -568,6 +627,35 @@ def invitations(self) -> invitations.InvitationsResourceWithRawResponse:
return InvitationsResourceWithRawResponse(self._client.invitations)
+ @cached_property
+ def policy_bundle(self) -> policy_bundle.PolicyBundleResourceWithRawResponse:
+ """Per-user Policy Bundle resource.
+
+ Allows clients (typically the Keycard CLI)
+ to GET, PUT, and DELETE the effective Policy Set for the calling user
+ on a zone. The bundle is encoded with a content-negotiated codec (currently
+ only `application/vnd.keycard.policy-bundle.v1+tar+gzip`).
+
+ ## Archive layout
+
+ The bundle is a gzip-compressed tar archive with this logical layout:
+
+ | Entry | Required on PUT | Notes |
+ |-------|-----------------|-------|
+ | `manifest.json` | **Yes** | See `PolicyBundleManifest`. The only source of the authoritative `schema.version`. |
+ | `schema.cedarschema` | No | Convenience snapshot of the Cedar schema. **Ignored on PUT** — the server validates policies against its own attested schema for `manifest.schema.version`. **Always present on GET.** |
+ | `policies/.cedar` | — | One Cedar policy per file; the filename stem is the policy's public ID. |
+
+ Decode rules: duplicate entries and unrecognized/nested entries are
+ rejected (`bundle_invalid`). On PUT the manifest's `sha` fields and
+ `policies[]` list are advisory — the server recomputes every digest from
+ the archived bytes and derives the policy set from the `policies/` files.
+ On GET every digest is authoritative.
+ """
+ from .resources.policy_bundle import PolicyBundleResourceWithRawResponse
+
+ return PolicyBundleResourceWithRawResponse(self._client.policy_bundle)
+
class AsyncKeycardAPIWithRawResponse:
_client: AsyncKeycardAPI
@@ -593,6 +681,35 @@ def invitations(self) -> invitations.AsyncInvitationsResourceWithRawResponse:
return AsyncInvitationsResourceWithRawResponse(self._client.invitations)
+ @cached_property
+ def policy_bundle(self) -> policy_bundle.AsyncPolicyBundleResourceWithRawResponse:
+ """Per-user Policy Bundle resource.
+
+ Allows clients (typically the Keycard CLI)
+ to GET, PUT, and DELETE the effective Policy Set for the calling user
+ on a zone. The bundle is encoded with a content-negotiated codec (currently
+ only `application/vnd.keycard.policy-bundle.v1+tar+gzip`).
+
+ ## Archive layout
+
+ The bundle is a gzip-compressed tar archive with this logical layout:
+
+ | Entry | Required on PUT | Notes |
+ |-------|-----------------|-------|
+ | `manifest.json` | **Yes** | See `PolicyBundleManifest`. The only source of the authoritative `schema.version`. |
+ | `schema.cedarschema` | No | Convenience snapshot of the Cedar schema. **Ignored on PUT** — the server validates policies against its own attested schema for `manifest.schema.version`. **Always present on GET.** |
+ | `policies/.cedar` | — | One Cedar policy per file; the filename stem is the policy's public ID. |
+
+ Decode rules: duplicate entries and unrecognized/nested entries are
+ rejected (`bundle_invalid`). On PUT the manifest's `sha` fields and
+ `policies[]` list are advisory — the server recomputes every digest from
+ the archived bytes and derives the policy set from the `policies/` files.
+ On GET every digest is authoritative.
+ """
+ from .resources.policy_bundle import AsyncPolicyBundleResourceWithRawResponse
+
+ return AsyncPolicyBundleResourceWithRawResponse(self._client.policy_bundle)
+
class KeycardAPIWithStreamedResponse:
_client: KeycardAPI
@@ -618,6 +735,35 @@ def invitations(self) -> invitations.InvitationsResourceWithStreamingResponse:
return InvitationsResourceWithStreamingResponse(self._client.invitations)
+ @cached_property
+ def policy_bundle(self) -> policy_bundle.PolicyBundleResourceWithStreamingResponse:
+ """Per-user Policy Bundle resource.
+
+ Allows clients (typically the Keycard CLI)
+ to GET, PUT, and DELETE the effective Policy Set for the calling user
+ on a zone. The bundle is encoded with a content-negotiated codec (currently
+ only `application/vnd.keycard.policy-bundle.v1+tar+gzip`).
+
+ ## Archive layout
+
+ The bundle is a gzip-compressed tar archive with this logical layout:
+
+ | Entry | Required on PUT | Notes |
+ |-------|-----------------|-------|
+ | `manifest.json` | **Yes** | See `PolicyBundleManifest`. The only source of the authoritative `schema.version`. |
+ | `schema.cedarschema` | No | Convenience snapshot of the Cedar schema. **Ignored on PUT** — the server validates policies against its own attested schema for `manifest.schema.version`. **Always present on GET.** |
+ | `policies/.cedar` | — | One Cedar policy per file; the filename stem is the policy's public ID. |
+
+ Decode rules: duplicate entries and unrecognized/nested entries are
+ rejected (`bundle_invalid`). On PUT the manifest's `sha` fields and
+ `policies[]` list are advisory — the server recomputes every digest from
+ the archived bytes and derives the policy set from the `policies/` files.
+ On GET every digest is authoritative.
+ """
+ from .resources.policy_bundle import PolicyBundleResourceWithStreamingResponse
+
+ return PolicyBundleResourceWithStreamingResponse(self._client.policy_bundle)
+
class AsyncKeycardAPIWithStreamedResponse:
_client: AsyncKeycardAPI
@@ -643,6 +789,35 @@ def invitations(self) -> invitations.AsyncInvitationsResourceWithStreamingRespon
return AsyncInvitationsResourceWithStreamingResponse(self._client.invitations)
+ @cached_property
+ def policy_bundle(self) -> policy_bundle.AsyncPolicyBundleResourceWithStreamingResponse:
+ """Per-user Policy Bundle resource.
+
+ Allows clients (typically the Keycard CLI)
+ to GET, PUT, and DELETE the effective Policy Set for the calling user
+ on a zone. The bundle is encoded with a content-negotiated codec (currently
+ only `application/vnd.keycard.policy-bundle.v1+tar+gzip`).
+
+ ## Archive layout
+
+ The bundle is a gzip-compressed tar archive with this logical layout:
+
+ | Entry | Required on PUT | Notes |
+ |-------|-----------------|-------|
+ | `manifest.json` | **Yes** | See `PolicyBundleManifest`. The only source of the authoritative `schema.version`. |
+ | `schema.cedarschema` | No | Convenience snapshot of the Cedar schema. **Ignored on PUT** — the server validates policies against its own attested schema for `manifest.schema.version`. **Always present on GET.** |
+ | `policies/.cedar` | — | One Cedar policy per file; the filename stem is the policy's public ID. |
+
+ Decode rules: duplicate entries and unrecognized/nested entries are
+ rejected (`bundle_invalid`). On PUT the manifest's `sha` fields and
+ `policies[]` list are advisory — the server recomputes every digest from
+ the archived bytes and derives the policy set from the `policies/` files.
+ On GET every digest is authoritative.
+ """
+ from .resources.policy_bundle import AsyncPolicyBundleResourceWithStreamingResponse
+
+ return AsyncPolicyBundleResourceWithStreamingResponse(self._client.policy_bundle)
+
Client = KeycardAPI
diff --git a/src/keycardai_api/resources/__init__.py b/src/keycardai_api/resources/__init__.py
index d7c6a99..3e222d8 100644
--- a/src/keycardai_api/resources/__init__.py
+++ b/src/keycardai_api/resources/__init__.py
@@ -24,6 +24,14 @@
OrganizationsResourceWithStreamingResponse,
AsyncOrganizationsResourceWithStreamingResponse,
)
+from .policy_bundle import (
+ PolicyBundleResource,
+ AsyncPolicyBundleResource,
+ PolicyBundleResourceWithRawResponse,
+ AsyncPolicyBundleResourceWithRawResponse,
+ PolicyBundleResourceWithStreamingResponse,
+ AsyncPolicyBundleResourceWithStreamingResponse,
+)
__all__ = [
"ZonesResource",
@@ -44,4 +52,10 @@
"AsyncInvitationsResourceWithRawResponse",
"InvitationsResourceWithStreamingResponse",
"AsyncInvitationsResourceWithStreamingResponse",
+ "PolicyBundleResource",
+ "AsyncPolicyBundleResource",
+ "PolicyBundleResourceWithRawResponse",
+ "AsyncPolicyBundleResourceWithRawResponse",
+ "PolicyBundleResourceWithStreamingResponse",
+ "AsyncPolicyBundleResourceWithStreamingResponse",
]
diff --git a/src/keycardai_api/resources/policy_bundle.py b/src/keycardai_api/resources/policy_bundle.py
new file mode 100644
index 0000000..14e0f28
--- /dev/null
+++ b/src/keycardai_api/resources/policy_bundle.py
@@ -0,0 +1,526 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+import httpx
+
+from ..types import policy_bundle_update_params
+from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given
+from .._utils import maybe_transform, strip_not_given, async_maybe_transform
+from .._compat import cached_property
+from .._resource import SyncAPIResource, AsyncAPIResource
+from .._response import (
+ BinaryAPIResponse,
+ AsyncBinaryAPIResponse,
+ StreamedBinaryAPIResponse,
+ AsyncStreamedBinaryAPIResponse,
+ to_raw_response_wrapper,
+ to_streamed_response_wrapper,
+ async_to_raw_response_wrapper,
+ to_custom_raw_response_wrapper,
+ async_to_streamed_response_wrapper,
+ to_custom_streamed_response_wrapper,
+ async_to_custom_raw_response_wrapper,
+ async_to_custom_streamed_response_wrapper,
+)
+from .._base_client import make_request_options
+
+__all__ = ["PolicyBundleResource", "AsyncPolicyBundleResource"]
+
+
+class PolicyBundleResource(SyncAPIResource):
+ """Per-user Policy Bundle resource.
+
+ Allows clients (typically the Keycard CLI)
+ to GET, PUT, and DELETE the effective Policy Set for the calling user
+ on a zone. The bundle is encoded with a content-negotiated codec (currently
+ only `application/vnd.keycard.policy-bundle.v1+tar+gzip`).
+
+ ## Archive layout
+
+ The bundle is a gzip-compressed tar archive with this logical layout:
+
+ | Entry | Required on PUT | Notes |
+ |-------|-----------------|-------|
+ | `manifest.json` | **Yes** | See `PolicyBundleManifest`. The only source of the authoritative `schema.version`. |
+ | `schema.cedarschema` | No | Convenience snapshot of the Cedar schema. **Ignored on PUT** — the server validates policies against its own attested schema for `manifest.schema.version`. **Always present on GET.** |
+ | `policies/.cedar` | — | One Cedar policy per file; the filename stem is the policy's public ID. |
+
+ Decode rules: duplicate entries and unrecognized/nested entries are
+ rejected (`bundle_invalid`). On PUT the manifest's `sha` fields and
+ `policies[]` list are advisory — the server recomputes every digest from
+ the archived bytes and derives the policy set from the `policies/` files.
+ On GET every digest is authoritative.
+ """
+
+ @cached_property
+ def with_raw_response(self) -> PolicyBundleResourceWithRawResponse:
+ """
+ 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/keycardai/keycard-python#accessing-raw-response-data-eg-headers
+ """
+ return PolicyBundleResourceWithRawResponse(self)
+
+ @cached_property
+ def with_streaming_response(self) -> PolicyBundleResourceWithStreamingResponse:
+ """
+ An alternative to `.with_raw_response` that doesn't eagerly read the response body.
+
+ For more information, see https://www.github.com/keycardai/keycard-python#with_streaming_response
+ """
+ return PolicyBundleResourceWithStreamingResponse(self)
+
+ def retrieve(
+ self,
+ *,
+ if_none_match: str | Omit = omit,
+ x_client_request_id: 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,
+ ) -> BinaryAPIResponse:
+ """
+ Returns the effective Policy Bundle for the user identified by the zone-issued
+ resource-scoped token. When no user-scope binding exists, one will be generated
+ from the default set.
+
+ The response body is a binary archive in the codec selected via the `Accept`
+ header. The only codec supported today is
+ `application/vnd.keycard.policy-bundle.v1+tar+gzip`. Clients SHOULD send an
+ explicit `Accept` header; absent one, the server defaults to the tar+gzip codec.
+
+ Supports conditional fetch via `If-None-Match`: when the supplied ETag matches
+ the current bundle, the server responds `304 Not Modified` with no body.
+
+ Args:
+ 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
+ """
+ extra_headers = {"Accept": "application/vnd.keycard.policy-bundle.v1+tar+gzip", **(extra_headers or {})}
+ extra_headers = {
+ **strip_not_given(
+ {
+ "If-None-Match": if_none_match,
+ "X-Client-Request-ID": x_client_request_id,
+ }
+ ),
+ **(extra_headers or {}),
+ }
+ return self._get(
+ "/policy/bundle",
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ security={"bearer_auth": True},
+ ),
+ cast_to=BinaryAPIResponse,
+ )
+
+ def update(
+ self,
+ *,
+ body: FileTypes,
+ if_match: str | Omit = omit,
+ x_client_request_id: 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,
+ ) -> BinaryAPIResponse:
+ """
+ Accepts an edited Policy Bundle archive and applies it as the active user-scope
+ PolicySetVersion for the calling user.
+
+ The user's policy set is seeded from the system-default policies on first
+ access, forked into customer-owned policies; a user bundle therefore contains
+ only customer-owned policies. Applying an edit creates a new version of the
+ affected policy, and a `new_policy` entry adds a further customer-owned policy.
+ Platform-owned catalog policies are never edited in place by this operation.
+
+ The request body codec is determined from `Content-Type`. The only codec
+ supported today is `application/vnd.keycard.policy-bundle.v1+tar+gzip`.
+
+ Supports optimistic concurrency via `If-Match`: when supplied, the server
+ applies the bundle only if the supplied ETag matches the current bundle ETag;
+ otherwise responds `412 Precondition Failed`.
+
+ On success the server returns the materialized bundle (in the same codec) and
+ its new `ETag`.
+
+ Args:
+ body: tar+gzip Policy Bundle archive. `manifest.json` is **required** (see
+ `PolicyBundleManifest`); `schema.cedarschema` is **optional and ignored** — the
+ server validates against its attested schema for `manifest.schema.version`. The
+ manifest's `policies[]` list is authoritative for the resulting set: each entry
+ must have a matching `policies/.cedar` (or, for a `new_policy` entry,
+ `policies/.cedar`) member, and a member with no manifest entry is
+ dropped. Only the `sha` fields are advisory and recomputed server-side.
+ Duplicate or unrecognized entries are rejected with `bundle_invalid`. See the
+ **PolicyBundle** tag for the layout.
+
+ 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
+ """
+ extra_headers = {"Accept": "application/vnd.keycard.policy-bundle.v1+tar+gzip", **(extra_headers or {})}
+ extra_headers = {
+ **strip_not_given(
+ {
+ "If-Match": if_match,
+ "X-Client-Request-ID": x_client_request_id,
+ }
+ ),
+ **(extra_headers or {}),
+ }
+ return self._put(
+ "/policy/bundle",
+ body=maybe_transform(body, policy_bundle_update_params.PolicyBundleUpdateParams),
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ security={"bearer_auth": True},
+ ),
+ cast_to=BinaryAPIResponse,
+ )
+
+ def reset(
+ self,
+ *,
+ x_client_request_id: 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,
+ ) -> None:
+ """
+ Archives the PolicySet for the calling user (if any), causing subsequent
+ `GET /policy/bundle` requests to fall back to the default user policies.
+ Idempotent: returns `204 No Content` even when no user-scope binding exists.
+
+ Args:
+ 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
+ """
+ extra_headers = {"Accept": "*/*", **(extra_headers or {})}
+ extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
+ return self._delete(
+ "/policy/bundle",
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ security={"bearer_auth": True},
+ ),
+ cast_to=NoneType,
+ )
+
+
+class AsyncPolicyBundleResource(AsyncAPIResource):
+ """Per-user Policy Bundle resource.
+
+ Allows clients (typically the Keycard CLI)
+ to GET, PUT, and DELETE the effective Policy Set for the calling user
+ on a zone. The bundle is encoded with a content-negotiated codec (currently
+ only `application/vnd.keycard.policy-bundle.v1+tar+gzip`).
+
+ ## Archive layout
+
+ The bundle is a gzip-compressed tar archive with this logical layout:
+
+ | Entry | Required on PUT | Notes |
+ |-------|-----------------|-------|
+ | `manifest.json` | **Yes** | See `PolicyBundleManifest`. The only source of the authoritative `schema.version`. |
+ | `schema.cedarschema` | No | Convenience snapshot of the Cedar schema. **Ignored on PUT** — the server validates policies against its own attested schema for `manifest.schema.version`. **Always present on GET.** |
+ | `policies/.cedar` | — | One Cedar policy per file; the filename stem is the policy's public ID. |
+
+ Decode rules: duplicate entries and unrecognized/nested entries are
+ rejected (`bundle_invalid`). On PUT the manifest's `sha` fields and
+ `policies[]` list are advisory — the server recomputes every digest from
+ the archived bytes and derives the policy set from the `policies/` files.
+ On GET every digest is authoritative.
+ """
+
+ @cached_property
+ def with_raw_response(self) -> AsyncPolicyBundleResourceWithRawResponse:
+ """
+ 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/keycardai/keycard-python#accessing-raw-response-data-eg-headers
+ """
+ return AsyncPolicyBundleResourceWithRawResponse(self)
+
+ @cached_property
+ def with_streaming_response(self) -> AsyncPolicyBundleResourceWithStreamingResponse:
+ """
+ An alternative to `.with_raw_response` that doesn't eagerly read the response body.
+
+ For more information, see https://www.github.com/keycardai/keycard-python#with_streaming_response
+ """
+ return AsyncPolicyBundleResourceWithStreamingResponse(self)
+
+ async def retrieve(
+ self,
+ *,
+ if_none_match: str | Omit = omit,
+ x_client_request_id: 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,
+ ) -> AsyncBinaryAPIResponse:
+ """
+ Returns the effective Policy Bundle for the user identified by the zone-issued
+ resource-scoped token. When no user-scope binding exists, one will be generated
+ from the default set.
+
+ The response body is a binary archive in the codec selected via the `Accept`
+ header. The only codec supported today is
+ `application/vnd.keycard.policy-bundle.v1+tar+gzip`. Clients SHOULD send an
+ explicit `Accept` header; absent one, the server defaults to the tar+gzip codec.
+
+ Supports conditional fetch via `If-None-Match`: when the supplied ETag matches
+ the current bundle, the server responds `304 Not Modified` with no body.
+
+ Args:
+ 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
+ """
+ extra_headers = {"Accept": "application/vnd.keycard.policy-bundle.v1+tar+gzip", **(extra_headers or {})}
+ extra_headers = {
+ **strip_not_given(
+ {
+ "If-None-Match": if_none_match,
+ "X-Client-Request-ID": x_client_request_id,
+ }
+ ),
+ **(extra_headers or {}),
+ }
+ return await self._get(
+ "/policy/bundle",
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ security={"bearer_auth": True},
+ ),
+ cast_to=AsyncBinaryAPIResponse,
+ )
+
+ async def update(
+ self,
+ *,
+ body: FileTypes,
+ if_match: str | Omit = omit,
+ x_client_request_id: 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,
+ ) -> AsyncBinaryAPIResponse:
+ """
+ Accepts an edited Policy Bundle archive and applies it as the active user-scope
+ PolicySetVersion for the calling user.
+
+ The user's policy set is seeded from the system-default policies on first
+ access, forked into customer-owned policies; a user bundle therefore contains
+ only customer-owned policies. Applying an edit creates a new version of the
+ affected policy, and a `new_policy` entry adds a further customer-owned policy.
+ Platform-owned catalog policies are never edited in place by this operation.
+
+ The request body codec is determined from `Content-Type`. The only codec
+ supported today is `application/vnd.keycard.policy-bundle.v1+tar+gzip`.
+
+ Supports optimistic concurrency via `If-Match`: when supplied, the server
+ applies the bundle only if the supplied ETag matches the current bundle ETag;
+ otherwise responds `412 Precondition Failed`.
+
+ On success the server returns the materialized bundle (in the same codec) and
+ its new `ETag`.
+
+ Args:
+ body: tar+gzip Policy Bundle archive. `manifest.json` is **required** (see
+ `PolicyBundleManifest`); `schema.cedarschema` is **optional and ignored** — the
+ server validates against its attested schema for `manifest.schema.version`. The
+ manifest's `policies[]` list is authoritative for the resulting set: each entry
+ must have a matching `policies/.cedar` (or, for a `new_policy` entry,
+ `policies/.cedar`) member, and a member with no manifest entry is
+ dropped. Only the `sha` fields are advisory and recomputed server-side.
+ Duplicate or unrecognized entries are rejected with `bundle_invalid`. See the
+ **PolicyBundle** tag for the layout.
+
+ 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
+ """
+ extra_headers = {"Accept": "application/vnd.keycard.policy-bundle.v1+tar+gzip", **(extra_headers or {})}
+ extra_headers = {
+ **strip_not_given(
+ {
+ "If-Match": if_match,
+ "X-Client-Request-ID": x_client_request_id,
+ }
+ ),
+ **(extra_headers or {}),
+ }
+ return await self._put(
+ "/policy/bundle",
+ body=await async_maybe_transform(body, policy_bundle_update_params.PolicyBundleUpdateParams),
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ security={"bearer_auth": True},
+ ),
+ cast_to=AsyncBinaryAPIResponse,
+ )
+
+ async def reset(
+ self,
+ *,
+ x_client_request_id: 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,
+ ) -> None:
+ """
+ Archives the PolicySet for the calling user (if any), causing subsequent
+ `GET /policy/bundle` requests to fall back to the default user policies.
+ Idempotent: returns `204 No Content` even when no user-scope binding exists.
+
+ Args:
+ 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
+ """
+ extra_headers = {"Accept": "*/*", **(extra_headers or {})}
+ extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
+ return await self._delete(
+ "/policy/bundle",
+ options=make_request_options(
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ security={"bearer_auth": True},
+ ),
+ cast_to=NoneType,
+ )
+
+
+class PolicyBundleResourceWithRawResponse:
+ def __init__(self, policy_bundle: PolicyBundleResource) -> None:
+ self._policy_bundle = policy_bundle
+
+ self.retrieve = to_custom_raw_response_wrapper(
+ policy_bundle.retrieve,
+ BinaryAPIResponse,
+ )
+ self.update = to_custom_raw_response_wrapper(
+ policy_bundle.update,
+ BinaryAPIResponse,
+ )
+ self.reset = to_raw_response_wrapper(
+ policy_bundle.reset,
+ )
+
+
+class AsyncPolicyBundleResourceWithRawResponse:
+ def __init__(self, policy_bundle: AsyncPolicyBundleResource) -> None:
+ self._policy_bundle = policy_bundle
+
+ self.retrieve = async_to_custom_raw_response_wrapper(
+ policy_bundle.retrieve,
+ AsyncBinaryAPIResponse,
+ )
+ self.update = async_to_custom_raw_response_wrapper(
+ policy_bundle.update,
+ AsyncBinaryAPIResponse,
+ )
+ self.reset = async_to_raw_response_wrapper(
+ policy_bundle.reset,
+ )
+
+
+class PolicyBundleResourceWithStreamingResponse:
+ def __init__(self, policy_bundle: PolicyBundleResource) -> None:
+ self._policy_bundle = policy_bundle
+
+ self.retrieve = to_custom_streamed_response_wrapper(
+ policy_bundle.retrieve,
+ StreamedBinaryAPIResponse,
+ )
+ self.update = to_custom_streamed_response_wrapper(
+ policy_bundle.update,
+ StreamedBinaryAPIResponse,
+ )
+ self.reset = to_streamed_response_wrapper(
+ policy_bundle.reset,
+ )
+
+
+class AsyncPolicyBundleResourceWithStreamingResponse:
+ def __init__(self, policy_bundle: AsyncPolicyBundleResource) -> None:
+ self._policy_bundle = policy_bundle
+
+ self.retrieve = async_to_custom_streamed_response_wrapper(
+ policy_bundle.retrieve,
+ AsyncStreamedBinaryAPIResponse,
+ )
+ self.update = async_to_custom_streamed_response_wrapper(
+ policy_bundle.update,
+ AsyncStreamedBinaryAPIResponse,
+ )
+ self.reset = async_to_streamed_response_wrapper(
+ policy_bundle.reset,
+ )
diff --git a/src/keycardai_api/types/__init__.py b/src/keycardai_api/types/__init__.py
index d89e8f9..b64078b 100644
--- a/src/keycardai_api/types/__init__.py
+++ b/src/keycardai_api/types/__init__.py
@@ -18,6 +18,7 @@
from .organization_create_params import OrganizationCreateParams as OrganizationCreateParams
from .organization_list_response import OrganizationListResponse as OrganizationListResponse
from .organization_update_params import OrganizationUpdateParams as OrganizationUpdateParams
+from .policy_bundle_update_params import PolicyBundleUpdateParams as PolicyBundleUpdateParams
from .invitation_retrieve_response import InvitationRetrieveResponse as InvitationRetrieveResponse
from .organization_retrieve_params import OrganizationRetrieveParams as OrganizationRetrieveParams
from .encryption_key_aws_kms_config import EncryptionKeyAwsKmsConfig as EncryptionKeyAwsKmsConfig
diff --git a/src/keycardai_api/types/policy_bundle_update_params.py b/src/keycardai_api/types/policy_bundle_update_params.py
new file mode 100644
index 0000000..928c1cd
--- /dev/null
+++ b/src/keycardai_api/types/policy_bundle_update_params.py
@@ -0,0 +1,30 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing_extensions import Required, Annotated, TypedDict
+
+from .._types import FileTypes
+from .._utils import PropertyInfo
+
+__all__ = ["PolicyBundleUpdateParams"]
+
+
+class PolicyBundleUpdateParams(TypedDict, total=False):
+ body: Required[FileTypes]
+ """tar+gzip Policy Bundle archive.
+
+ `manifest.json` is **required** (see `PolicyBundleManifest`);
+ `schema.cedarschema` is **optional and ignored** — the server validates against
+ its attested schema for `manifest.schema.version`. The manifest's `policies[]`
+ list is authoritative for the resulting set: each entry must have a matching
+ `policies/.cedar` (or, for a `new_policy` entry,
+ `policies/.cedar`) member, and a member with no manifest entry is
+ dropped. Only the `sha` fields are advisory and recomputed server-side.
+ Duplicate or unrecognized entries are rejected with `bundle_invalid`. See the
+ **PolicyBundle** tag for the layout.
+ """
+
+ if_match: Annotated[str, PropertyInfo(alias="If-Match")]
+
+ x_client_request_id: Annotated[str, PropertyInfo(alias="X-Client-Request-ID")]
diff --git a/tests/api_resources/test_policy_bundle.py b/tests/api_resources/test_policy_bundle.py
new file mode 100644
index 0000000..5342e3f
--- /dev/null
+++ b/tests/api_resources/test_policy_bundle.py
@@ -0,0 +1,312 @@
+# 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 httpx
+import pytest
+from respx import MockRouter
+
+from keycardai_api import KeycardAPI, AsyncKeycardAPI
+from keycardai_api._response import (
+ BinaryAPIResponse,
+ AsyncBinaryAPIResponse,
+ StreamedBinaryAPIResponse,
+ AsyncStreamedBinaryAPIResponse,
+)
+
+base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
+
+
+class TestPolicyBundle:
+ parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"])
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ def test_method_retrieve(self, client: KeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.get("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ policy_bundle = client.policy_bundle.retrieve()
+ assert policy_bundle.is_closed
+ assert policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, BinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ def test_method_retrieve_with_all_params(self, client: KeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.get("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ policy_bundle = client.policy_bundle.retrieve(
+ if_none_match="If-None-Match",
+ x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
+ )
+ assert policy_bundle.is_closed
+ assert policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, BinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ def test_raw_response_retrieve(self, client: KeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.get("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+
+ policy_bundle = client.policy_bundle.with_raw_response.retrieve()
+
+ assert policy_bundle.is_closed is True
+ assert policy_bundle.http_request.headers.get("X-Stainless-Lang") == "python"
+ assert policy_bundle.json() == {"foo": "bar"}
+ assert isinstance(policy_bundle, BinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ def test_streaming_response_retrieve(self, client: KeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.get("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ with client.policy_bundle.with_streaming_response.retrieve() as policy_bundle:
+ assert not policy_bundle.is_closed
+ assert policy_bundle.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ assert policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, StreamedBinaryAPIResponse)
+
+ assert cast(Any, policy_bundle.is_closed) is True
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ def test_method_update(self, client: KeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.put("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ policy_bundle = client.policy_bundle.update(
+ body=b"Example data",
+ )
+ assert policy_bundle.is_closed
+ assert policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, BinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ def test_method_update_with_all_params(self, client: KeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.put("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ policy_bundle = client.policy_bundle.update(
+ body=b"Example data",
+ if_match="If-Match",
+ x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
+ )
+ assert policy_bundle.is_closed
+ assert policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, BinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ def test_raw_response_update(self, client: KeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.put("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+
+ policy_bundle = client.policy_bundle.with_raw_response.update(
+ body=b"Example data",
+ )
+
+ assert policy_bundle.is_closed is True
+ assert policy_bundle.http_request.headers.get("X-Stainless-Lang") == "python"
+ assert policy_bundle.json() == {"foo": "bar"}
+ assert isinstance(policy_bundle, BinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ def test_streaming_response_update(self, client: KeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.put("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ with client.policy_bundle.with_streaming_response.update(
+ body=b"Example data",
+ ) as policy_bundle:
+ assert not policy_bundle.is_closed
+ assert policy_bundle.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ assert policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, StreamedBinaryAPIResponse)
+
+ assert cast(Any, policy_bundle.is_closed) is True
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_reset(self, client: KeycardAPI) -> None:
+ policy_bundle = client.policy_bundle.reset()
+ assert policy_bundle is None
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_reset_with_all_params(self, client: KeycardAPI) -> None:
+ policy_bundle = client.policy_bundle.reset(
+ x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
+ )
+ assert policy_bundle is None
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_raw_response_reset(self, client: KeycardAPI) -> None:
+ response = client.policy_bundle.with_raw_response.reset()
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ policy_bundle = response.parse()
+ assert policy_bundle is None
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_streaming_response_reset(self, client: KeycardAPI) -> None:
+ with client.policy_bundle.with_streaming_response.reset() as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ policy_bundle = response.parse()
+ assert policy_bundle is None
+
+ assert cast(Any, response.is_closed) is True
+
+
+class TestAsyncPolicyBundle:
+ parametrize = pytest.mark.parametrize(
+ "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"]
+ )
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ async def test_method_retrieve(self, async_client: AsyncKeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.get("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ policy_bundle = await async_client.policy_bundle.retrieve()
+ assert policy_bundle.is_closed
+ assert await policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, AsyncBinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ async def test_method_retrieve_with_all_params(self, async_client: AsyncKeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.get("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ policy_bundle = await async_client.policy_bundle.retrieve(
+ if_none_match="If-None-Match",
+ x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
+ )
+ assert policy_bundle.is_closed
+ assert await policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, AsyncBinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ async def test_raw_response_retrieve(self, async_client: AsyncKeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.get("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+
+ policy_bundle = await async_client.policy_bundle.with_raw_response.retrieve()
+
+ assert policy_bundle.is_closed is True
+ assert policy_bundle.http_request.headers.get("X-Stainless-Lang") == "python"
+ assert await policy_bundle.json() == {"foo": "bar"}
+ assert isinstance(policy_bundle, AsyncBinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ async def test_streaming_response_retrieve(self, async_client: AsyncKeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.get("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ async with async_client.policy_bundle.with_streaming_response.retrieve() as policy_bundle:
+ assert not policy_bundle.is_closed
+ assert policy_bundle.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ assert await policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, AsyncStreamedBinaryAPIResponse)
+
+ assert cast(Any, policy_bundle.is_closed) is True
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ async def test_method_update(self, async_client: AsyncKeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.put("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ policy_bundle = await async_client.policy_bundle.update(
+ body=b"Example data",
+ )
+ assert policy_bundle.is_closed
+ assert await policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, AsyncBinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ async def test_method_update_with_all_params(self, async_client: AsyncKeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.put("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ policy_bundle = await async_client.policy_bundle.update(
+ body=b"Example data",
+ if_match="If-Match",
+ x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
+ )
+ assert policy_bundle.is_closed
+ assert await policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, AsyncBinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ async def test_raw_response_update(self, async_client: AsyncKeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.put("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+
+ policy_bundle = await async_client.policy_bundle.with_raw_response.update(
+ body=b"Example data",
+ )
+
+ assert policy_bundle.is_closed is True
+ assert policy_bundle.http_request.headers.get("X-Stainless-Lang") == "python"
+ assert await policy_bundle.json() == {"foo": "bar"}
+ assert isinstance(policy_bundle, AsyncBinaryAPIResponse)
+
+ @parametrize
+ @pytest.mark.respx(base_url=base_url)
+ async def test_streaming_response_update(self, async_client: AsyncKeycardAPI, respx_mock: MockRouter) -> None:
+ respx_mock.put("/policy/bundle").mock(return_value=httpx.Response(200, json={"foo": "bar"}))
+ async with async_client.policy_bundle.with_streaming_response.update(
+ body=b"Example data",
+ ) as policy_bundle:
+ assert not policy_bundle.is_closed
+ assert policy_bundle.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ assert await policy_bundle.json() == {"foo": "bar"}
+ assert cast(Any, policy_bundle.is_closed) is True
+ assert isinstance(policy_bundle, AsyncStreamedBinaryAPIResponse)
+
+ assert cast(Any, policy_bundle.is_closed) is True
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_reset(self, async_client: AsyncKeycardAPI) -> None:
+ policy_bundle = await async_client.policy_bundle.reset()
+ assert policy_bundle is None
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_reset_with_all_params(self, async_client: AsyncKeycardAPI) -> None:
+ policy_bundle = await async_client.policy_bundle.reset(
+ x_client_request_id="182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e",
+ )
+ assert policy_bundle is None
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_raw_response_reset(self, async_client: AsyncKeycardAPI) -> None:
+ response = await async_client.policy_bundle.with_raw_response.reset()
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ policy_bundle = await response.parse()
+ assert policy_bundle is None
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_streaming_response_reset(self, async_client: AsyncKeycardAPI) -> None:
+ async with async_client.policy_bundle.with_streaming_response.reset() as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ policy_bundle = await response.parse()
+ assert policy_bundle is None
+
+ assert cast(Any, response.is_closed) is True
From d516a7f7b207aa4eed33d1a401721b9aa1ecd5d9 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 24 Jul 2026 15:14:39 +0000
Subject: [PATCH 20/21] fix(sdk): generate /policy/bundle body as raw binary,
not multipart
---
.stats.yml | 4 +-
api.md | 2 +-
src/keycardai_api/resources/policy_bundle.py | 38 +++++++++++++------
.../types/policy_bundle_update_params.py | 17 +--------
4 files changed, 31 insertions(+), 30 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 59a7ccf..b32e061 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 109
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-79558666a6b9db6f96e38787e23a61d167f02806b4874b2142e20eafeda79a01.yml
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/keycard/keycard-api-3c029b6d95f9ba550b5a983b3b021fa404cc2bd73d11495c0ae82065b4ba95ad.yml
openapi_spec_hash: 49c3651c217697ea8899b58c8f2bb046
-config_hash: 7bcd9f04550ca2b9d8d8272528e6fa37
+config_hash: 22d144a33ba2901c82cc1f387a83f9a7
diff --git a/api.md b/api.md
index 85da9f7..81bbd7d 100644
--- a/api.md
+++ b/api.md
@@ -436,5 +436,5 @@ Methods:
Methods:
- client.policy_bundle.retrieve() -> BinaryAPIResponse
-- client.policy_bundle.update(\*\*params) -> BinaryAPIResponse
+- client.policy_bundle.update(body, \*\*params) -> BinaryAPIResponse
- client.policy_bundle.reset() -> None
diff --git a/src/keycardai_api/resources/policy_bundle.py b/src/keycardai_api/resources/policy_bundle.py
index 14e0f28..e68008f 100644
--- a/src/keycardai_api/resources/policy_bundle.py
+++ b/src/keycardai_api/resources/policy_bundle.py
@@ -2,11 +2,25 @@
from __future__ import annotations
+import os
+
import httpx
-from ..types import policy_bundle_update_params
-from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, omit, not_given
-from .._utils import maybe_transform, strip_not_given, async_maybe_transform
+from .._files import read_file_content, async_read_file_content
+from .._types import (
+ Body,
+ Omit,
+ Query,
+ Headers,
+ NoneType,
+ NotGiven,
+ BinaryTypes,
+ FileContent,
+ AsyncBinaryTypes,
+ omit,
+ not_given,
+)
+from .._utils import strip_not_given
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
@@ -106,7 +120,7 @@ def retrieve(
timeout: Override the client-level default timeout for this request, in seconds
"""
- extra_headers = {"Accept": "application/vnd.keycard.policy-bundle.v1+tar+gzip", **(extra_headers or {})}
+ extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
extra_headers = {
**strip_not_given(
{
@@ -130,8 +144,8 @@ def retrieve(
def update(
self,
+ body: FileContent | BinaryTypes,
*,
- body: FileTypes,
if_match: str | Omit = omit,
x_client_request_id: str | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
@@ -180,7 +194,7 @@ def update(
timeout: Override the client-level default timeout for this request, in seconds
"""
- extra_headers = {"Accept": "application/vnd.keycard.policy-bundle.v1+tar+gzip", **(extra_headers or {})}
+ extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
extra_headers = {
**strip_not_given(
{
@@ -190,9 +204,10 @@ def update(
),
**(extra_headers or {}),
}
+ extra_headers = {"Content-Type": "application/octet-stream", **(extra_headers or {})}
return self._put(
"/policy/bundle",
- body=maybe_transform(body, policy_bundle_update_params.PolicyBundleUpdateParams),
+ content=read_file_content(body) if isinstance(body, os.PathLike) else body,
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
@@ -321,7 +336,7 @@ async def retrieve(
timeout: Override the client-level default timeout for this request, in seconds
"""
- extra_headers = {"Accept": "application/vnd.keycard.policy-bundle.v1+tar+gzip", **(extra_headers or {})}
+ extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
extra_headers = {
**strip_not_given(
{
@@ -345,8 +360,8 @@ async def retrieve(
async def update(
self,
+ body: FileContent | AsyncBinaryTypes,
*,
- body: FileTypes,
if_match: str | Omit = omit,
x_client_request_id: str | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
@@ -395,7 +410,7 @@ async def update(
timeout: Override the client-level default timeout for this request, in seconds
"""
- extra_headers = {"Accept": "application/vnd.keycard.policy-bundle.v1+tar+gzip", **(extra_headers or {})}
+ extra_headers = {"Accept": "application/octet-stream", **(extra_headers or {})}
extra_headers = {
**strip_not_given(
{
@@ -405,9 +420,10 @@ async def update(
),
**(extra_headers or {}),
}
+ extra_headers = {"Content-Type": "application/octet-stream", **(extra_headers or {})}
return await self._put(
"/policy/bundle",
- body=await async_maybe_transform(body, policy_bundle_update_params.PolicyBundleUpdateParams),
+ content=await async_read_file_content(body) if isinstance(body, os.PathLike) else body,
options=make_request_options(
extra_headers=extra_headers,
extra_query=extra_query,
diff --git a/src/keycardai_api/types/policy_bundle_update_params.py b/src/keycardai_api/types/policy_bundle_update_params.py
index 928c1cd..86dd8e2 100644
--- a/src/keycardai_api/types/policy_bundle_update_params.py
+++ b/src/keycardai_api/types/policy_bundle_update_params.py
@@ -2,29 +2,14 @@
from __future__ import annotations
-from typing_extensions import Required, Annotated, TypedDict
+from typing_extensions import Annotated, TypedDict
-from .._types import FileTypes
from .._utils import PropertyInfo
__all__ = ["PolicyBundleUpdateParams"]
class PolicyBundleUpdateParams(TypedDict, total=False):
- body: Required[FileTypes]
- """tar+gzip Policy Bundle archive.
-
- `manifest.json` is **required** (see `PolicyBundleManifest`);
- `schema.cedarschema` is **optional and ignored** — the server validates against
- its attested schema for `manifest.schema.version`. The manifest's `policies[]`
- list is authoritative for the resulting set: each entry must have a matching
- `policies/.cedar` (or, for a `new_policy` entry,
- `policies/.cedar`) member, and a member with no manifest entry is
- dropped. Only the `sha` fields are advisory and recomputed server-side.
- Duplicate or unrecognized entries are rejected with `bundle_invalid`. See the
- **PolicyBundle** tag for the layout.
- """
-
if_match: Annotated[str, PropertyInfo(alias="If-Match")]
x_client_request_id: Annotated[str, PropertyInfo(alias="X-Client-Request-ID")]
From 9927bcfbf3b21ae81ed2a6a4d8b60c935f2272f7 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 24 Jul 2026 15:15:02 +0000
Subject: [PATCH 21/21] release: 0.17.0
---
.release-please-manifest.json | 2 +-
CHANGELOG.md | 28 ++++++++++++++++++++++++++++
pyproject.toml | 2 +-
src/keycardai_api/_version.py | 2 +-
4 files changed, 31 insertions(+), 3 deletions(-)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index b4e9013..6db19b9 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.16.0"
+ ".": "0.17.0"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cefd0e4..f749575 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,33 @@
# Changelog
+## 0.17.0 (2026-07-24)
+
+Full Changelog: [v0.16.0...v0.17.0](https://github.com/keycardai/keycard-python/compare/v0.16.0...v0.17.0)
+
+### Features
+
+* **ACC-709:** application assignees + expose role assignments ([6a76dbf](https://github.com/keycardai/keycard-python/commit/6a76dbf04cbd9d16c1054a262a7a42a01668f035))
+* **applications:** allow unified-gateway and mcp-server traits (ECO-128) ([414ac68](https://github.com/keycardai/keycard-python/commit/414ac68a77bb5c54837b7d4146c2e8b8735f6370))
+* filter users by identifier in management list ([147af02](https://github.com/keycardai/keycard-python/commit/147af020e8e8c31678928137fb85386b54dd4ec6))
+* **ID-365:** support additional SSO provider configuration options ([526b229](https://github.com/keycardai/keycard-python/commit/526b22958acc91a11569ba3f8453a004eb03f6f5))
+* **sdk:** generate a client for the existing /policy/bundle endpoint ([518c473](https://github.com/keycardai/keycard-python/commit/518c473dcb23f0d68e849838a070187addf422a9))
+* **stlc:** configurable CI runner and private-production-repo support in workflow templates ([1c22f7e](https://github.com/keycardai/keycard-python/commit/1c22f7ea4ae904c4864a794dc4aaa46afa7d7e0e))
+
+
+### Bug Fixes
+
+* **ACC-613:** preserve source order of policies in draft/convert cedar_json ([6d27d21](https://github.com/keycardai/keycard-python/commit/6d27d213ac66fb9f1890409a380924c3edb44b99))
+* **ci:** resolve Stainless error diagnostics and enforce fail_on: error ([8a01934](https://github.com/keycardai/keycard-python/commit/8a01934b6ceb8636db998f0498d86ccb810cf639))
+* exact-match identifier filter on resources management list ([846f24e](https://github.com/keycardai/keycard-python/commit/846f24efdb2492e4ea0741167ebc7153af5d0d1e))
+* **internal:** resolve build failures ([15cb317](https://github.com/keycardai/keycard-python/commit/15cb317c1d1ea8462b30bff1411e52a554df0fb0))
+* **sdk:** generate /policy/bundle body as raw binary, not multipart ([d516a7f](https://github.com/keycardai/keycard-python/commit/d516a7f7b207aa4eed33d1a401721b9aa1ecd5d9))
+
+
+### Chores
+
+* de-dup and align types across API specs ([bf16103](https://github.com/keycardai/keycard-python/commit/bf161039858f079eabd6abf9102fa439633b9bc9))
+* Fixes found during Terraform work ([8389f30](https://github.com/keycardai/keycard-python/commit/8389f304ffae7c4bbe512de541a6896469be273f))
+
## 0.16.0 (2026-06-30)
Full Changelog: [v0.15.0...v0.16.0](https://github.com/keycardai/keycard-python/compare/v0.15.0...v0.16.0)
diff --git a/pyproject.toml b/pyproject.toml
index 0f72996..a2de0a3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "keycardai_api"
-version = "0.16.0"
+version = "0.17.0"
description = "The official Python library for the keycard-api API"
dynamic = ["readme"]
license = "Apache-2.0"
diff --git a/src/keycardai_api/_version.py b/src/keycardai_api/_version.py
index ff81ceb..cb30031 100644
--- a/src/keycardai_api/_version.py
+++ b/src/keycardai_api/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
__title__ = "keycardai_api"
-__version__ = "0.16.0" # x-release-please-version
+__version__ = "0.17.0" # x-release-please-version