From 3d364304e81d03eadd11a0ee0373784ff0b3b298 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:28:52 +0300 Subject: [PATCH 01/32] Add the generated PDP data refresh models PDPDataRefreshRequest and PDPDataRefreshResponse are copied unchanged from what scripts/generate_models.sh generates from today's API schema. The rest of models.py is not regenerated, so the diff holds only these two classes. Their entries in the schema drift allowlist would now be stale, so they are removed. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/schema_drift_allowlist.json | 12 -------- permit/api/models.py | 32 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.github/scripts/schema_drift_allowlist.json b/.github/scripts/schema_drift_allowlist.json index 956a7c5c..27cf3cbf 100644 --- a/.github/scripts/schema_drift_allowlist.json +++ b/.github/scripts/schema_drift_allowlist.json @@ -42,18 +42,6 @@ "spec": "model", "reason": "In the API schema but not in models.py (generated 2025-09-17); no SDK method uses it." }, - { - "id": "class_added:PDPDataRefreshRequest", - "sdk": "(absent)", - "spec": "model", - "reason": "In the API schema but not in models.py (generated 2025-09-17); no SDK method uses it." - }, - { - "id": "class_added:PDPDataRefreshResponse", - "sdk": "(absent)", - "spec": "model", - "reason": "In the API schema but not in models.py (generated 2025-09-17); no SDK method uses it." - }, { "id": "class_added:PaginatedResultAccessRequestList", "sdk": "(absent)", diff --git a/permit/api/models.py b/permit/api/models.py index 3a28ca40..59e00d4c 100644 --- a/permit/api/models.py +++ b/permit/api/models.py @@ -1360,6 +1360,38 @@ class Config: env_id: UUID = Field(..., title='Env Id') +class PDPDataRefreshRequest(BaseModel): + class Config: + extra = Extra.allow + + reason: Optional[constr(max_length=512)] = Field( + default=None, + description='Optional human-readable reason for the refresh, propagated to the OPAL DataUpdate and visible in PDP/OPAL logs.', + title='Reason', + ) + shard_id: Optional[conint(ge=0)] = Field( + default=None, + description="For sharded PDPs, target only this shard. When omitted, the refresh targets the PDP's main topic (all shards).", + title='Shard Id', + ) + + +class PDPDataRefreshResponse(BaseModel): + class Config: + extra = Extra.allow + + update_id: UUID = Field( + ..., + description='The id of the generated OPAL DataUpdate. It is injected as the X-Permit-Update-Id header and surfaced in PDP/OPAL logs for correlation. Because the refresh is a full-data reload (dst_path=""), the PDP confirms it by advancing PDPInstance.current_data_date once the new bundle is fetched and saved (most_recent_data_fetch_id is only set for scoped/delta updates).', + title='Update Id', + ) + pdp_ids: List[UUID] = Field( + ..., + description='The ids of the PDP configurations that were targeted by this refresh.', + title='Pdp Ids', + ) + + class PDPShardMigration(BaseModel): class Config: extra = Extra.allow From 0a3d5ee5b814d25d1b83487968e13b536ab90d67 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:29:57 +0300 Subject: [PATCH 02/32] Factor the caller-attributed deprecation warning out of deprecated() A deprecated argument has to warn from inside the method, only when the argument is passed, so it needs the same attribution to the awaiting line or the blocking call site that deprecated() applies to a whole coroutine. The logic moves to one private helper that both use. Co-Authored-By: Claude Opus 5.5 --- permit/utils/deprecation.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/permit/utils/deprecation.py b/permit/utils/deprecation.py index 15f47310..7062fbca 100644 --- a/permit/utils/deprecation.py +++ b/permit/utils/deprecation.py @@ -9,6 +9,24 @@ _F = TypeVar("_F", bound=Callable[..., Any]) +def _warn_deprecated(message: str) -> None: + """Issue a `DeprecationWarning` attributed to the line that called the caller's caller. + + Call it from a coroutine function's body, so the warning names the line that awaited + that coroutine function, or the line that made the blocking call that runs it. + + Args: + message: The warning text, typically naming the replacement. + """ + call_site = _blocking_call_site.get() + if call_site is None: + warn(message, DeprecationWarning, stacklevel=3) + else: + # The blocking client runs the coroutine under asyncio, so stacklevel would + # blame asyncio's frames rather than the line that called the blocking method. + call_site.warn(message, DeprecationWarning) + + def deprecated(message: str) -> Callable[[_F], _F]: """Mark a function or coroutine function as deprecated. @@ -29,13 +47,7 @@ def wrapper(*args: Any, **kwargs: Any) -> object: @wraps(func) async def async_wrapper(*args: Any, **kwargs: Any) -> object: - call_site = _blocking_call_site.get() - if call_site is None: - warn(message, DeprecationWarning, stacklevel=2) - else: - # The blocking client runs this coroutine under asyncio, so stacklevel would - # blame asyncio's frames rather than the line that called the blocking method. - call_site.warn(message, DeprecationWarning) + _warn_deprecated(message) return await func(*args, **kwargs) # Either wrapper takes and returns what func does, so callers keep func's type. From 08c7e1a6d25cb4fb9550470e3e40910f00d6b904 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:37:04 +0300 Subject: [PATCH 03/32] Sort the stub's imported names case-insensitively, as ruff does ruff's isort compares the names of a from-import case-insensitively within constants, classes and the rest, so PaginatedResultUserRead comes before PDPDataRefreshResponse. The stub generator compared them case-sensitively, which no name in the stub had exposed until now, and would have written an import block ruff rejects. The generated stub is unchanged. Co-Authored-By: Claude Opus 5.5 --- scripts/generate_sync_stubs.py | 15 ++++++++++----- tests/test_typing_surface.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/scripts/generate_sync_stubs.py b/scripts/generate_sync_stubs.py index 6396fed3..b64c4987 100644 --- a/scripts/generate_sync_stubs.py +++ b/scripts/generate_sync_stubs.py @@ -331,13 +331,18 @@ def resolve(module_name: str, tree: ast.Module, name: str) -> tuple[str, str | N raise StubError(msg) -def member_sort_key(name: str) -> tuple[int, str]: - """The order isort's order-by-type uses: constants, then classes, then everything else.""" +def member_sort_key(name: str) -> tuple[int, str, str]: + """The order isort's order-by-type uses: constants, then classes, then everything else. + + Within each group ruff compares names case-insensitively, so ``PaginatedResultUserRead`` + comes before ``PDPDataRefreshResponse``; the name as written breaks a tie. + """ + folded = name.lower() if name.isupper() and len(name) > 1: - return 0, name + return 0, folded, name if name[0].isupper(): - return 1, name - return 2, name + return 1, folded, name + return 2, folded, name def import_block(imports: dict[str, set[str | None]]) -> str: diff --git a/tests/test_typing_surface.py b/tests/test_typing_surface.py index 82c22343..6b077f99 100644 --- a/tests/test_typing_surface.py +++ b/tests/test_typing_surface.py @@ -72,6 +72,27 @@ def test_sync_stub_matches_the_async_classes() -> None: assert not diff, f"permit/_sync_types.pyi is out of date. Run `{regenerate}`.\n{diff}" +def test_stub_imports_order_names_as_ruff_does() -> None: + """Constants, then classes, then the rest, each group compared case-insensitively.""" + generator = load_stub_generator() + names: set[str | None] = { + "Pdpx", + "a_var", + "PDPDataRefreshResponse", + "CONST", + "Ab", + "APIa", + "PaginatedResultUserRead", + } + + block = generator.import_block({"models": names}) + + assert block == ( + "from models import CONST, Ab, APIa, PaginatedResultUserRead, PDPDataRefreshResponse, " + "Pdpx, a_var" + ) + + def runtime_sync_classes() -> dict[str, type]: """Every class declared with ``metaclass=SyncClass``, keyed by qualified name.""" found: dict[str, type] = {} From 0b6b21d3ba90d0add823d9b10675b11940c235de Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:45:45 +0300 Subject: [PATCH 04/32] Add list_detailed() to role assignments, resource instances and tuples Each calls the API's /detailed route next to the list route list() calls, with the query list() sends for the same filters, taken as keyword arguments, and returns the paginated detailed read model. Both methods of a module now build that query with one private helper, which holds list()'s code unchanged. Role assignments carry the role, user, tenant and resource instance objects; resource instances their relationship tuples; tuples their subject, relation, object and tenant details. The detailed resource instance search matches a key exactly. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 ++ permit/_sync_types.pyi | 125 ++++++++ permit/api/relationship_tuples.py | 100 +++++- permit/api/resource_instances.py | 94 +++++- permit/api/role_assignments.py | 130 ++++++-- tests/test_detailed_lists_offline.py | 446 +++++++++++++++++++++++++++ tests/type_check/consumer.py | 29 ++ 7 files changed, 907 insertions(+), 40 deletions(-) create mode 100644 tests/test_detailed_lists_offline.py diff --git a/README.md b/README.md index 2f9c9c70..ffa505d8 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,29 @@ without a role, such as `create_user()` creates, is not listed. Only the contain this query: the cloud PDP answers 404, which the SDK raises as a `PermitConnectionError`. Both methods are on the blocking client too. +## Detailed lists + +`list_detailed()` on `permit.api.role_assignments`, `permit.api.resource_instances` and +`permit.api.relationship_tuples` takes the filters of that API's `list()`, as keyword +arguments, and returns one page of results with the total count: + +```py +page = await permit.api.role_assignments.list_detailed(user_key="alice", tenant_key="default") +for assignment in page.data: + print(assignment.role.name, assignment.tenant.name, assignment.user.email) +``` + +- A role assignment comes with its role, user and tenant, and the resource instance of a + resource role, as objects with their names and attributes where `list()` gives their keys. +- A resource instance comes with `relationships`, the relationship tuples whose subject or + object it is. Its `search_key` matches an instance key or id exactly, where `list()` also + matches part of a key. +- A relationship tuple comes with `subject_details`, `relation_details`, `object_details` and + `tenant_details`, which `list()` leaves empty. + +They need the API key `list()` needs: an environment-level key, or a broader key with the +SDK's API context set to the environment. The blocking client has the same methods. + ## Type checking The package ships a `py.typed` marker (PEP 561), so mypy, pyright and IDEs check your diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index 242426ed..da468165 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -36,6 +36,9 @@ from permit.api.models import ( PaginatedResultElementsUserInviteRead, PaginatedResultGroupReadSchema, PaginatedResultRelationRead, + PaginatedResultRelationshipTupleDetailedRead, + PaginatedResultResourceInstanceDetailedRead, + PaginatedResultRoleAssignmentDetailedRead, PaginatedResultUserRead, PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings, ProjectCreate, @@ -917,6 +920,47 @@ class SyncRelationshipTuplesApi(BasePermitApi): Returns: an array of relationship tuples. + Raises: + PermitApiError: If the API returns an error HTTP status code. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def list_detailed( + self, + *, + page: int = 1, + per_page: int = 100, + subject_key: str | None = None, + relation_key: str | None = None, + object_key: str | None = None, + tenant_key: str | None = None, + ) -> PaginatedResultRelationshipTupleDetailedRead: + """Lists relationship tuples with their subject, relation, object and tenant. + + Takes the same filters as ``list()``, as keyword arguments. Each tuple carries what + ``list()`` returns, and also fills in the fields ``list()`` leaves empty: + ``subject_details`` and ``object_details`` (each resource instance's key, resource + type, tenant and attributes), ``relation_details`` (the relation's key, name and + description) and ``tenant_details`` (the tenant's key, name, description and + attributes). + + Needs an environment-level API key, or a project- or organization-level key with the + SDK's API context set to the environment. + + Args: + page: The page number to fetch, starting at 1 (default: 1). + per_page: How many items to fetch per page, at most 100 (default: 100). + subject_key: if specified, only relationship tuples with this subject will be + fetched: `resource_type:instance_key` or the resource instance id. + relation_key: if specified, only relationship tuples with this relation will be + fetched. + object_key: if specified, only relationship tuples with this object will be + fetched: `resource_type:instance_key` or the resource instance id. + tenant_key: if specified, only relationship tuples in this tenant will be fetched. + + Returns: + One page of detailed relationship tuples, with the total count across all pages. + Raises: PermitApiError: If the API returns an error HTTP status code. PermitContextError: If the configured ApiContext does not match the required endpoint @@ -1377,6 +1421,41 @@ class SyncResourceInstancesApi(BasePermitApi): Returns: an array of resource instances. + Raises: + PermitApiError: If the API returns an error HTTP status code. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def list_detailed( + self, + *, + page: int = 1, + per_page: int = 100, + tenant_key: str | None = None, + resource_key: str | None = None, + search_key: str | None = None, + ) -> PaginatedResultResourceInstanceDetailedRead: + """Lists resource instances, each with the relationship tuples it is part of. + + Takes the filters of ``list()``, as keyword arguments, and replaces + ``list(detailed_key=True)``. Each instance carries what ``list()`` returns, and + ``relationships`` lists the relationship tuples whose subject or object it is, each + as its subject, relation and object. ``search_key`` matches an instance key or id + exactly, where ``list()`` also matches part of a key. + + Needs an environment-level API key, or a project- or organization-level key with the + SDK's API context set to the environment. + + Args: + page: The page number to fetch, starting at 1 (default: 1). + per_page: How many items to fetch per page, at most 100 (default: 100). + tenant_key: Only return instances that belong to this tenant (its key or id). + resource_key: Only return instances of this resource type (its key or id). + search_key: Only return the instance whose key or id is exactly this. + + Returns: + One page of detailed resource instances, with the total count across all pages. + Raises: PermitApiError: If the API returns an error HTTP status code. PermitContextError: If the configured ApiContext does not match the required endpoint @@ -1981,6 +2060,52 @@ class SyncRoleAssignmentsApi(BasePermitApi): Returns: an array of role assignments. + Raises: + PermitApiError: If the API returns an error HTTP status code. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + def list_detailed( + self, + *, + user_key: str | builtins.list[str] | None = None, + role_key: str | builtins.list[str] | None = None, + tenant_key: str | builtins.list[str] | None = None, + resource_key: str | None = None, + resource_instance_key: str | None = None, + page: int = 1, + per_page: int = 100, + ) -> PaginatedResultRoleAssignmentDetailedRead: + """Lists role assignments with the role, user, tenant and resource instance they name. + + Takes the same filters as ``list()``, as keyword arguments. Where ``list()`` returns + the keys of the role, user and tenant of each assignment, this returns them as + objects: the role's key, name and permissions, the user's key, email, names and + attributes, the tenant's key, name and attributes, and, for a resource role, the + resource instance's key, resource type and attributes. + + Needs an environment-level API key, or a project- or organization-level key with the + SDK's API context set to the environment. + + Args: + user_key: if specified, only roles granted to this user, or to any of these + users, will be fetched. + role_key: if specified, only assignments of this role, or of any of these roles, + will be fetched. + tenant_key: if specified, only roles granted within this tenant, or within any of + these tenants, will be fetched. With ``resource_instance_key``, pass a single + tenant: the API resolves the instance in the last tenant given. + resource_key: (for resource roles) if specified, only roles granted on instances + of this resource type will be fetched. + resource_instance_key: (for resource roles) if specified, only roles granted with + this instance as the object will be fetched. The instance identity, either + `resource_type:instance_key` (like Repository:react) or the instance uuid. + page: The page number to fetch, starting at 1 (default: 1). + per_page: How many items to fetch per page, at most 1000 (default: 100). + + Returns: + One page of detailed role assignments, with the total count across all pages. + Raises: PermitApiError: If the API returns an error HTTP status code. PermitContextError: If the configured ApiContext does not match the required endpoint diff --git a/permit/api/relationship_tuples.py b/permit/api/relationship_tuples.py index c796b69c..74784311 100644 --- a/permit/api/relationship_tuples.py +++ b/permit/api/relationship_tuples.py @@ -17,6 +17,7 @@ ) from permit.api.context import ApiContextLevel, ApiKeyAccessLevel from permit.api.models import ( + PaginatedResultRelationshipTupleDetailedRead, RelationshipTupleCreate, RelationshipTupleCreateBulkOperation, RelationshipTupleCreateBulkOperationResult, @@ -28,6 +29,29 @@ from permit.utils.model_input import ModelInput, ModelListInput +def _filter_params( + *, + page: int, + per_page: int, + subject_key: str | None, + relation_key: str | None, + object_key: str | None, + tenant_key: str | None, +) -> list[tuple[str, str | int]]: + """The query of a relationship tuples list: pagination, then the filters given.""" + params = list(pagination_params(page, per_page).items()) + + if subject_key is not None: + params.append(("subject", subject_key)) + if relation_key is not None: + params.append(("relation", relation_key)) + if object_key is not None: + params.append(("object", object_key)) + if tenant_key is not None: + params.append(("tenant", tenant_key)) + return params + + class RelationshipTuplesApi(BasePermitApi): """Manage relationship tuples between resource instances (ReBAC).""" @@ -69,16 +93,14 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - params = list(pagination_params(page, per_page).items()) - - if subject_key is not None: - params.append(("subject", subject_key)) - if relation_key is not None: - params.append(("relation", relation_key)) - if object_key is not None: - params.append(("object", object_key)) - if tenant_key is not None: - params.append(("tenant", tenant_key)) + params = _filter_params( + page=page, + per_page=per_page, + subject_key=subject_key, + relation_key=relation_key, + object_key=object_key, + tenant_key=tenant_key, + ) return await self.__relationship_tuples.get( "", @@ -86,6 +108,64 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi params=params, ) + @validate_arguments + async def list_detailed( + self, + *, + page: int = 1, + per_page: int = 100, + subject_key: str | None = None, + relation_key: str | None = None, + object_key: str | None = None, + tenant_key: str | None = None, + ) -> PaginatedResultRelationshipTupleDetailedRead: + """Lists relationship tuples with their subject, relation, object and tenant. + + Takes the same filters as ``list()``, as keyword arguments. Each tuple carries what + ``list()`` returns, and also fills in the fields ``list()`` leaves empty: + ``subject_details`` and ``object_details`` (each resource instance's key, resource + type, tenant and attributes), ``relation_details`` (the relation's key, name and + description) and ``tenant_details`` (the tenant's key, name, description and + attributes). + + Needs an environment-level API key, or a project- or organization-level key with the + SDK's API context set to the environment. + + Args: + page: The page number to fetch, starting at 1 (default: 1). + per_page: How many items to fetch per page, at most 100 (default: 100). + subject_key: if specified, only relationship tuples with this subject will be + fetched: `resource_type:instance_key` or the resource instance id. + relation_key: if specified, only relationship tuples with this relation will be + fetched. + object_key: if specified, only relationship tuples with this object will be + fetched: `resource_type:instance_key` or the resource instance id. + tenant_key: if specified, only relationship tuples in this tenant will be fetched. + + Returns: + One page of detailed relationship tuples, with the total count across all pages. + + Raises: + PermitApiError: If the API returns an error HTTP status code. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) + await self._ensure_context(ApiContextLevel.ENVIRONMENT) + params = _filter_params( + page=page, + per_page=per_page, + subject_key=subject_key, + relation_key=relation_key, + object_key=object_key, + tenant_key=tenant_key, + ) + return await self.__relationship_tuples.get( + "/detailed", + model=PaginatedResultRelationshipTupleDetailedRead, + params=params, + ) + @validate_arguments async def create( self, tuple_data: ModelInput[RelationshipTupleCreate] diff --git a/permit/api/resource_instances.py b/permit/api/resource_instances.py index d0b5bda2..52a04f3b 100644 --- a/permit/api/resource_instances.py +++ b/permit/api/resource_instances.py @@ -15,6 +15,7 @@ from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params from permit.api.context import ApiContextLevel, ApiKeyAccessLevel from permit.api.models import ( + PaginatedResultResourceInstanceDetailedRead, ResourceInstanceCreate, ResourceInstanceCreateBulkOperation, ResourceInstanceCreateBulkOperationResult, @@ -26,6 +27,29 @@ from permit.utils.model_input import ModelInput, ModelListInput +def _filter_params( + *, + page: int, + per_page: int, + tenant_key: str | None, + resource_key: str | None, + detailed_key: bool | None, + search_key: str | None, +) -> dict[str, str | int]: + """The query of a resource instances list: pagination, then the filters given.""" + params = pagination_params(page, per_page) + if tenant_key is not None: + params.update(tenant=tenant_key) + if resource_key is not None: + params.update(resource=resource_key) + if detailed_key is not None: + # yarl rejects bool query values, and the API parses these as booleans + params.update(detailed="true" if detailed_key else "false") + if search_key is not None: + params.update(search=search_key) + return params + + class ResourceInstancesApi(BasePermitApi): """Manage resource instances.""" @@ -75,16 +99,14 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - params = pagination_params(page, per_page) - if tenant_key is not None: - params.update(tenant=tenant_key) - if resource_key is not None: - params.update(resource=resource_key) - if detailed_key is not None: - # yarl rejects bool query values, and the API parses these as booleans - params.update(detailed="true" if detailed_key else "false") - if search_key is not None: - params.update(search=search_key) + params = _filter_params( + page=page, + per_page=per_page, + tenant_key=tenant_key, + resource_key=resource_key, + detailed_key=detailed_key, + search_key=search_key, + ) return await self.__resource_instances.get( "", @@ -92,6 +114,58 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi params=params, ) + @validate_arguments + async def list_detailed( + self, + *, + page: int = 1, + per_page: int = 100, + tenant_key: str | None = None, + resource_key: str | None = None, + search_key: str | None = None, + ) -> PaginatedResultResourceInstanceDetailedRead: + """Lists resource instances, each with the relationship tuples it is part of. + + Takes the filters of ``list()``, as keyword arguments, and replaces + ``list(detailed_key=True)``. Each instance carries what ``list()`` returns, and + ``relationships`` lists the relationship tuples whose subject or object it is, each + as its subject, relation and object. ``search_key`` matches an instance key or id + exactly, where ``list()`` also matches part of a key. + + Needs an environment-level API key, or a project- or organization-level key with the + SDK's API context set to the environment. + + Args: + page: The page number to fetch, starting at 1 (default: 1). + per_page: How many items to fetch per page, at most 100 (default: 100). + tenant_key: Only return instances that belong to this tenant (its key or id). + resource_key: Only return instances of this resource type (its key or id). + search_key: Only return the instance whose key or id is exactly this. + + Returns: + One page of detailed resource instances, with the total count across all pages. + + Raises: + PermitApiError: If the API returns an error HTTP status code. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) + await self._ensure_context(ApiContextLevel.ENVIRONMENT) + params = _filter_params( + page=page, + per_page=per_page, + tenant_key=tenant_key, + resource_key=resource_key, + detailed_key=None, + search_key=search_key, + ) + return await self.__resource_instances.get( + "/detailed", + model=PaginatedResultResourceInstanceDetailedRead, + params=params, + ) + async def _get(self, instance_key: str) -> ResourceInstanceRead: return await self.__resource_instances.get(f"/{instance_key}", model=ResourceInstanceRead) diff --git a/permit/api/role_assignments.py b/permit/api/role_assignments.py index 372cb989..42c06d55 100644 --- a/permit/api/role_assignments.py +++ b/permit/api/role_assignments.py @@ -10,6 +10,8 @@ else: from pydantic.v1 import validate_arguments +import builtins + from permit.api.base import ( BasePermitApi, SimpleHttpClient, @@ -19,6 +21,7 @@ from permit.api.models import ( BulkRoleAssignmentReport, BulkRoleUnAssignmentReport, + PaginatedResultRoleAssignmentDetailedRead, RoleAssignmentCreate, RoleAssignmentRead, RoleAssignmentRemove, @@ -26,6 +29,40 @@ from permit.utils.model_input import ModelInput, ModelListInput +def _filter_params( + *, + user_key: str | list[str] | None, + role_key: str | list[str] | None, + tenant_key: str | list[str] | None, + resource_key: str | None, + resource_instance_key: str | None, + page: int, + per_page: int, +) -> list[tuple[str, str | int]]: + """The query of a role assignments list: pagination, then one entry per filter value.""" + params = list(pagination_params(page, per_page).items()) + if user_key is not None: + if isinstance(user_key, list): + params.extend(("user", user) for user in user_key) + else: + params.append(("user", user_key)) + if role_key is not None: + if isinstance(role_key, list): + params.extend(("role", role) for role in role_key) + else: + params.append(("role", role_key)) + if tenant_key is not None: + if isinstance(tenant_key, list): + params.extend(("tenant", tenant) for tenant in tenant_key) + else: + params.append(("tenant", tenant_key)) + if resource_key is not None: + params.append(("resource", resource_key)) + if resource_instance_key is not None: + params.append(("resource_instance", resource_instance_key)) + return params + + class RoleAssignmentsApi(BasePermitApi): """Assign roles to users and list or remove role assignments.""" @@ -74,32 +111,85 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - params = list(pagination_params(page, per_page).items()) - if user_key is not None: - if isinstance(user_key, list): - params.extend(("user", user) for user in user_key) - else: - params.append(("user", user_key)) - if role_key is not None: - if isinstance(role_key, list): - params.extend(("role", role) for role in role_key) - else: - params.append(("role", role_key)) - if tenant_key is not None: - if isinstance(tenant_key, list): - params.extend(("tenant", tenant) for tenant in tenant_key) - else: - params.append(("tenant", tenant_key)) - if resource_key is not None: - params.append(("resource", resource_key)) - if resource_instance_key is not None: - params.append(("resource_instance", resource_instance_key)) + params = _filter_params( + user_key=user_key, + role_key=role_key, + tenant_key=tenant_key, + resource_key=resource_key, + resource_instance_key=resource_instance_key, + page=page, + per_page=per_page, + ) return await self.__role_assignments.get( "", model=list[RoleAssignmentRead], params=params, ) + @validate_arguments + async def list_detailed( + self, + *, + user_key: str | builtins.list[str] | None = None, + role_key: str | builtins.list[str] | None = None, + tenant_key: str | builtins.list[str] | None = None, + resource_key: str | None = None, + resource_instance_key: str | None = None, + page: int = 1, + per_page: int = 100, + ) -> PaginatedResultRoleAssignmentDetailedRead: + """Lists role assignments with the role, user, tenant and resource instance they name. + + Takes the same filters as ``list()``, as keyword arguments. Where ``list()`` returns + the keys of the role, user and tenant of each assignment, this returns them as + objects: the role's key, name and permissions, the user's key, email, names and + attributes, the tenant's key, name and attributes, and, for a resource role, the + resource instance's key, resource type and attributes. + + Needs an environment-level API key, or a project- or organization-level key with the + SDK's API context set to the environment. + + Args: + user_key: if specified, only roles granted to this user, or to any of these + users, will be fetched. + role_key: if specified, only assignments of this role, or of any of these roles, + will be fetched. + tenant_key: if specified, only roles granted within this tenant, or within any of + these tenants, will be fetched. With ``resource_instance_key``, pass a single + tenant: the API resolves the instance in the last tenant given. + resource_key: (for resource roles) if specified, only roles granted on instances + of this resource type will be fetched. + resource_instance_key: (for resource roles) if specified, only roles granted with + this instance as the object will be fetched. The instance identity, either + `resource_type:instance_key` (like Repository:react) or the instance uuid. + page: The page number to fetch, starting at 1 (default: 1). + per_page: How many items to fetch per page, at most 1000 (default: 100). + + Returns: + One page of detailed role assignments, with the total count across all pages. + + Raises: + PermitApiError: If the API returns an error HTTP status code. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) + await self._ensure_context(ApiContextLevel.ENVIRONMENT) + params = _filter_params( + user_key=user_key, + role_key=role_key, + tenant_key=tenant_key, + resource_key=resource_key, + resource_instance_key=resource_instance_key, + page=page, + per_page=per_page, + ) + return await self.__role_assignments.get( + "/detailed", + model=PaginatedResultRoleAssignmentDetailedRead, + params=params, + ) + @validate_arguments async def assign(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssignmentRead: """Assigns a role to a user in the scope of a given tenant. diff --git a/tests/test_detailed_lists_offline.py b/tests/test_detailed_lists_offline.py new file mode 100644 index 00000000..c85ac808 --- /dev/null +++ b/tests/test_detailed_lists_offline.py @@ -0,0 +1,446 @@ +"""Offline tests for the detailed lists (PER-16337). + +``list_detailed()`` on ``role_assignments``, ``resource_instances`` and +``relationship_tuples`` is called through the async and the blocking client. The tests +check the request it puts on the wire (method, path, query string, headers and body) and +what the response parses into. For the same filters it must send exactly the query its +module's ``list()`` sends, to the ``/detailed`` route next to it. + +Every request is served by a local ``pytest_httpserver`` and the API context is +pre-populated, so no API key and no ``/v2/api-key/scope`` lookup are needed. +""" + +import asyncio +import inspect +from operator import attrgetter +from typing import Any, NamedTuple + +import pytest +from pydantic.v1 import BaseModel +from pytest_httpserver import HTTPServer +from werkzeug import Request + +from permit import Permit +from permit.api.models import ( + PaginatedResultRelationshipTupleDetailedRead, + PaginatedResultResourceInstanceDetailedRead, + PaginatedResultRoleAssignmentDetailedRead, + RelationshipTupleBlockRead, + RelationshipTupleDetailedRead, + ResourceInstanceBlockRead, + ResourceInstanceDetailedRead, + RoleAssignmentDetailedRead, + RoleAssignmentResourceInstance, + RoleAssignmentUser, + StrippedRelationBlockRead, + TenantBlockRead, +) +from permit.config import PermitConfig +from permit.exceptions import PermitApiError, PermitContextError, PermitNotFoundError +from permit.sync import Permit as SyncPermit +from tests.utils import FACTS, ORG, PROJECT, Call, call, sent + +FLAVOURS = ["async", "sync"] + +# The headers the SDK sets. The wait-for-sync ones are listed so that sending one shows. +HEADERS = ("Authorization", "Content-Type", "X-Wait-Timeout", "X-Timeout-Policy") +JSON_HEADERS: dict[str, str | None] = { + "Authorization": "Bearer test-token", + "Content-Type": "application/json", + "X-Wait-Timeout": None, + "X-Timeout-Policy": None, +} + +NOW = "2024-01-01T00:00:00+00:00" +SCOPE = { + "organization_id": "00000000-0000-4000-8000-000000000001", + "project_id": "00000000-0000-4000-8000-000000000002", + "environment_id": "00000000-0000-4000-8000-000000000003", +} +TENANT_ID = "00000000-0000-4000-8000-000000000004" + +ROLE_ASSIGNMENT_DETAILED = { + "id": "00000000-0000-4000-8000-000000000010", + "role": { + "id": "00000000-0000-4000-8000-000000000011", + "key": "editor", + "name": "Editor", + "permissions": ["document:read", "document:edit"], + }, + "user": { + "id": "00000000-0000-4000-8000-000000000012", + "key": "alice", + "email": "alice@example.com", + "first_name": "Alice", + "last_name": "Smith", + "attributes": {"dept": "eng"}, + }, + "tenant": {"id": TENANT_ID, "key": "t1", "name": "T1", "attributes": {"tier": "gold"}}, + "resource_instance": { + "id": "00000000-0000-4000-8000-000000000013", + "key": "readme", + "resource": "document", + "attributes": {"public": False}, + }, + **SCOPE, + "created_at": NOW, +} +RESOURCE_INSTANCE_DETAILED = { + "key": "readme", + "tenant": "t1", + "resource": "document", + "id": "00000000-0000-4000-8000-000000000020", + **SCOPE, + "created_at": NOW, + "updated_at": NOW, + "resource_id": "00000000-0000-4000-8000-000000000021", + "tenant_id": TENANT_ID, + "attributes": {"public": False}, + "relationships": [ + {"subject": "folder:docs", "relation": "parent", "object": "document:readme"} + ], +} +RELATIONSHIP_TUPLE_DETAILED = { + "subject": "folder:docs", + "relation": "parent", + "object": "document:readme", + "id": "00000000-0000-4000-8000-000000000030", + "tenant": "t1", + "subject_id": "00000000-0000-4000-8000-000000000031", + "relation_id": "00000000-0000-4000-8000-000000000032", + "object_id": "00000000-0000-4000-8000-000000000033", + "tenant_id": TENANT_ID, + **SCOPE, + "created_at": NOW, + "updated_at": NOW, + "subject_details": {"key": "docs", "tenant": "t1", "resource": "folder", "attributes": {}}, + "relation_details": {"key": "parent", "name": "Parent", "description": "a folder's"}, + "object_details": { + "key": "readme", + "tenant": "t1", + "resource": "document", + "attributes": {"public": False}, + }, + "tenant_details": {"key": "t1", "name": "T1", "attributes": {"tier": "gold"}}, +} + + +class Module(NamedTuple): + """An API module with a list_detailed(), the page it is answered with, and its model.""" + + page: dict[str, Any] + model: type[BaseModel] + + +MODULES = { + "role_assignments": Module( + {"data": [ROLE_ASSIGNMENT_DETAILED], "total_count": 41, "page_count": 3}, + PaginatedResultRoleAssignmentDetailedRead, + ), + "resource_instances": Module( + {"data": [RESOURCE_INSTANCE_DETAILED], "total_count": 1, "page_count": 1}, + PaginatedResultResourceInstanceDetailedRead, + ), + "relationship_tuples": Module( + {"data": [RELATIONSHIP_TUPLE_DETAILED], "total_count": 1, "page_count": 1}, + PaginatedResultRelationshipTupleDetailedRead, + ), +} +DEFAULT_PAGE = [("page", "1"), ("per_page", "100")] + + +class QueryCase(NamedTuple): + """Filters passed to list() and list_detailed() of one module, and the query they send.""" + + module: str + kwargs: dict[str, Any] + query: list[tuple[str, str]] + + +QUERY_CASES = { + "role_assignments-defaults": QueryCase("role_assignments", {}, DEFAULT_PAGE), + "role_assignments-lists": QueryCase( + "role_assignments", + { + "user_key": ["alice", "bob"], + "role_key": ["editor", "viewer"], + "tenant_key": ["t1", "t2"], + "resource_key": "document", + "resource_instance_key": "document:readme", + "page": 2, + "per_page": 10, + }, + sorted( + [ + ("page", "2"), + ("per_page", "10"), + ("user", "alice"), + ("user", "bob"), + ("role", "editor"), + ("role", "viewer"), + ("tenant", "t1"), + ("tenant", "t2"), + ("resource", "document"), + ("resource_instance", "document:readme"), + ] + ), + ), + "role_assignments-single-values": QueryCase( + "role_assignments", + {"user_key": "alice", "role_key": "editor", "tenant_key": "t1"}, + sorted([*DEFAULT_PAGE, ("user", "alice"), ("role", "editor"), ("tenant", "t1")]), + ), + "resource_instances-defaults": QueryCase("resource_instances", {}, DEFAULT_PAGE), + "resource_instances-filters": QueryCase( + "resource_instances", + { + "tenant_key": "t1", + "resource_key": "document", + "search_key": "readme", + "page": 3, + "per_page": 25, + }, + sorted( + [ + ("page", "3"), + ("per_page", "25"), + ("tenant", "t1"), + ("resource", "document"), + ("search", "readme"), + ] + ), + ), + "relationship_tuples-defaults": QueryCase("relationship_tuples", {}, DEFAULT_PAGE), + "relationship_tuples-filters": QueryCase( + "relationship_tuples", + { + "subject_key": "folder:docs", + "relation_key": "parent", + "object_key": "document:readme", + "tenant_key": "t1", + "page": 2, + "per_page": 50, + }, + sorted( + [ + ("page", "2"), + ("per_page", "50"), + ("subject", "folder:docs"), + ("relation", "parent"), + ("object", "document:readme"), + ("tenant", "t1"), + ] + ), + ), +} + + +def invoke(config: PermitConfig, flavour: str, target: Call) -> object: + """Call ``permit.`` on the async or the blocking client.""" + permit = Permit(config) if flavour == "async" else SyncPermit(config) + result = attrgetter(target.path)(permit)(*target.args, **target.kwargs) + if flavour == "async": + return asyncio.run(result) + assert not inspect.isawaitable(result) + return result + + +def sent_headers(request: Request) -> dict[str, str | None]: + return {name: request.headers.get(name) for name in HEADERS} + + +@pytest.fixture +def pdp_server(httpserver_ipv4: HTTPServer) -> HTTPServer: + """A server of its own for the PDP, so a request reaching it is told from one to the API.""" + return httpserver_ipv4 + + +@pytest.fixture +def split_config(config: PermitConfig, pdp_server: HTTPServer) -> PermitConfig: + """The offline config with the API on ``httpserver`` and the PDP on ``pdp_server``.""" + config.pdp = pdp_server.url_for("").rstrip("/") + return config + + +# --- list_detailed() ------------------------------------------------------------------- + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("case", QUERY_CASES.values(), ids=QUERY_CASES.keys()) +def test_list_detailed_sends_the_query_of_list_to_the_detailed_route( + httpserver: HTTPServer, config: PermitConfig, case: QueryCase, flavour: str +) -> None: + collection = f"{FACTS}/{case.module}" + httpserver.expect_request(collection, method="GET").respond_with_json([]) + httpserver.expect_request(f"{collection}/detailed", method="GET").respond_with_json( + MODULES[case.module].page + ) + + invoke(config, flavour, call(f"api.{case.module}.list", **case.kwargs)) + invoke(config, flavour, call(f"api.{case.module}.list_detailed", **case.kwargs)) + + assert [sent(request) for request, _ in httpserver.log] == [ + {"method": "GET", "path": collection, "query": case.query, "body": None}, + {"method": "GET", "path": f"{collection}/detailed", "query": case.query, "body": None}, + ] + (listed, _), (detailed, _) = httpserver.log + assert detailed.query_string == listed.query_string + assert [sent_headers(request) for request, _ in httpserver.log] == [JSON_HEADERS] * 2 + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("module", MODULES.keys()) +def test_list_detailed_returns_the_page_as_its_detailed_model( + httpserver: HTTPServer, config: PermitConfig, module: str, flavour: str +) -> None: + page, model = MODULES[module] + httpserver.expect_request(f"{FACTS}/{module}/detailed", method="GET").respond_with_json(page) + + result = invoke(config, flavour, call(f"api.{module}.list_detailed")) + + assert type(result) is model + assert result == model.parse_obj(page) + assert len(httpserver.log) == 1 + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_role_assignments_list_detailed_parses_the_objects_each_assignment_names( + httpserver: HTTPServer, config: PermitConfig, flavour: str +) -> None: + page = MODULES["role_assignments"].page + httpserver.expect_request(f"{FACTS}/role_assignments/detailed", method="GET").respond_with_json( + page + ) + + result = invoke(config, flavour, call("api.role_assignments.list_detailed")) + + assert isinstance(result, PaginatedResultRoleAssignmentDetailedRead) + assert (result.total_count, result.page_count) == (41, 3) + (assignment,) = result.data + assert type(assignment) is RoleAssignmentDetailedRead + assert type(assignment.user) is RoleAssignmentUser + assert (assignment.user.key, assignment.user.email) == ("alice", "alice@example.com") + assert assignment.user.attributes == {"dept": "eng"} + assert (assignment.role.key, assignment.role.permissions) == ( + "editor", + ["document:read", "document:edit"], + ) + assert (assignment.tenant.key, assignment.tenant.attributes) == ("t1", {"tier": "gold"}) + assert type(assignment.resource_instance) is RoleAssignmentResourceInstance + assert (assignment.resource_instance.resource, assignment.resource_instance.key) == ( + "document", + "readme", + ) + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_resource_instances_list_detailed_parses_the_relationships( + httpserver: HTTPServer, config: PermitConfig, flavour: str +) -> None: + page = MODULES["resource_instances"].page + httpserver.expect_request( + f"{FACTS}/resource_instances/detailed", method="GET" + ).respond_with_json(page) + + result = invoke(config, flavour, call("api.resource_instances.list_detailed")) + + assert isinstance(result, PaginatedResultResourceInstanceDetailedRead) + (instance,) = result.data + assert type(instance) is ResourceInstanceDetailedRead + assert instance.relationships == [ + RelationshipTupleBlockRead( + subject="folder:docs", relation="parent", object="document:readme" + ) + ] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_relationship_tuples_list_detailed_parses_the_details( + httpserver: HTTPServer, config: PermitConfig, flavour: str +) -> None: + page = MODULES["relationship_tuples"].page + httpserver.expect_request( + f"{FACTS}/relationship_tuples/detailed", method="GET" + ).respond_with_json(page) + + result = invoke(config, flavour, call("api.relationship_tuples.list_detailed")) + + assert isinstance(result, PaginatedResultRelationshipTupleDetailedRead) + (detailed,) = result.data + assert type(detailed) is RelationshipTupleDetailedRead + assert detailed.subject_details == ResourceInstanceBlockRead( + key="docs", tenant="t1", resource="folder", attributes={} + ) + assert detailed.relation_details == StrippedRelationBlockRead( + key="parent", name="Parent", description="a folder's" + ) + assert detailed.object_details is not None + assert detailed.object_details.key == "readme" + assert detailed.tenant_details == TenantBlockRead( + key="t1", name="T1", attributes={"tier": "gold"} + ) + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("proxy_facts_via_pdp", [False, True], ids=["api", "proxy-via-pdp"]) +@pytest.mark.parametrize("module", MODULES.keys()) +def test_list_detailed_follows_proxy_facts_via_pdp_as_list_does( + *, + httpserver: HTTPServer, + pdp_server: HTTPServer, + split_config: PermitConfig, + module: str, + proxy_facts_via_pdp: bool, + flavour: str, +) -> None: + """With proxy_facts_via_pdp, the PDP forwards the read to the API, as it does for list().""" + split_config.proxy_facts_via_pdp = proxy_facts_via_pdp + path = f"/facts/{module}/detailed" if proxy_facts_via_pdp else f"{FACTS}/{module}/detailed" + server, other = (pdp_server, httpserver) if proxy_facts_via_pdp else (httpserver, pdp_server) + server.expect_request(path, method="GET").respond_with_json(MODULES[module].page) + + invoke(split_config, flavour, call(f"api.{module}.list_detailed", page=2)) + + assert [sent(request) for request, _ in server.log] == [ + {"method": "GET", "path": path, "query": [("page", "2"), ("per_page", "100")], "body": None} + ] + assert other.log == [] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("module", MODULES.keys()) +def test_list_detailed_raises_the_api_error( + httpserver: HTTPServer, config: PermitConfig, module: str, flavour: str +) -> None: + detail = { + "id": "request-1", + "title": "Not found", + "error_code": "NOT_FOUND", + "message": "The tenant does not exist", + } + httpserver.expect_request(f"{FACTS}/{module}/detailed", method="GET").respond_with_json( + detail, status=404 + ) + + with pytest.raises(PermitApiError) as raised: + invoke(config, flavour, call(f"api.{module}.list_detailed", tenant_key="missing")) + + assert type(raised.value) is PermitNotFoundError + assert raised.value.status_code == 404 + assert raised.value.details == detail + assert len(httpserver.log) == 1 + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("module", MODULES.keys()) +def test_list_detailed_refuses_a_project_context_before_sending( + httpserver: HTTPServer, config: PermitConfig, module: str, flavour: str +) -> None: + """A project-level key needs the SDK's API context set to an environment first.""" + config.api_context._save_api_key_accessible_scope(org=ORG, project=PROJECT) + config.api_context.set_project_level_context(ORG, PROJECT) + + with pytest.raises(PermitContextError): + invoke(config, flavour, call(f"api.{module}.list_detailed")) + + assert httpserver.log == [] diff --git a/tests/type_check/consumer.py b/tests/type_check/consumer.py index 1d63fd87..5e0b204c 100644 --- a/tests/type_check/consumer.py +++ b/tests/type_check/consumer.py @@ -28,6 +28,9 @@ GroupRead, GroupReadSchema, PaginatedResultGroupReadSchema, + PaginatedResultRelationshipTupleDetailedRead, + PaginatedResultResourceInstanceDetailedRead, + PaginatedResultRoleAssignmentDetailedRead, PaginatedResultUserRead, RoleAssignmentCreate, RoleAssignmentRead, @@ -135,6 +138,17 @@ async def async_client() -> None: group_role = GroupAddRole(role="editor", resource="doc", resource_instance="d1", tenant="t1") assert_type(await permit.api.groups.assign_role("eng", group_role), GroupRead) await permit.api.groups.remove_role("eng", group_role) + detailed = await permit.api.role_assignments.list_detailed(user_key=["u", "v"], page=2) + assert_type(detailed, PaginatedResultRoleAssignmentDetailedRead) + assert_type(detailed.data[0].user.key, str) + assert_type( + await permit.api.resource_instances.list_detailed(search_key="doc-1"), + PaginatedResultResourceInstanceDetailedRead, + ) + assert_type( + await permit.api.relationship_tuples.list_detailed(subject_key="folder:docs"), + PaginatedResultRelationshipTupleDetailedRead, + ) # A list built before a bulk call is accepted too, whether of models or of dicts. users = [UserCreate(key=key) for key in ("u4", "u5")] @@ -195,6 +209,18 @@ def sync_client() -> None: permit.api.users.bulk_replace(users) assert_type(permit.api.get_user("u"), UserRead) assert_type(permit.api.groups.list(), PaginatedResultGroupReadSchema) + assert_type( + permit.api.role_assignments.list_detailed(tenant_key="t1"), + PaginatedResultRoleAssignmentDetailedRead, + ) + assert_type( + permit.api.resource_instances.list_detailed(), + PaginatedResultResourceInstanceDetailedRead, + ) + assert_type( + permit.api.relationship_tuples.list_detailed(per_page=10), + PaginatedResultRelationshipTupleDetailedRead, + ) assert_type(permit.api.groups.assign_user("eng", "u", "t1"), GroupRead) assert_type( permit.api.groups.assign_group("group:leads", {"group_instance_key": "eng"}), GroupRead @@ -218,6 +244,9 @@ async def mistakes_stay_errors() -> None: # Accepting dicts does not mean accepting anything. await permit.api.users.create("u") # type: ignore[arg-type] await permit.api.tenants.create_user("t1", "u") # type: ignore[arg-type] + # The detailed lists take their filters as keywords only. + await permit.api.role_assignments.list_detailed("u") # type: ignore[call-arg] + sync_permit.api.resource_instances.list_detailed(1, 100) # type: ignore[call-arg] # SDK models are pydantic v1 models, so the pydantic v2 API does not exist on them. UserCreate(key="u").model_dump() # type: ignore[attr-defined] # The blocking client returns values, not awaitables. From bb773361ccaba6c3741b48db81b62367146a3a22 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:47:03 +0300 Subject: [PATCH 05/32] Deprecate the detailed_key argument of resource_instances.list() It sends the API's deprecated detailed query parameter, and list_detailed() replaces it. A call that passes detailed_key still sends what it sent before, and now issues one DeprecationWarning that names list_detailed() and permit 4.0, attributed to the calling line on the async and the blocking client. A call without it does not warn. The two regression tests that pass detailed_key now expect the warning. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++ permit/_sync_types.pyi | 4 +- permit/api/resource_instances.py | 12 +++- tests/test_detailed_lists_offline.py | 93 +++++++++++++++++++++++++++- tests/test_offline_regressions.py | 6 +- 5 files changed, 113 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ffa505d8..468241ea 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,10 @@ each one issues a `DeprecationWarning` that says what to do instead. replacement. - **`permit.api.tenants.add_user()`**, an alias of `permit.api.tenants.create_user()`. The route creates the user, so `create_user()` is the name that says what it does. +- **The `detailed_key` argument of `permit.api.resource_instances.list()`**, which sends a + query parameter the API has deprecated. Use `permit.api.resource_instances.list_detailed()` + instead (see [Detailed lists](#detailed-lists)). Only a call that passes `detailed_key=True` + or `detailed_key=False` warns. By default, Python shows these warnings only when the code that triggers them is in `__main__`, such as the script you run. pytest shows them in its warnings summary. To see diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index da468165..ea5764a0 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -1415,7 +1415,9 @@ class SyncResourceInstancesApi(BasePermitApi): per_page: How many items to fetch per page (default: 100). tenant_key: Only return instances that belong to this tenant. resource_key: Only return instances of this resource type. - detailed_key: Whether to return detailed instances. + detailed_key: Deprecated, to be removed in permit 4.0: use ``list_detailed()``. + Whether to return detailed instances. Passing True or False sends the API's + deprecated ``detailed`` query parameter and issues a ``DeprecationWarning``. search_key: Only return instances matching this search string. Returns: diff --git a/permit/api/resource_instances.py b/permit/api/resource_instances.py index 52a04f3b..82c7559f 100644 --- a/permit/api/resource_instances.py +++ b/permit/api/resource_instances.py @@ -24,8 +24,14 @@ ResourceInstanceRead, ResourceInstanceUpdate, ) +from permit.utils.deprecation import _warn_deprecated from permit.utils.model_input import ModelInput, ModelListInput +_DETAILED_KEY_DEPRECATION = ( + "The detailed_key argument of permit.api.resource_instances.list() is deprecated and will " + "be removed in permit 4.0; use permit.api.resource_instances.list_detailed() instead." +) + def _filter_params( *, @@ -86,7 +92,9 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi per_page: How many items to fetch per page (default: 100). tenant_key: Only return instances that belong to this tenant. resource_key: Only return instances of this resource type. - detailed_key: Whether to return detailed instances. + detailed_key: Deprecated, to be removed in permit 4.0: use ``list_detailed()``. + Whether to return detailed instances. Passing True or False sends the API's + deprecated ``detailed`` query parameter and issues a ``DeprecationWarning``. search_key: Only return instances matching this search string. Returns: @@ -97,6 +105,8 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi PermitContextError: If the configured ApiContext does not match the required endpoint context. """ + if detailed_key is not None: + _warn_deprecated(_DETAILED_KEY_DEPRECATION) await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) params = _filter_params( diff --git a/tests/test_detailed_lists_offline.py b/tests/test_detailed_lists_offline.py index c85ac808..c88ac56d 100644 --- a/tests/test_detailed_lists_offline.py +++ b/tests/test_detailed_lists_offline.py @@ -1,4 +1,4 @@ -"""Offline tests for the detailed lists (PER-16337). +"""Offline tests for the detailed lists and the detailed_key deprecation (PER-16337). ``list_detailed()`` on ``role_assignments``, ``resource_instances`` and ``relationship_tuples`` is called through the async and the blocking client. The tests @@ -6,12 +6,15 @@ what the response parses into. For the same filters it must send exactly the query its module's ``list()`` sends, to the ``/detailed`` route next to it. -Every request is served by a local ``pytest_httpserver`` and the API context is +``resource_instances.list(detailed_key=...)`` keeps sending what it sent in 3.0, and warns +once, at the line that called it, on both clients; a call without ``detailed_key`` does not +warn. Every request is served by a local ``pytest_httpserver`` and the API context is pre-populated, so no API key and no ``/v2/api-key/scope`` lookup are needed. """ import asyncio import inspect +import warnings from operator import attrgetter from typing import Any, NamedTuple @@ -444,3 +447,89 @@ def test_list_detailed_refuses_a_project_context_before_sending( invoke(config, flavour, call(f"api.{module}.list_detailed")) assert httpserver.log == [] + + +# --- resource_instances.list(detailed_key=...) ------------------------------------------- + +INSTANCES = f"{FACTS}/resource_instances" +DETAILED_KEY_WARNING = ( + "The detailed_key argument of permit.api.resource_instances.list() is deprecated and will " + "be removed in permit 4.0; use permit.api.resource_instances.list_detailed() instead." +) + + +def list_blocking(permit: SyncPermit, target: Call) -> object: + return permit.api.resource_instances.list(*target.args, **target.kwargs) + + +async def list_awaiting(permit: Permit, target: Call) -> object: + return await permit.api.resource_instances.list(*target.args, **target.kwargs) + + +# The line each client's warning must name: the one statement of the helper above that +# calls list() on that client. +CALL_SITES = { + "sync": (__file__, list_blocking.__code__.co_firstlineno + 1), + "async": (__file__, list_awaiting.__code__.co_firstlineno + 1), +} + + +def call_list(config: PermitConfig, flavour: str, target: Call) -> list[tuple[str, str, int]]: + """Call resource_instances.list(), and return the DeprecationWarnings it issued. + + Each warning is its message and the file and line it names. Other categories are left + out: a ResourceWarning, for one, comes from garbage collection and can land anywhere. + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + if flavour == "async": + asyncio.run(list_awaiting(Permit(config), target)) + else: + list_blocking(SyncPermit(config), target) + return [ + (str(warning.message), warning.filename, warning.lineno) + for warning in caught + if issubclass(warning.category, DeprecationWarning) + ] + + +DETAILED_KEY_CALLS = { + "true": (call("list", detailed_key=True), "true"), + "false": (call("list", detailed_key=False), "false"), + # A positional detailed_key is the case under test, so the bare boolean is the point. + "positional": (call("list", 1, 100, None, None, True), "true"), # noqa: FBT003 + "with-filters": (call("list", tenant_key="t1", detailed_key=True, search_key="r"), "true"), +} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + ("target", "detailed"), DETAILED_KEY_CALLS.values(), ids=DETAILED_KEY_CALLS.keys() +) +def test_detailed_key_warns_once_at_the_call_and_still_sends_the_detailed_flag( + httpserver: HTTPServer, config: PermitConfig, target: Call, detailed: str, flavour: str +) -> None: + httpserver.expect_request(INSTANCES, method="GET").respond_with_json([]) + + caught = call_list(config, flavour, target) + + assert caught == [(DETAILED_KEY_WARNING, *CALL_SITES[flavour])] + ((request, _),) = httpserver.log + assert ("detailed", detailed) in sent(request)["query"] + assert request.args.getlist("detailed") == [detailed] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + "target", + [call("list"), call("list", detailed_key=None), call("list", 2, 10, "t1", "document")], + ids=["no-arguments", "detailed-key-none", "other-filters"], +) +def test_list_without_detailed_key_neither_warns_nor_sends_the_flag( + httpserver: HTTPServer, config: PermitConfig, target: Call, flavour: str +) -> None: + httpserver.expect_request(INSTANCES, method="GET").respond_with_json([]) + + assert call_list(config, flavour, target) == [] + ((request, _),) = httpserver.log + assert "detailed" not in request.args diff --git a/tests/test_offline_regressions.py b/tests/test_offline_regressions.py index 06a601c0..09a503d4 100644 --- a/tests/test_offline_regressions.py +++ b/tests/test_offline_regressions.py @@ -128,7 +128,8 @@ async def test_resource_instances_list_sends_detailed_filter_as_query_string( """detailed_key must reach the wire as a string: yarl rejects bool query values.""" httpserver.expect_request(f"{FACTS}/resource_instances", method="GET").respond_with_json([]) - await ResourceInstancesApi(config).list(detailed_key=True) + with pytest.warns(DeprecationWarning, match="detailed_key"): + await ResourceInstancesApi(config).list(detailed_key=True) assert single_request(httpserver).args["detailed"] == "true" @@ -138,7 +139,8 @@ async def test_resource_instances_list_sends_detailed_false_as_query_string( ) -> None: httpserver.expect_request(f"{FACTS}/resource_instances", method="GET").respond_with_json([]) - await ResourceInstancesApi(config).list(detailed_key=False) + with pytest.warns(DeprecationWarning, match="detailed_key"): + await ResourceInstancesApi(config).list(detailed_key=False) assert single_request(httpserver).args["detailed"] == "false" From 8d288676e7a3efb1ca9c0909a8e51c70d8ebb1d5 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:51:46 +0300 Subject: [PATCH 06/32] Add permit.api.pdps.refresh() to refresh every PDP's data POST /v2/pdps/{proj}/{env}/configs/refresh makes every PDP connected to the environment fetch all of its data again now. refresh() sends an optional reason, at most 512 characters and checked before sending, and returns the update id and the ids of the PDP configurations targeted. It returns once the refresh is triggered, not once the PDPs finish. It needs a key with write or admin access to the environment, and always goes to the API, whatever proxy_facts_via_pdp says. pdps is a new sub-API on both clients, so the parity test's count of sub-APIs goes from 18 to 19. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 ++++ permit/_sync_types.pyi | 34 ++++++ permit/api/api_client.py | 10 ++ permit/api/pdps.py | 63 +++++++++++ permit/api/sync_api_client.py | 14 +++ tests/test_fix_sync_parity.py | 2 +- tests/test_pdps_offline.py | 192 ++++++++++++++++++++++++++++++++++ tests/type_check/consumer.py | 7 ++ 8 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 permit/api/pdps.py create mode 100644 tests/test_pdps_offline.py diff --git a/README.md b/README.md index 468241ea..46bf4357 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,24 @@ for assignment in page.data: They need the API key `list()` needs: an environment-level key, or a broader key with the SDK's API context set to the environment. The blocking client has the same methods. +## PDP data refresh + +`permit.api.pdps.refresh()` makes every PDP connected to the environment fetch all of its +authorization data from Permit again now, instead of at its next periodic update, for +example after data the PDPs decide on changed in an external data source: + +```py +refreshed = await permit.api.pdps.refresh(reason="nightly import") +print(refreshed.update_id, refreshed.pdp_ids) +``` + +- It returns once Permit has triggered the refresh, not once the PDPs have finished it, so + a check sent right after it may still be answered from the old data. +- `reason` is optional, at most 512 characters, and shows in the PDPs' logs. +- It needs an environment-level API key with write or admin access, or a broader key with + the SDK's API context set to the environment. The API rejects a read-only key with 403, + and answers 404 for an environment with no PDP configuration. + ## Type checking The package ships a `py.typed` marker (PEP 561), so mypy, pyright and IDEs check your diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index ea5764a0..112a9b05 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -40,6 +40,7 @@ from permit.api.models import ( PaginatedResultResourceInstanceDetailedRead, PaginatedResultRoleAssignmentDetailedRead, PaginatedResultUserRead, + PDPDataRefreshResponse, PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings, ProjectCreate, ProjectRead, @@ -788,6 +789,39 @@ class SyncGroupsApi(BasePermitApi): context. """ +class SyncPdpsApi(BasePermitApi): + """Act on the Policy Decision Points (PDPs) connected to an environment.""" + def refresh(self, reason: str | None = None) -> PDPDataRefreshResponse: + """Triggers a data refresh on every PDP in the environment. + + Each PDP connected to the environment fetches all of its authorization data from + Permit again now, instead of at its next periodic update. Use it when the data a PDP + decides on changed outside Permit, such as in an external data source, and the PDPs + should not wait for their next update to see it. + + The call returns once Permit has triggered the refresh, not once the PDPs have + finished it: they fetch the data in the background, so a check sent right after + this returns may still be answered from the old data. + + Needs an environment-level API key, or a project- or organization-level key with the + SDK's API context set to the environment. The key needs write or admin access: the + API rejects a read-only key with 403. + + Args: + reason: Why the refresh was triggered, at most 512 characters. The PDPs show it + in their logs. + + Returns: + The id of the data update that carries the refresh, and the ids of the PDP + configurations it was sent to. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 403 for a + read-only API key or 404 when the environment has no PDP configuration. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + class SyncProjectsApi(BasePermitApi): """Manage the projects of an organization.""" def __init__(self, config: PermitConfig) -> None: ... diff --git a/permit/api/api_client.py b/permit/api/api_client.py index f3890155..760493e3 100644 --- a/permit/api/api_client.py +++ b/permit/api/api_client.py @@ -3,6 +3,7 @@ from permit.api.deprecated import DeprecatedApi from permit.api.environments import EnvironmentsApi from permit.api.groups import GroupsApi +from permit.api.pdps import PdpsApi from permit.api.projects import ProjectsApi from permit.api.relationship_tuples import RelationshipTuplesApi from permit.api.resource_action_groups import ResourceActionGroupsApi @@ -35,6 +36,7 @@ def __init__(self, config: PermitConfig) -> None: self._condition_sets = ConditionSetsApi(config) self._environments = EnvironmentsApi(config) self._groups = GroupsApi(config) + self._pdps = PdpsApi(config) self._projects = ProjectsApi(config) self._action_groups = ResourceActionGroupsApi(config) self._resource_actions = ResourceActionsApi(config) @@ -90,6 +92,14 @@ def groups(self) -> GroupsApi: """ return self._groups + @property + def pdps(self) -> PdpsApi: + """API for acting on the environment's PDPs, such as refreshing their data. + + See: https://api.permit.io/v2/redoc#tag/Policy-Decision-Points + """ + return self._pdps + @property def action_groups(self) -> ResourceActionGroupsApi: """API for managing resource action groups. diff --git a/permit/api/pdps.py b/permit/api/pdps.py new file mode 100644 index 00000000..34e83db6 --- /dev/null +++ b/permit/api/pdps.py @@ -0,0 +1,63 @@ +from typing import TYPE_CHECKING + +from permit.utils.pydantic_version import PYDANTIC_VERSION + +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): + from pydantic import validate_arguments +else: + from pydantic.v1 import validate_arguments + +from permit.api.base import BasePermitApi, SimpleHttpClient +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import PDPDataRefreshRequest, PDPDataRefreshResponse + + +class PdpsApi(BasePermitApi): + """Act on the Policy Decision Points (PDPs) connected to an environment.""" + + @property + def __pdp_configs(self) -> SimpleHttpClient: + return self._build_http_client( + f"/v2/pdps/{self.config.api_context.project}/{self.config.api_context.environment}/configs" + ) + + @validate_arguments + async def refresh(self, reason: str | None = None) -> PDPDataRefreshResponse: + """Triggers a data refresh on every PDP in the environment. + + Each PDP connected to the environment fetches all of its authorization data from + Permit again now, instead of at its next periodic update. Use it when the data a PDP + decides on changed outside Permit, such as in an external data source, and the PDPs + should not wait for their next update to see it. + + The call returns once Permit has triggered the refresh, not once the PDPs have + finished it: they fetch the data in the background, so a check sent right after + this returns may still be answered from the old data. + + Needs an environment-level API key, or a project- or organization-level key with the + SDK's API context set to the environment. The key needs write or admin access: the + API rejects a read-only key with 403. + + Args: + reason: Why the refresh was triggered, at most 512 characters. The PDPs show it + in their logs. + + Returns: + The id of the data update that carries the refresh, and the ids of the PDP + configurations it was sent to. + + Raises: + PermitApiError: If the API returns an error HTTP status code, such as 403 for a + read-only API key or 404 when the environment has no PDP configuration. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ + await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) + await self._ensure_context(ApiContextLevel.ENVIRONMENT) + request = ( + PDPDataRefreshRequest() if reason is None else PDPDataRefreshRequest(reason=reason) + ) + return await self.__pdp_configs.post("/refresh", model=PDPDataRefreshResponse, json=request) diff --git a/permit/api/sync_api_client.py b/permit/api/sync_api_client.py index 2a4f5566..e1137779 100644 --- a/permit/api/sync_api_client.py +++ b/permit/api/sync_api_client.py @@ -5,6 +5,7 @@ from permit.api.deprecated import DeprecatedApi from permit.api.environments import EnvironmentsApi from permit.api.groups import GroupsApi +from permit.api.pdps import PdpsApi from permit.api.projects import ProjectsApi from permit.api.relationship_tuples import RelationshipTuplesApi from permit.api.resource_action_groups import ResourceActionGroupsApi @@ -30,6 +31,7 @@ from permit._sync_types import SyncDeprecatedApi as SyncDeprecatedApi from permit._sync_types import SyncEnvironmentsApi as SyncEnvironmentsApi from permit._sync_types import SyncGroupsApi as SyncGroupsApi + from permit._sync_types import SyncPdpsApi as SyncPdpsApi from permit._sync_types import SyncProjectsApi as SyncProjectsApi from permit._sync_types import SyncRelationshipTuplesApi as SyncRelationshipTuplesApi from permit._sync_types import SyncResourceActionGroupsApi as SyncResourceActionGroupsApi @@ -61,6 +63,9 @@ class SyncEnvironmentsApi(EnvironmentsApi, metaclass=SyncClass): class SyncGroupsApi(GroupsApi, metaclass=SyncClass): """Blocking variant of `GroupsApi`.""" + class SyncPdpsApi(PdpsApi, metaclass=SyncClass): + """Blocking variant of `PdpsApi`.""" + class SyncProjectsApi(ProjectsApi, metaclass=SyncClass): """Blocking variant of `ProjectsApi`.""" @@ -119,6 +124,7 @@ def __init__(self, config: PermitConfig) -> None: self._condition_sets = SyncConditionSetsApi(config) self._environments = SyncEnvironmentsApi(config) self._groups = SyncGroupsApi(config) + self._pdps = SyncPdpsApi(config) self._projects = SyncProjectsApi(config) self._relationship_tuples = SyncRelationshipTuplesApi(config) self._action_groups = SyncResourceActionGroupsApi(config) @@ -174,6 +180,14 @@ def groups(self) -> SyncGroupsApi: """ return self._groups + @property + def pdps(self) -> SyncPdpsApi: + """API for acting on the environment's PDPs, such as refreshing their data. + + See: https://api.permit.io/v2/redoc#tag/Policy-Decision-Points + """ + return self._pdps + @property def action_groups(self) -> SyncResourceActionGroupsApi: """API for managing resource action groups. diff --git a/tests/test_fix_sync_parity.py b/tests/test_fix_sync_parity.py index 21301a1f..6c569c3d 100644 --- a/tests/test_fix_sync_parity.py +++ b/tests/test_fix_sync_parity.py @@ -31,7 +31,7 @@ # PermitApiClient has this many sub-API properties. The walk descends only through # properties, so if it finds fewer it has stopped seeing them, and the parity checks # pass without having looked. Lower it only when a sub-API is removed. -API_SUB_API_COUNT = 18 +API_SUB_API_COUNT = 19 # The walk only reads attributes, so nothing is ever sent here. NO_SERVER = "http://localhost:1" diff --git a/tests/test_pdps_offline.py b/tests/test_pdps_offline.py new file mode 100644 index 00000000..29c8e06c --- /dev/null +++ b/tests/test_pdps_offline.py @@ -0,0 +1,192 @@ +"""Offline tests for permit.api.pdps (PER-16337). + +``refresh()`` is called through the async and the blocking client, and the tests check the +request it puts on the wire (method, path, query string, headers and JSON body) and what the +response parses into. Every request is served by a local ``pytest_httpserver`` and the API +context is pre-populated, so no API key and no ``/v2/api-key/scope`` lookup are needed. +""" + +import asyncio +import inspect +from operator import attrgetter +from typing import Any, NamedTuple +from uuid import UUID + +import pytest +from pydantic.v1 import ValidationError +from pytest_httpserver import HTTPServer +from werkzeug import Request + +from permit import Permit +from permit.api.models import PDPDataRefreshResponse +from permit.api.pdps import PdpsApi +from permit.config import PermitConfig +from permit.exceptions import ( + PermitApiDetailedError, + PermitApiError, + PermitContextError, + PermitNotFoundError, +) +from permit.sync import Permit as SyncPermit +from tests.utils import ENVIRONMENT, ORG, PROJECT, Call, call, sent + +FLAVOURS = ["async", "sync"] +REFRESH = f"/v2/pdps/{PROJECT}/{ENVIRONMENT}/configs/refresh" + +# The headers the SDK sets. The wait-for-sync ones are listed so that sending one shows. +HEADERS = ("Authorization", "Content-Type", "X-Wait-Timeout", "X-Timeout-Policy") +JSON_HEADERS: dict[str, str | None] = { + "Authorization": "Bearer test-token", + "Content-Type": "application/json", + "X-Wait-Timeout": None, + "X-Timeout-Policy": None, +} + +UPDATE_ID = "00000000-0000-4000-8000-000000000040" +PDP_IDS = ["00000000-0000-4000-8000-000000000041", "00000000-0000-4000-8000-000000000042"] +REFRESHED = {"update_id": UPDATE_ID, "pdp_ids": PDP_IDS} + + +class Case(NamedTuple): + """One refresh() call and the JSON body it must send.""" + + call: Call + body: dict[str, Any] + + +CASES = { + "no-reason": Case(call("refresh"), {}), + "reason": Case(call("refresh", "nightly import"), {"reason": "nightly import"}), + "reason-keyword": Case(call("refresh", reason="sync"), {"reason": "sync"}), + "reason-none": Case(call("refresh", reason=None), {}), + "reason-unicode": Case(call("refresh", "réimport ✓"), {"reason": "réimport ✓"}), + "reason-512-characters": Case(call("refresh", "r" * 512), {"reason": "r" * 512}), +} + + +def invoke(config: PermitConfig, flavour: str, target: Call) -> object: + """Call ``permit.api.pdps.`` on the async or the blocking client.""" + permit = Permit(config) if flavour == "async" else SyncPermit(config) + result = attrgetter(f"api.pdps.{target.path}")(permit)(*target.args, **target.kwargs) + if flavour == "async": + return asyncio.run(result) + assert not inspect.isawaitable(result) + return result + + +def sent_headers(request: Request) -> dict[str, str | None]: + return {name: request.headers.get(name) for name in HEADERS} + + +def test_refresh_is_the_only_public_method() -> None: + public = { + name + for name, value in vars(PdpsApi).items() + if not name.startswith("_") and callable(value) + } + + assert public == {"refresh"} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("case", CASES.values(), ids=CASES.keys()) +def test_refresh_posts_the_reason_to_the_environment_refresh_route( + httpserver: HTTPServer, config: PermitConfig, case: Case, flavour: str +) -> None: + httpserver.expect_request(REFRESH, method="POST").respond_with_json(REFRESHED) + + invoke(config, flavour, case.call) + + assert [sent(request) for request, _ in httpserver.log] == [ + {"method": "POST", "path": REFRESH, "query": [], "body": case.body} + ] + assert [sent_headers(request) for request, _ in httpserver.log] == [JSON_HEADERS] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_refresh_returns_the_update_id_and_the_targeted_pdps( + httpserver: HTTPServer, config: PermitConfig, flavour: str +) -> None: + httpserver.expect_request(REFRESH, method="POST").respond_with_json(REFRESHED) + + result = invoke(config, flavour, call("refresh")) + + assert type(result) is PDPDataRefreshResponse + assert result.update_id == UUID(UPDATE_ID) + assert result.pdp_ids == [UUID(pdp_id) for pdp_id in PDP_IDS] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_refresh_goes_to_the_api_even_with_proxy_facts_via_pdp( + httpserver: HTTPServer, httpserver_ipv4: HTTPServer, config: PermitConfig, flavour: str +) -> None: + """The PDPs are refreshed by the Permit API, so the request never goes to a PDP.""" + config.pdp = httpserver_ipv4.url_for("").rstrip("/") + config.proxy_facts_via_pdp = True + httpserver.expect_request(REFRESH, method="POST").respond_with_json(REFRESHED) + + invoke(config, flavour, call("refresh")) + + assert [sent(request)["path"] for request, _ in httpserver.log] == [REFRESH] + assert [sent_headers(request) for request, _ in httpserver.log] == [JSON_HEADERS] + assert httpserver_ipv4.log == [] + + +class ApiError(NamedTuple): + """An error status, the error code the API sends with it, and what the SDK raises.""" + + status: int + error_code: str + raises: type[PermitApiError] + + +API_ERRORS = { + "read-only-key": ApiError(403, "FORBIDDEN_ACCESS", PermitApiDetailedError), + "no-pdp-configuration": ApiError(404, "NOT_FOUND", PermitNotFoundError), +} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize("error", API_ERRORS.values(), ids=API_ERRORS.keys()) +def test_refresh_raises_the_matching_permit_api_error( + httpserver: HTTPServer, config: PermitConfig, error: ApiError, flavour: str +) -> None: + detail = { + "id": "request-1", + "title": f"status {error.status}", + "error_code": error.error_code, + "message": f"status {error.status}", + } + httpserver.expect_request(REFRESH, method="POST").respond_with_json(detail, status=error.status) + + with pytest.raises(PermitApiError) as raised: + invoke(config, flavour, call("refresh")) + + assert type(raised.value) is error.raises + assert raised.value.status_code == error.status + assert raised.value.details == detail + assert len(httpserver.log) == 1 + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_refresh_rejects_a_reason_over_512_characters_before_sending( + httpserver: HTTPServer, config: PermitConfig, flavour: str +) -> None: + with pytest.raises(ValidationError): + invoke(config, flavour, call("refresh", "r" * 513)) + + assert httpserver.log == [] + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_refresh_refuses_a_project_context_before_sending( + httpserver: HTTPServer, config: PermitConfig, flavour: str +) -> None: + """A project-level key needs the SDK's API context set to an environment first.""" + config.api_context._save_api_key_accessible_scope(org=ORG, project=PROJECT) + config.api_context.set_project_level_context(ORG, PROJECT) + + with pytest.raises(PermitContextError): + invoke(config, flavour, call("refresh")) + + assert httpserver.log == [] diff --git a/tests/type_check/consumer.py b/tests/type_check/consumer.py index 5e0b204c..4fc86b70 100644 --- a/tests/type_check/consumer.py +++ b/tests/type_check/consumer.py @@ -8,6 +8,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any, TypeVar +from uuid import UUID from typing_extensions import assert_type @@ -32,6 +33,7 @@ PaginatedResultResourceInstanceDetailedRead, PaginatedResultRoleAssignmentDetailedRead, PaginatedResultUserRead, + PDPDataRefreshResponse, RoleAssignmentCreate, RoleAssignmentRead, RoleCreate, @@ -149,6 +151,9 @@ async def async_client() -> None: await permit.api.relationship_tuples.list_detailed(subject_key="folder:docs"), PaginatedResultRelationshipTupleDetailedRead, ) + refreshed = await permit.api.pdps.refresh("nightly import") + assert_type(refreshed, PDPDataRefreshResponse) + assert_type(refreshed.pdp_ids, list[UUID]) # A list built before a bulk call is accepted too, whether of models or of dicts. users = [UserCreate(key=key) for key in ("u4", "u5")] @@ -221,6 +226,8 @@ def sync_client() -> None: permit.api.relationship_tuples.list_detailed(per_page=10), PaginatedResultRelationshipTupleDetailedRead, ) + assert_type(permit.api.pdps.refresh(), PDPDataRefreshResponse) + assert_type(permit.api.pdps.refresh(reason="sync").update_id, UUID) assert_type(permit.api.groups.assign_user("eng", "u", "t1"), GroupRead) assert_type( permit.api.groups.assign_group("group:leads", {"group_instance_key": "eng"}), GroupRead From fcdd13c3587791b3b913fd19e5abb55b59ab0312 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:55:15 +0300 Subject: [PATCH 07/32] Send a context with get_user_permissions() get_user_permissions() on the Enforcer, permit.Permit and permit.sync.Permit takes a context, which it sends in the /user-permissions body merged over the context store's base context, as check() merges it. Without a context the body is byte for byte what 3.0 sent: no context key, even when the context store holds one. The offline tests pin the exact bytes of both bodies. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 + permit/_sync_types.pyi | 5 + permit/enforcement/enforcer.py | 9 +- permit/permit.py | 9 +- permit/sync.py | 7 +- .../test_user_permissions_context_offline.py | 196 ++++++++++++++++++ tests/type_check/consumer.py | 5 + 7 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 tests/test_user_permissions_context_offline.py diff --git a/README.md b/README.md index 46bf4357..a0d6ae33 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,14 @@ without a role, such as `create_user()` creates, is not listed. Only the contain this query: the cloud PDP answers 404, which the SDK raises as a `PermitConnectionError`. Both methods are on the blocking client too. +## User permissions with context + +`permit.get_user_permissions("alice", context={"ip": "10.0.0.1"})` sends the context with the +query, for ABAC policies to read. It is merged over the context store's base context, as +`permit.check()` merges it. A call without `context` sends no context, as in 3.0, so the base +context is not sent either; pass `context={}` to send the base context alone. The blocking +client takes the same argument. + ## Detailed lists `list_detailed()` on `permit.api.role_assignments`, `permit.api.resource_instances` and diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index 112a9b05..931ba5a3 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -2973,6 +2973,7 @@ class SyncEnforcer: tenants: list[str] | None = None, resources: list[str] | None = None, resource_types: list[str] | None = None, + context: Context | None = None, ) -> dict[str, Any]: """Get all permissions of a user. @@ -2981,6 +2982,10 @@ class SyncEnforcer: tenants: Only return permissions in these tenants. resources: Only return permissions on these resources. resource_types: Only return permissions on these resource types. + context: The query's context, which ABAC policies can read, merged over the + context store's base context as ``check()`` merges it. When it is None (the + default), the request carries no context, and the base context is not sent + either; pass ``{}`` to send the base context alone. Returns: The user's permissions per tenant and resource. diff --git a/permit/enforcement/enforcer.py b/permit/enforcement/enforcer.py index 9c019fe2..e8e7393b 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -481,6 +481,7 @@ async def get_user_permissions( tenants: list[str] | None = None, resources: list[str] | None = None, resource_types: list[str] | None = None, + context: Context | None = None, ) -> dict[str, Any]: """Get all permissions of a user. @@ -489,6 +490,10 @@ async def get_user_permissions( tenants: Only return permissions in these tenants. resources: Only return permissions on these resources. resource_types: Only return permissions on these resource types. + context: The query's context, which ABAC policies can read, merged over the + context store's base context as ``check()`` merges it. When it is None (the + default), the request carries no context, and the base context is not sent + either; pass ``{}`` to send the base context alone. Returns: The user's permissions per tenant and resource. @@ -496,12 +501,14 @@ async def get_user_permissions( Raises: PermitConnectionError: If the PDP rejects the request or cannot be reached. """ - input_data = { + input_data: dict[str, Any] = { "user": {"key": user} if isinstance(user, str) else user, "tenants": tenants, "resources": resources, "resource_types": resource_types, } + if context is not None: + input_data["context"] = self._context_store.get_derived_context(context) async with aiohttp.ClientSession(headers=self._headers, **self._timeout_config) as session: url = f"{self._base_url}/user-permissions" diff --git a/permit/permit.py b/permit/permit.py index 680e3a3f..c6836346 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -252,6 +252,7 @@ async def get_user_permissions( tenants: list[str] | None = None, resources: list[str] | None = None, resource_types: list[str] | None = None, + context: Context | None = None, ) -> dict[str, Any]: """Get all permissions for a user. @@ -260,6 +261,10 @@ async def get_user_permissions( tenants: Optional list of tenants to filter permissions resources: Optional list of resources to filter resource_types: Optional list of resource types to filter + context: The query's context, which ABAC policies can read, merged over the + context store's base context as ``check()`` merges it. When it is None (the + default), the request carries no context, and the base context is not sent + either; pass ``{}`` to send the base context alone. Returns: dict: User permissions per tenant @@ -267,7 +272,9 @@ async def get_user_permissions( Raises: PermitConnectionError: If an error occurs while sending the request to the PDP """ - return await self._enforcer.get_user_permissions(user, tenants, resources, resource_types) + return await self._enforcer.get_user_permissions( + user, tenants, resources, resource_types, context + ) async def get_user_tenants( self, user: User, context: Context | None = None diff --git a/permit/sync.py b/permit/sync.py index f46637a7..a0c556d4 100644 --- a/permit/sync.py +++ b/permit/sync.py @@ -190,6 +190,7 @@ def get_user_permissions( # type: ignore[override] tenants: list[str] | None = None, resources: list[str] | None = None, resource_types: list[str] | None = None, + context: Context | None = None, ) -> dict[str, Any]: """Get all permissions for a user. @@ -198,6 +199,10 @@ def get_user_permissions( # type: ignore[override] tenants: Optional list of tenants to filter permissions resources: Optional list of resources to filter resource_types: Optional list of resource types to filter + context: The query's context, which ABAC policies can read, merged over the + context store's base context as ``check()`` merges it. When it is None (the + default), the request carries no context, and the base context is not sent + either; pass ``{}`` to send the base context alone. Returns: dict: User permissions per tenant @@ -206,7 +211,7 @@ def get_user_permissions( # type: ignore[override] PermitConnectionError: If an error occurs while sending the request to the PDP """ return self._enforcer.get_user_permissions( # type: ignore[return-value] - user, tenants, resources, resource_types + user, tenants, resources, resource_types, context ) def get_user_tenants( # type: ignore[override] diff --git a/tests/test_user_permissions_context_offline.py b/tests/test_user_permissions_context_offline.py new file mode 100644 index 00000000..a7a77a0c --- /dev/null +++ b/tests/test_user_permissions_context_offline.py @@ -0,0 +1,196 @@ +"""Offline tests for the context of get_user_permissions() (PER-16337). + +``get_user_permissions(..., context=...)`` is called on the Enforcer and through the async +and the blocking client, and the tests check the exact bytes of the request body it sends to +the PDP's ``/user-permissions``. Without a context the body is byte for byte what 3.0 sent: +no ``context`` key, whatever the context store holds. With one, the body ends with the +context merged over the context store's base context, as ``check()`` merges it. Every +request is served by a local ``pytest_httpserver``, so no API key or PDP is needed. +""" + +import asyncio +import inspect +from operator import attrgetter +from typing import Any, NamedTuple + +import pytest +from pytest_httpserver import HTTPServer +from werkzeug import Request + +from permit import Permit +from permit.config import PermitConfig +from permit.enforcement.enforcer import Enforcer +from permit.sync import Permit as SyncPermit +from tests.utils import Call, call + +FLAVOURS = ["async", "sync"] +USER_PERMISSIONS = "/user-permissions" +HEADERS: dict[str, str | None] = { + "Authorization": "Bearer test-token", + "Content-Type": "application/json", +} +PERMISSIONS = {"__tenant:t1": {"tenant": {"key": "t1"}, "permissions": ["document:read"]}} +STORE = {"region": "eu", "flags": {"a": 1}} + + +class Case(NamedTuple): + """One get_user_permissions() call and the exact request body it must send.""" + + call: Call + body: bytes + + +# Bodies without a context, as permit 3.0 sends them: json.dumps of the user and the +# three filters, in that order, nulls included. +WITHOUT_CONTEXT = { + "user-key": Case( + call("get_user_permissions", "alice"), + b'{"user": {"key": "alice"}, "tenants": null, "resources": null, "resource_types": null}', + ), + "user-dict-and-filters": Case( + call( + "get_user_permissions", + {"key": "alice", "attributes": {"dept": "eng"}}, + ["t1"], + ["document:readme"], + ["document"], + ), + b'{"user": {"key": "alice", "attributes": {"dept": "eng"}}, "tenants": ["t1"], ' + b'"resources": ["document:readme"], "resource_types": ["document"]}', + ), + "context-none": Case( + call("get_user_permissions", "alice", tenants=["t1"], context=None), + b'{"user": {"key": "alice"}, "tenants": ["t1"], "resources": null, "resource_types": null}', + ), +} +WITH_CONTEXT = { + "context-keyword": Case( + call("get_user_permissions", "alice", context={"region": "us", "ip": "10.0.0.1"}), + b'{"user": {"key": "alice"}, "tenants": null, "resources": null, "resource_types": null, ' + b'"context": {"region": "us", "ip": "10.0.0.1"}}', + ), + "context-positional-with-filters": Case( + call("get_user_permissions", "alice", ["t1"], None, ["document"], {"time": 12}), + b'{"user": {"key": "alice"}, "tenants": ["t1"], "resources": null, ' + b'"resource_types": ["document"], "context": {"time": 12}}', + ), + "context-json-types": Case( + call( + "get_user_permissions", + "alice", + context={"ok": True, "n": 1.5, "none": None, "name": "ré", "list": [1, "a"]}, + ), + b'{"user": {"key": "alice"}, "tenants": null, "resources": null, "resource_types": null, ' + b'"context": {"ok": true, "n": 1.5, "none": null, "name": "r\\u00e9", "list": [1, "a"]}}', + ), + "context-empty": Case( + call("get_user_permissions", "alice", context={}), + b'{"user": {"key": "alice"}, "tenants": null, "resources": null, "resource_types": null, ' + b'"context": {}}', + ), +} + + +def invoke(config: PermitConfig, flavour: str, target: Call) -> object: + """Call ``permit.`` on the async or the blocking client.""" + permit = Permit(config) if flavour == "async" else SyncPermit(config) + result = attrgetter(target.path)(permit)(*target.args, **target.kwargs) + if flavour == "async": + return asyncio.run(result) + assert not inspect.isawaitable(result) + return result + + +def sent_bodies(httpserver: HTTPServer) -> list[bytes]: + return [request.get_data() for request, _ in httpserver.log] + + +def sent_headers(request: Request) -> dict[str, str | None]: + return {name: request.headers.get(name) for name in HEADERS} + + +@pytest.mark.parametrize("flavour", FLAVOURS) +@pytest.mark.parametrize( + "case", + [*WITHOUT_CONTEXT.values(), *WITH_CONTEXT.values()], + ids=[*WITHOUT_CONTEXT.keys(), *WITH_CONTEXT.keys()], +) +def test_get_user_permissions_sends_exactly_this_body( + httpserver: HTTPServer, config: PermitConfig, case: Case, flavour: str +) -> None: + httpserver.expect_request(USER_PERMISSIONS, method="POST").respond_with_json(PERMISSIONS) + + result = invoke(config, flavour, case.call) + + assert sent_bodies(httpserver) == [case.body] + assert [request.path for request, _ in httpserver.log] == [USER_PERMISSIONS] + assert [request.query_string for request, _ in httpserver.log] == [b""] + assert [sent_headers(request) for request, _ in httpserver.log] == [HEADERS] + assert result == PERMISSIONS + + +@pytest.mark.parametrize("case", WITHOUT_CONTEXT.values(), ids=WITHOUT_CONTEXT.keys()) +async def test_without_a_context_the_context_store_is_not_sent( + httpserver: HTTPServer, config: PermitConfig, case: Case +) -> None: + """The body stays what 3.0 sent, which never carried the context store's base context.""" + enforcer = Enforcer(config) + enforcer.context_store.add(STORE) + httpserver.expect_request(USER_PERMISSIONS, method="POST").respond_with_json(PERMISSIONS) + + await enforcer.get_user_permissions(*case.call.args, **case.call.kwargs) + + assert sent_bodies(httpserver) == [case.body] + + +@pytest.mark.parametrize( + ("context", "sent_context"), + [ + ({"flags": {"b": 2}}, b'{"region": "eu", "flags": {"a": 1, "b": 2}}'), + ({"region": "us"}, b'{"region": "us", "flags": {"a": 1}}'), + ({}, b'{"region": "eu", "flags": {"a": 1}}'), + ], + ids=["deep-merged", "query-wins", "empty-sends-the-store"], +) +async def test_a_context_is_merged_over_the_context_store_as_check_merges_it( + httpserver: HTTPServer, config: PermitConfig, context: dict[str, Any], sent_context: bytes +) -> None: + enforcer = Enforcer(config) + enforcer.context_store.add(STORE) + httpserver.expect_request(USER_PERMISSIONS, method="POST").respond_with_json(PERMISSIONS) + httpserver.expect_request("/allowed", method="POST").respond_with_json({"allow": True}) + + await enforcer.get_user_permissions("alice", context=context) + await enforcer.check("alice", "read", "document", context) + + (permissions_request, _), (check_request, _) = httpserver.log + assert permissions_request.get_data() == ( + b'{"user": {"key": "alice"}, "tenants": null, "resources": null, "resource_types": null, ' + b'"context": ' + sent_context + b"}" + ) + assert check_request.get_data().endswith(b'"context": ' + sent_context + b"}") + assert enforcer.context_store.get_derived_context({}) == STORE + + +@pytest.mark.parametrize("flavour", FLAVOURS) +def test_the_clients_merge_the_context_over_their_context_store( + httpserver: HTTPServer, config: PermitConfig, flavour: str +) -> None: + httpserver.expect_request(USER_PERMISSIONS, method="POST").respond_with_json(PERMISSIONS) + context = {"flags": {"b": 2}} + + if flavour == "async": + permit = Permit(config) + permit._enforcer.context_store.add(STORE) + asyncio.run(permit.get_user_permissions("alice", context=context)) + else: + sync_permit = SyncPermit(config) + sync_permit._enforcer.context_store.add(STORE) + sync_permit.get_user_permissions("alice", context=context) + + assert sent_bodies(httpserver) == [ + ( + b'{"user": {"key": "alice"}, "tenants": null, "resources": null, ' + b'"resource_types": null, "context": {"region": "eu", "flags": {"a": 1, "b": 2}}}' + ) + ] diff --git a/tests/type_check/consumer.py b/tests/type_check/consumer.py index 4fc86b70..dbfcf800 100644 --- a/tests/type_check/consumer.py +++ b/tests/type_check/consumer.py @@ -83,6 +83,10 @@ async def async_client() -> None: list[bool], ) assert_type(await permit.get_user_permissions("u"), dict[str, Any]) + assert_type( + await permit.get_user_permissions("u", ["t1"], context={"ip": "10.0.0.1"}), + dict[str, Any], + ) tenants = await permit.get_user_tenants("u") assert_type(tenants, list[TenantDetails]) assert_type(tenants[0].key, str) @@ -198,6 +202,7 @@ def sync_client() -> None: assert_type(permit.check("user", "read", "document"), bool) assert_type(permit.get_user_permissions("u"), dict[str, Any]) + assert_type(permit.get_user_permissions("u", context={"ip": "10.0.0.1"}), dict[str, Any]) assert_type(permit.get_user_tenants("u"), list[TenantDetails]) assert_type(permit.get_user_tenants({"key": "u"}, {"region": "eu"}), list[TenantDetails]) assert_type(permit.api.users.get("u"), UserRead) From dc8ee7c9ff73d511f6700c18749d6d3207b6ea2d Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:56:50 +0300 Subject: [PATCH 08/32] Record the requests the tests send, for the API coverage report tests/api_coverage_recorder.py is a pytest plugin that tests/conftest.py loads. Given a record file (--api-coverage-record or PERMIT_API_COVERAGE_RECORD), it adds an aiohttp trace config to every ClientSession and writes one JSON line per request: method, raw path, response status, test id and whether the test is marked e2e, then a session line with the exit status. Without a record file it does nothing. Part of PER-16337. Co-Authored-By: Claude Opus 5.5 --- tests/api_coverage_recorder.py | 169 +++++++++++++++++++++++ tests/conftest.py | 4 + tests/test_api_coverage_recorder.py | 203 ++++++++++++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 tests/api_coverage_recorder.py create mode 100644 tests/test_api_coverage_recorder.py diff --git a/tests/api_coverage_recorder.py b/tests/api_coverage_recorder.py new file mode 100644 index 00000000..ace98410 --- /dev/null +++ b/tests/api_coverage_recorder.py @@ -0,0 +1,169 @@ +"""A pytest plugin that records every HTTP request the SDK sends (PER-16337). + +The API coverage report (.github/scripts/api_coverage_report.py) learns which API +operation each SDK method calls from the requests the tests actually send: the +offline wire tests for the coverage column, the end-to-end tests for the column of +operations exercised against a real backend and PDP. + +The plugin is always loaded (tests/conftest.py names it in ``pytest_plugins``) and +does nothing unless a record file is given, with ``--api-coverage-record PATH`` or +the ``PERMIT_API_COVERAGE_RECORD`` environment variable (the option wins). A normal +test run is unchanged. + +When enabled, it adds an aiohttp trace config to every ``aiohttp.ClientSession`` +created during the session, which is how every SDK request is sent, through the +async and the blocking client alike. The record is JSON Lines: + +* a ``header`` line with the format version; +* one ``request`` line per request: the HTTP method, the raw (still percent-encoded) + URL path without its query string, the response status (null when no response + arrived), the test's node id and whether that test is marked ``e2e``; +* a ``session`` line written when the session finishes, with its exit status and + the number of tests that ran. A record without it comes from a session that did + not finish, and the report refuses to read it. + +Only the method and path are kept: the query string, headers and bodies stay out +of the record, and so out of the CI artifact it is uploaded as. +""" + +from __future__ import annotations + +import json +import os +import threading +from pathlib import Path +from typing import IO, TYPE_CHECKING, Any + +import aiohttp +import pytest + +if TYPE_CHECKING: + from collections.abc import Generator + from types import SimpleNamespace + + from yarl import URL + +RECORD_OPTION = "--api-coverage-record" +RECORD_ENV = "PERMIT_API_COVERAGE_RECORD" +# Bumped when a record line changes shape; the report rejects other versions. +FORMAT_VERSION = 1 +PLUGIN_NAME = "api-coverage-recorder" + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Add the option that turns recording on.""" + parser.getgroup("api coverage").addoption( + RECORD_OPTION, + metavar="PATH", + default=None, + help=( + "Write every HTTP request the SDK sends to PATH (JSON Lines), for the API " + f"coverage report. Also read from the {RECORD_ENV} environment variable." + ), + ) + + +def pytest_configure(config: pytest.Config) -> None: + """Start recording when a record file is given; otherwise do nothing.""" + target = config.getoption(RECORD_OPTION) or os.environ.get(RECORD_ENV) + if not target: + return + recorder = RequestRecorder(Path(target)) + config.pluginmanager.register(recorder, PLUGIN_NAME) + config.add_cleanup(recorder.close) + + +class RequestRecorder: + """Writes one record line per request, attributed to the test that sent it.""" + + def __init__(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + self._file: IO[str] | None = path.open("w", encoding="utf-8") + self._lock = threading.Lock() + self._test: str | None = None + self._e2e = False + self._tests = 0 + self._write({"kind": "header", "version": FORMAT_VERSION}) + + trace = aiohttp.TraceConfig() + trace.on_request_start.append(self._on_request_start) + trace.on_request_end.append(self._on_request_end) + trace.on_request_exception.append(self._on_request_exception) + original_init = aiohttp.ClientSession.__init__ + + def traced_init(session: aiohttp.ClientSession, *args: Any, **kwargs: Any) -> None: + configs = list(kwargs.pop("trace_configs", None) or []) + original_init(session, *args, trace_configs=[*configs, trace], **kwargs) + + self._patch = pytest.MonkeyPatch() + self._patch.setattr(aiohttp.ClientSession, "__init__", traced_init) + + def close(self) -> None: + """Stop tracing new sessions and close the record file.""" + self._patch.undo() + with self._lock: + if self._file is not None: + self._file.close() + self._file = None + + @pytest.hookimpl(wrapper=True) + def pytest_runtest_protocol(self, item: pytest.Item) -> Generator[None, object, object]: + """Attribute the requests of a test's setup, call and teardown to that test.""" + self._test = item.nodeid + self._e2e = item.get_closest_marker("e2e") is not None + self._tests += 1 + try: + return (yield) + finally: + self._test = None + self._e2e = False + + def pytest_sessionfinish(self, exitstatus: int) -> None: + """Write the line that marks the record as complete.""" + self._write({"kind": "session", "exitstatus": int(exitstatus), "tests": self._tests}) + + async def _on_request_start( + self, + _session: aiohttp.ClientSession, + context: SimpleNamespace, + params: aiohttp.TraceRequestStartParams, + ) -> None: + context.api_coverage = { + "method": params.method.upper(), + "path": _raw_path(params.url), + "test": self._test, + "e2e": self._e2e, + } + + async def _on_request_end( + self, + _session: aiohttp.ClientSession, + context: SimpleNamespace, + params: aiohttp.TraceRequestEndParams, + ) -> None: + self._write_request(context, params.response.status) + + async def _on_request_exception( + self, + _session: aiohttp.ClientSession, + context: SimpleNamespace, + _params: aiohttp.TraceRequestExceptionParams, + ) -> None: + self._write_request(context, None) + + def _write_request(self, context: SimpleNamespace, status: int | None) -> None: + started = getattr(context, "api_coverage", None) + if started is not None: + self._write({"kind": "request", **started, "status": status}) + + def _write(self, line: dict[str, Any]) -> None: + with self._lock: + if self._file is None: + return + self._file.write(json.dumps(line, sort_keys=True) + "\n") + self._file.flush() + + +def _raw_path(url: URL) -> str: + """The URL's path as sent, percent-encoding kept, so a ``%2F`` in a key stays one segment.""" + return url.raw_path diff --git a/tests/conftest.py b/tests/conftest.py index 9767dec4..50a64905 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,10 @@ from permit.sync import Permit as SyncPermit from tests.utils import CLOUD_PDP_URL, offline_config +# Records the requests the tests send, for the API coverage report. Inert unless a +# record file is given (see that module). +pytest_plugins = ("tests.api_coverage_recorder",) + # pytest_httpserver's `httpserver` fixture binds a free port chosen by the OS, # so parallel runs on one machine cannot collide. Tests reach it through # httpserver.url_for(), never a hardcoded port. Set PYTEST_HTTPSERVER_PORT to diff --git a/tests/test_api_coverage_recorder.py b/tests/test_api_coverage_recorder.py new file mode 100644 index 00000000..f4978b5c --- /dev/null +++ b/tests/test_api_coverage_recorder.py @@ -0,0 +1,203 @@ +"""Tests for the request recorder the API coverage report reads (tests/api_coverage_recorder.py). + +Each test runs a small pytest session in a fresh interpreter, with the recorder loaded as +a plugin, and checks the record it writes. A separate interpreter keeps those sessions' +requests out of this session's own record when the coverage job runs the suite with the +recorder on, and keeps the recorder's patch of aiohttp out of this process. +""" + +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Any + +import pytest + +from tests.api_coverage_recorder import FORMAT_VERSION, RECORD_ENV, RECORD_OPTION + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# The inner session's tests. Every request goes to pytest-httpserver's local server, +# except the one that is refused on purpose. +INNER_TESTS = textwrap.dedent( + """ + import asyncio + import re + from concurrent.futures import ThreadPoolExecutor + + import aiohttp + import pytest + from yarl import URL + + from permit.sync import Permit + from tests.utils import offline_config + + + async def send(method, url, **session_kwargs): + async with aiohttp.ClientSession(**session_kwargs) as session: + async with session.request(method, URL(url, encoded=True)) as response: + return response.status + + + @pytest.fixture(autouse=True) + def answer_everything(httpserver): + httpserver.expect_request(re.compile(".*")).respond_with_json({}, status=201) + + + def test_encoded_path(httpserver): + url = httpserver.url_for("/v2/users/a%2Fb") + "?secret=1" + assert asyncio.run(send("GET", url)) == 201 + + + @pytest.mark.e2e + def test_marked_e2e(httpserver): + asyncio.run(send("POST", httpserver.url_for("/allowed"))) + + + def test_in_another_thread(httpserver): + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(asyncio.run, send("DELETE", httpserver.url_for("/threaded"))).result() + + + def test_blocking_client(httpserver): + permit = Permit(offline_config(httpserver.url_for("").rstrip("/"))) + try: + permit.api.users.get("u1") + except Exception: + pass + + + def test_refused(): + with pytest.raises(aiohttp.ClientError): + asyncio.run(send("PUT", "http://127.0.0.1:1/refused")) + + + def test_own_trace_configs_still_run(httpserver): + seen = [] + + async def on_start(session, context, params): + seen.append(params.url.path) + + trace = aiohttp.TraceConfig() + trace.on_request_start.append(on_start) + asyncio.run(send("GET", httpserver.url_for("/traced"), trace_configs=[trace])) + assert seen == ["/traced"] + + + def test_fails(): + assert False + """ +) + +UNPATCHED = textwrap.dedent( + """ + import aiohttp + + + def test_aiohttp_is_untouched(pytestconfig): + assert aiohttp.ClientSession.__init__.__qualname__ == "ClientSession.__init__" + assert pytestconfig.pluginmanager.get_plugin("api-coverage-recorder") is None + """ +) + + +def run_session( + tmp_path: Path, tests: str, *args: str, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + """Run tests in a pytest session of their own, with the recorder plugin loaded.""" + (tmp_path / "test_inner.py").write_text(tests, encoding="utf-8") + (tmp_path / "pytest.ini").write_text("[pytest]\nmarkers =\n e2e: marked\n", encoding="utf-8") + environment = {key: value for key, value in os.environ.items() if key != RECORD_ENV} + environment["PYTHONPATH"] = os.pathsep.join( + filter(None, [str(REPO_ROOT), environment.get("PYTHONPATH")]) + ) + environment.update(env or {}) + return subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-p", + "tests.api_coverage_recorder", + "-p", + "no:cacheprovider", + "-c", + str(tmp_path / "pytest.ini"), + "--rootdir", + str(tmp_path), + "-q", + str(tmp_path / "test_inner.py"), + *args, + ], + capture_output=True, + text=True, + check=False, + cwd=tmp_path, + env=environment, + timeout=120, + ) + + +def read_record(path: Path) -> list[dict[str, Any]]: + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + +def test_the_record_holds_every_request_with_the_test_that_sent_it(tmp_path: Path) -> None: + record = tmp_path / "out" / "record.jsonl" + ignored = tmp_path / "from-env.jsonl" + completed = run_session( + tmp_path, INNER_TESTS, RECORD_OPTION, str(record), env={RECORD_ENV: str(ignored)} + ) + assert completed.returncode == 1, completed.stdout + completed.stderr + assert "1 failed, 6 passed" in completed.stdout + assert not ignored.exists(), "the option must win over the environment variable" + + lines = read_record(record) + assert lines[0] == {"kind": "header", "version": FORMAT_VERSION} + assert lines[-1] == {"kind": "session", "exitstatus": 1, "tests": 7} + requests = [(line["test"].split("::")[-1], line) for line in lines[1:-1]] + test_file = "test_inner.py" + assert all(line["test"].startswith(test_file) for _, line in requests) + assert [ + (test, line["method"], line["path"], line["status"], line["e2e"]) for test, line in requests + ] == [ + ("test_encoded_path", "GET", "/v2/users/a%2Fb", 201, False), + ("test_marked_e2e", "POST", "/allowed", 201, True), + ("test_in_another_thread", "DELETE", "/threaded", 201, False), + ("test_blocking_client", "GET", "/v2/facts/test-project/test-env/users/u1", 201, False), + ("test_refused", "PUT", "/refused", None, False), + ("test_own_trace_configs_still_run", "GET", "/traced", 201, False), + ] + assert {key for _, line in requests for key in line} == { + "kind", + "method", + "path", + "status", + "test", + "e2e", + } + + +def test_the_environment_variable_turns_recording_on(tmp_path: Path) -> None: + record = tmp_path / "record.jsonl" + completed = run_session(tmp_path, INNER_TESTS, "-k", "encoded", env={RECORD_ENV: str(record)}) + assert completed.returncode == 0, completed.stdout + completed.stderr + lines = read_record(record) + assert [line["kind"] for line in lines] == ["header", "request", "session"] + assert lines[1]["path"] == "/v2/users/a%2Fb" + assert lines[2] == {"kind": "session", "exitstatus": 0, "tests": 1} + + +def test_without_a_record_file_the_recorder_does_nothing(tmp_path: Path) -> None: + completed = run_session(tmp_path, UNPATCHED) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert list(tmp_path.glob("**/*.jsonl")) == [] + + +def test_the_suite_loads_the_recorder(pytestconfig: pytest.Config) -> None: + """tests/conftest.py registers the plugin, so the coverage job's option exists.""" + assert pytestconfig.pluginmanager.get_plugin("tests.api_coverage_recorder") is not None + assert pytestconfig.getoption(RECORD_OPTION, default="unregistered") != "unregistered" From 4cda6120e56fdbc91cbf252bf59856d3bbee84a6 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:57:06 +0300 Subject: [PATCH 09/32] Add the API coverage report, its spec snapshots and allowlist .github/scripts/api_coverage.py matches each request in a test record to an operation of the control-plane and container PDP specs, and lists covered, missing, allowlisted and SDK-only operations by GA, EAP and deprecated, with an end-to-end column filled from e2e records. It exits 1 on a GA operation neither covered nor allowlisted, a stale or changed allowlist entry, or an unexplained SDK-only request, and 2 when it did not run. Its `snapshot` command writes a spec's operation inventory. .github/api-specs/ holds the inventories of the control-plane spec and of the pinned PDP image's spec, each with its source and fetch date. The allowlist gives every operation left out on purpose a status and a reason: PER-16337's EXCLUDE and DEFER lists, the P2 items deferred to PER-16737, and the operations an SDK method sends that no offline test covers yet (PER-16177). The Audit Script Tests job runs its tests. Part of PER-16337. Co-Authored-By: Claude Opus 5.5 --- .github/api-specs/control-plane.json | 2176 +++++++++++++++++++ .github/api-specs/control-plane.source.json | 5 + .github/api-specs/pdp.json | 306 +++ .github/api-specs/pdp.source.json | 5 + .github/scripts/api_coverage.py | 1313 +++++++++++ .github/scripts/api_coverage_allowlist.json | 1876 ++++++++++++++++ .github/scripts/test_api_coverage.py | 1086 +++++++++ .github/workflows/security.yml | 6 +- 8 files changed, 6771 insertions(+), 2 deletions(-) create mode 100644 .github/api-specs/control-plane.json create mode 100644 .github/api-specs/control-plane.source.json create mode 100644 .github/api-specs/pdp.json create mode 100644 .github/api-specs/pdp.source.json create mode 100755 .github/scripts/api_coverage.py create mode 100644 .github/scripts/api_coverage_allowlist.json create mode 100644 .github/scripts/test_api_coverage.py diff --git a/.github/api-specs/control-plane.json b/.github/api-specs/control-plane.json new file mode 100644 index 00000000..5f039b4a --- /dev/null +++ b/.github/api-specs/control-plane.json @@ -0,0 +1,2176 @@ +{ + "info": { + "title": "Permit.io API", + "version": "2.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/v2/activity": { + "get": { + "operationId": "list_activity_events", + "summary": "List Activity Events", + "tags": [ + "Activity Log" + ] + } + }, + "/v2/activity/types": { + "get": { + "operationId": "list_activity_types", + "summary": "List Activity Types", + "tags": [ + "Activity Log" + ] + } + }, + "/v2/api-key": { + "get": { + "operationId": "list_api_keys", + "summary": "List Api Keys", + "tags": [ + "API Keys" + ] + }, + "post": { + "operationId": "create_api_key", + "summary": "Create Api Key", + "tags": [ + "API Keys" + ] + } + }, + "/v2/api-key/scope": { + "get": { + "operationId": "get_api_key_scope", + "summary": "Get Api Key Scope", + "tags": [ + "API Keys" + ] + } + }, + "/v2/api-key/{api_key_id}": { + "delete": { + "operationId": "delete_api_key", + "summary": "Delete Api Key", + "tags": [ + "API Keys" + ] + }, + "get": { + "operationId": "get_api_key", + "summary": "Get Api Key", + "tags": [ + "API Keys" + ] + } + }, + "/v2/api-key/{api_key_id}/rotate-secret": { + "post": { + "operationId": "rotate_api_key", + "summary": "Rotate API Key", + "tags": [ + "API Keys" + ] + } + }, + "/v2/api-key/{proj_id}/{env_id}": { + "get": { + "operationId": "get_environment_api_key", + "summary": "Get Environment Api Key", + "tags": [ + "API Keys" + ] + } + }, + "/v2/audit-log-replay": { + "post": { + "operationId": "run_audit_log_replay", + "summary": "Run the audit log replay", + "tags": [ + "Audit Log Replay" + ] + } + }, + "/v2/data-export": { + "post": { + "operationId": "start_organization_export", + "summary": "Start an export of your organization's data", + "tags": [ + "Data Export" + ] + } + }, + "/v2/data-export/{task_id}": { + "get": { + "operationId": "get_organization_export", + "summary": "Get the status / download URL of an export", + "tags": [ + "Data Export" + ] + } + }, + "/v2/deprecated/activity": { + "get": { + "deprecated": true, + "operationId": "list_activity_events_v2_deprecated_activity_get", + "summary": "List Activity Events", + "tags": [] + } + }, + "/v2/deprecated/activity/types": { + "get": { + "deprecated": true, + "operationId": "list_activity_types_v2_deprecated_activity_types_get", + "summary": "List Activity Types", + "tags": [] + } + }, + "/v2/deprecated/history": { + "get": { + "deprecated": true, + "operationId": "list_api_events", + "summary": "List Api Events", + "tags": [] + } + }, + "/v2/deprecated/history/{event_id}": { + "get": { + "deprecated": true, + "operationId": "get_api_event", + "summary": "Get Api Event", + "tags": [] + } + }, + "/v2/deprecated/history/{event_id}/request": { + "get": { + "deprecated": true, + "operationId": "get_request_body", + "summary": "Get Request Body", + "tags": [] + } + }, + "/v2/deprecated/history/{event_id}/response": { + "get": { + "deprecated": true, + "operationId": "get_response_body", + "summary": "Get Response Body", + "tags": [] + } + }, + "/v2/elements/{proj_id}/{env_id}/config": { + "get": { + "operationId": "list_elements_configs", + "summary": "List Elements Configs", + "tags": [ + "Elements Configs (EAP)" + ] + }, + "post": { + "operationId": "create_elements_config", + "summary": "Create Elements Config", + "tags": [ + "Elements Configs (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}": { + "get": { + "operationId": "get_elements_config", + "summary": "Get Elements Config", + "tags": [ + "Elements Configs (EAP)" + ] + }, + "patch": { + "operationId": "update_elements_config", + "summary": "Update Elements Config", + "tags": [ + "Elements Configs (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests": { + "get": { + "operationId": "list_access_requests_v2_elements__proj_id___env_id__config__elements_config_id__access_requests_get", + "summary": "List Access Requests", + "tags": [ + "Access Requests (EAP)" + ] + }, + "post": { + "operationId": "create_access_request_v2_elements__proj_id___env_id__config__elements_config_id__access_requests_post", + "summary": "Create Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}": { + "get": { + "operationId": "get_access_request_v2_elements__proj_id___env_id__config__elements_config_id__access_requests__access_request_id__get", + "summary": "Get Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}/approve": { + "put": { + "operationId": "approve_access_request_v2_elements__proj_id___env_id__config__elements_config_id__access_requests__access_request_id__approve_put", + "summary": "Approve Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}/cancel": { + "put": { + "operationId": "cancel_access_request_v2_elements__proj_id___env_id__config__elements_config_id__access_requests__access_request_id__cancel_put", + "summary": "Cancel Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}/deny": { + "put": { + "operationId": "deny_access_request_v2_elements__proj_id___env_id__config__elements_config_id__access_requests__access_request_id__deny_put", + "summary": "Deny Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}/reviewer": { + "patch": { + "operationId": "update_access_request_reviewer_v2_elements__proj_id___env_id__config__elements_config_id__access_requests__access_request_id__reviewer_patch", + "summary": "Update Access Request Reviewer", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/active": { + "post": { + "operationId": "set_config_active", + "summary": "Set Config Active", + "tags": [ + "Users Elements Data" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/audit_logs": { + "get": { + "operationId": "elements_list_audit_logs", + "summary": "List audit logs", + "tags": [ + "Audit Elements Data" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/roles": { + "get": { + "operationId": "elements_list_roles", + "summary": "List roles", + "tags": [ + "Users Elements Data" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/user-invites": { + "get": { + "operationId": "list_elements_user_invites", + "summary": "List all Elements User Invites", + "tags": [ + "Users Elements Data" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/users": { + "get": { + "operationId": "elements_list_users", + "summary": "List users", + "tags": [ + "Users Elements Data" + ] + }, + "post": { + "operationId": "elements_create_user", + "summary": "Create user", + "tags": [ + "Users Elements Data" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/users/{user_id}": { + "delete": { + "operationId": "elements_delete_user", + "summary": "Delete user", + "tags": [ + "Users Elements Data" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/users/{user_id}/roles": { + "delete": { + "operationId": "elements_unassign_role_from_user", + "summary": "Unassign role from user", + "tags": [ + "Users Elements Data" + ] + }, + "post": { + "operationId": "elements_assign_role_to_user", + "summary": "Assign role to user", + "tags": [ + "Users Elements Data" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval": { + "get": { + "operationId": "list_operation_approvals", + "summary": "List Operation Approvals", + "tags": [ + "Operation Approval (EAP)" + ] + }, + "post": { + "operationId": "create_operation_approval", + "summary": "Create Operation Approval", + "tags": [ + "Operation Approval (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}": { + "get": { + "operationId": "get_operation_approval", + "summary": "Get Operation Approval", + "tags": [ + "Operation Approval (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}/approve": { + "put": { + "operationId": "approve_operation_approval", + "summary": "Approve Operation Approval", + "tags": [ + "Operation Approval (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}/cancel": { + "put": { + "operationId": "cancel_operation_approval", + "summary": "Cancel Operation Approval", + "tags": [ + "Operation Approval (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}/deny": { + "put": { + "operationId": "deny_operation_approval", + "summary": "Deny Operation Approval", + "tags": [ + "Operation Approval (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}/reviewer": { + "patch": { + "operationId": "update_operation_approval_reviewer", + "summary": "Update Operation Approval Reviewer", + "tags": [ + "Operation Approval (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/runtime": { + "get": { + "operationId": "get_elements_config_runtime", + "summary": "Get Elements Config Runtime", + "tags": [ + "Elements Configs (EAP)" + ] + } + }, + "/v2/elements/{proj_id}/{env_id}/{elements_config_id}": { + "delete": { + "operationId": "delete_elements_config", + "summary": "Delete Elements Config", + "tags": [ + "Elements Configs (EAP)" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}": { + "get": { + "operationId": "list_access_requests", + "summary": "List Access Requests", + "tags": [ + "Access Requests (EAP)" + ] + }, + "post": { + "operationId": "create_access_request", + "summary": "Create Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}": { + "get": { + "operationId": "get_access_request", + "summary": "Get Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}/approve": { + "put": { + "operationId": "approve_access_request", + "summary": "Approve Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}/cancel": { + "put": { + "operationId": "cancel_access_request", + "summary": "Cancel Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}/deny": { + "put": { + "operationId": "deny_access_request", + "summary": "Deny Access Request", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}/reviewer": { + "patch": { + "operationId": "update_access_request_reviewer", + "summary": "Update Access Request Reviewer", + "tags": [ + "Access Requests (EAP)" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/bulk/resource_instances": { + "delete": { + "operationId": "bulk_delete_resource_instances", + "summary": "Bulk Delete Resource Instances", + "tags": [ + "Bulk Operations" + ] + }, + "put": { + "operationId": "bulk_replace_resource_instances", + "summary": "Bulk Replace Resource Instances", + "tags": [ + "Bulk Operations" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/bulk/tenants": { + "delete": { + "operationId": "bulk_delete_tenants", + "summary": "Bulk Delete Tenants", + "tags": [ + "Bulk Operations" + ] + }, + "post": { + "operationId": "bulk_create_tenants", + "summary": "Bulk Create Tenants", + "tags": [ + "Bulk Operations" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/bulk/users": { + "delete": { + "operationId": "bulk_delete_users", + "summary": "Bulk Delete Users", + "tags": [ + "Bulk Operations" + ] + }, + "post": { + "operationId": "bulk_create_users", + "summary": "Bulk Create Users", + "tags": [ + "Bulk Operations" + ] + }, + "put": { + "operationId": "bulk_replace_users", + "summary": "Bulk Replace Users", + "tags": [ + "Bulk Operations" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/email_configurations": { + "get": { + "operationId": "get_email_configuration", + "summary": "Get Email Configuration", + "tags": [ + "Email Configurations" + ] + }, + "post": { + "operationId": "create_or_update_email_configuration", + "summary": "Create Or Update Email Configuration", + "tags": [ + "Email Configurations" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/email_configurations/send_test_email": { + "post": { + "operationId": "send_test_email", + "summary": "Send Test Email", + "tags": [ + "Email Configurations" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/email_templates/": { + "get": { + "operationId": "list_templates", + "summary": "List Templates", + "tags": [ + "Email Templates" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/email_templates/{template_type}": { + "get": { + "operationId": "get_template_by_type", + "summary": "Get Template By Type", + "tags": [ + "Email Templates" + ] + }, + "post": { + "operationId": "update_template_by_type", + "summary": "Update Template By Type", + "tags": [ + "Email Templates" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/email_templates/{template_type}/send_test_email": { + "post": { + "operationId": "send_test_email_by_type", + "summary": "Send Test Email By Type", + "tags": [ + "Email Templates" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/proxy_configs": { + "get": { + "operationId": "list_proxy_configs", + "summary": "List Proxy Configs", + "tags": [ + "Proxy Config" + ] + }, + "post": { + "operationId": "create_proxy_config", + "summary": "Create Proxy Config", + "tags": [ + "Proxy Config" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/proxy_configs/{proxy_config_id}": { + "delete": { + "operationId": "delete_proxy_config", + "summary": "Delete Proxy Config", + "tags": [ + "Proxy Config" + ] + }, + "get": { + "operationId": "get_proxy_config", + "summary": "Get Proxy Config", + "tags": [ + "Proxy Config" + ] + }, + "patch": { + "operationId": "update_proxy_config", + "summary": "Update Proxy Config", + "tags": [ + "Proxy Config" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/relationship_tuples": { + "delete": { + "operationId": "delete_relationship_tuple", + "summary": "Delete Relationship Tuple", + "tags": [ + "Relationship tuples" + ] + }, + "get": { + "operationId": "list_relationship_tuples", + "summary": "List Relationship Tuples", + "tags": [ + "Relationship tuples" + ] + }, + "post": { + "operationId": "create_relationship_tuple", + "summary": "Create Relationship Tuple", + "tags": [ + "Relationship tuples" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/relationship_tuples/bulk": { + "delete": { + "operationId": "bulk_delete_relationship_tuples", + "summary": "Bulk Delete Relationship Tuples", + "tags": [ + "Relationship tuples" + ] + }, + "post": { + "operationId": "bulk_create_relationship_tuples", + "summary": "Bulk create relationship tuples", + "tags": [ + "Relationship tuples" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/relationship_tuples/detailed": { + "get": { + "operationId": "list_relationship_tuples_detailed", + "summary": "List Relationship Tuples Detailed", + "tags": [ + "Relationship tuples" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/resource_instances": { + "get": { + "operationId": "list_resource_instances", + "summary": "List Resource Instances", + "tags": [ + "Resource Instances" + ] + }, + "post": { + "operationId": "create_resource_instance", + "summary": "Create Resource Instance", + "tags": [ + "Resource Instances" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/resource_instances/detailed": { + "get": { + "operationId": "list_resource_instances_detailed", + "summary": "List Resource Instances Detailed", + "tags": [ + "Resource Instances" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/resource_instances/{instance_id}": { + "delete": { + "operationId": "delete_resource_instance", + "summary": "Delete Resource Instance", + "tags": [ + "Resource Instances" + ] + }, + "get": { + "operationId": "get_resource_instance", + "summary": "Get Resource Instance", + "tags": [ + "Resource Instances" + ] + }, + "patch": { + "operationId": "update_resource_instance", + "summary": "Update Resource Instance", + "tags": [ + "Resource Instances" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/role_assignments": { + "delete": { + "operationId": "unassign_role", + "summary": "Unassign Role", + "tags": [ + "Role Assignments" + ] + }, + "get": { + "operationId": "list_role_assignments", + "summary": "List Role Assignments", + "tags": [ + "Role Assignments" + ] + }, + "post": { + "operationId": "assign_role", + "summary": "Assign Role", + "tags": [ + "Role Assignments" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/role_assignments/bulk": { + "delete": { + "operationId": "bulk_unassign_role", + "summary": "Bulk Unassign Role", + "tags": [ + "Role Assignments", + "Bulk Operations" + ] + }, + "post": { + "operationId": "bulk_assign_role", + "summary": "Bulk create role assignments", + "tags": [ + "Role Assignments", + "Bulk Operations" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/role_assignments/detailed": { + "get": { + "operationId": "list_role_assignments_detailed", + "summary": "List Role Assignments Detailed", + "tags": [ + "Role Assignments" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/set_rules": { + "delete": { + "operationId": "unassign_set_permissions", + "summary": "Unassign Set Permissions", + "tags": [ + "Condition Set Rules" + ] + }, + "get": { + "operationId": "list_set_permissions", + "summary": "List Set Permissions", + "tags": [ + "Condition Set Rules" + ] + }, + "post": { + "operationId": "assign_set_permissions", + "summary": "Assign Set Permissions", + "tags": [ + "Condition Set Rules" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/tenants": { + "get": { + "operationId": "list_tenants", + "summary": "List Tenants", + "tags": [ + "Tenants" + ] + }, + "post": { + "operationId": "create_tenant", + "summary": "Create Tenant", + "tags": [ + "Tenants" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}": { + "delete": { + "operationId": "delete_tenant", + "summary": "Delete Tenant", + "tags": [ + "Tenants" + ] + }, + "get": { + "operationId": "get_tenant", + "summary": "Get Tenant", + "tags": [ + "Tenants" + ] + }, + "patch": { + "operationId": "update_tenant", + "summary": "Update Tenant", + "tags": [ + "Tenants" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}/users": { + "get": { + "operationId": "list_tenant_users", + "summary": "List Tenant Users", + "tags": [ + "Tenants" + ] + }, + "post": { + "operationId": "add_user_to_tenant", + "summary": "Add User To Tenant", + "tags": [ + "Tenants" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}/users/{user_id}": { + "delete": { + "operationId": "delete_tenant_user", + "summary": "Delete Tenant User", + "tags": [ + "Tenants" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/user_invites": { + "get": { + "operationId": "list_user_invites", + "summary": "List User Invites", + "tags": [ + "User Invites" + ] + }, + "post": { + "operationId": "create_user_invite", + "summary": "Create User Invite", + "tags": [ + "User Invites" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/user_invites/{user_invite_id}": { + "delete": { + "operationId": "delete_user_invite", + "summary": "Delete User Invite", + "tags": [ + "User Invites" + ] + }, + "get": { + "operationId": "get_user_invite", + "summary": "Get User Invite", + "tags": [ + "User Invites" + ] + }, + "patch": { + "operationId": "update_user_invite", + "summary": "Update User Invite", + "tags": [ + "User Invites" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/user_invites/{user_invite_id}/approve": { + "post": { + "operationId": "approve_user_invite", + "summary": "Approve User Invite", + "tags": [ + "User Invites" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/users": { + "get": { + "operationId": "list_users", + "summary": "List Users", + "tags": [ + "Users" + ] + }, + "post": { + "operationId": "create_user", + "summary": "Create User", + "tags": [ + "Users" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/users/{user_id}": { + "delete": { + "operationId": "delete_user", + "summary": "Delete User", + "tags": [ + "Users" + ] + }, + "get": { + "operationId": "get_user", + "summary": "Get User", + "tags": [ + "Users" + ] + }, + "patch": { + "operationId": "update_user", + "summary": "Update User", + "tags": [ + "Users" + ] + }, + "put": { + "operationId": "replace_user", + "summary": "Replace User", + "tags": [ + "Users" + ] + } + }, + "/v2/facts/{proj_id}/{env_id}/users/{user_id}/roles": { + "delete": { + "operationId": "unassign_role_from_user", + "summary": "Unassign Role From User", + "tags": [ + "Users" + ] + }, + "post": { + "operationId": "assign_role_to_user", + "summary": "Assign Role To User", + "tags": [ + "Users" + ] + } + }, + "/v2/history": { + "get": { + "operationId": "list_api_events_v2_history_get", + "summary": "List Api Events", + "tags": [ + "API History" + ] + } + }, + "/v2/history/{event_id}": { + "get": { + "operationId": "get_api_event_v2_history__event_id__get", + "summary": "Get Api Event", + "tags": [ + "API History" + ] + } + }, + "/v2/history/{event_id}/request": { + "get": { + "operationId": "get_request_body_v2_history__event_id__request_get", + "summary": "Get Request Body", + "tags": [ + "API History" + ] + } + }, + "/v2/history/{event_id}/response": { + "get": { + "operationId": "get_response_body_v2_history__event_id__response_get", + "summary": "Get Response Body", + "tags": [ + "API History" + ] + } + }, + "/v2/internal/opal_data/{org_id}/{proj_id}/{env_id}": { + "get": { + "operationId": "get_all_data", + "summary": "Get All Data", + "tags": [ + "OPAL Data ( EAP )" + ] + } + }, + "/v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/optimized": { + "get": { + "operationId": "get_all_data_optimized", + "summary": "Get All Data Optimized", + "tags": [ + "OPAL Data ( EAP )" + ] + } + }, + "/v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/relationships": { + "get": { + "operationId": "get_all_relationships_data", + "summary": "Get All Relationships Data", + "tags": [ + "OPAL Data ( EAP )" + ] + } + }, + "/v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/resource_instances": { + "get": { + "operationId": "get_all_resource_instances_data", + "summary": "Get All Resource Instances Data", + "tags": [ + "OPAL Data ( EAP )" + ] + } + }, + "/v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/role_assignments": { + "get": { + "operationId": "get_all_role_assignments_data", + "summary": "Get All Role Assignments Data", + "tags": [ + "OPAL Data ( EAP )" + ] + } + }, + "/v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/users": { + "get": { + "operationId": "get_all_users_data", + "summary": "Get All Users Data", + "tags": [ + "OPAL Data ( EAP )" + ] + } + }, + "/v2/members": { + "delete": { + "operationId": "delete_organization_permissions", + "summary": "Remove permission", + "tags": [ + "Members" + ] + }, + "get": { + "operationId": "list_organization_members", + "summary": "List Organization Members", + "tags": [ + "Members" + ] + }, + "post": { + "operationId": "create_organization_members", + "summary": "Invite new members", + "tags": [ + "Members" + ] + } + }, + "/v2/members/me": { + "get": { + "operationId": "get_authenticated_member", + "summary": "Get the authenticated account member", + "tags": [ + "Members" + ] + } + }, + "/v2/members/{member_id}": { + "delete": { + "operationId": "delete_organization_member", + "summary": "Remove member", + "tags": [ + "Members" + ] + }, + "get": { + "operationId": "get_organization_member", + "summary": "Get Organization Member", + "tags": [ + "Members" + ] + }, + "patch": { + "operationId": "update_organization_member", + "summary": "Edit members", + "tags": [ + "Members" + ] + } + }, + "/v2/orgs": { + "get": { + "operationId": "list_organizations", + "summary": "List Organizations", + "tags": [ + "Organizations" + ] + }, + "post": { + "operationId": "create_organization", + "summary": "Create Organization", + "tags": [ + "Organizations" + ] + } + }, + "/v2/orgs/active/org": { + "get": { + "operationId": "get_active_organization", + "summary": "Get Active Organization", + "tags": [ + "Organizations" + ] + } + }, + "/v2/orgs/{org_id}": { + "delete": { + "operationId": "delete_organization", + "summary": "Delete Organization", + "tags": [ + "Organizations" + ] + }, + "get": { + "operationId": "get_organization", + "summary": "Get Organization", + "tags": [ + "Organizations" + ] + }, + "patch": { + "operationId": "update_organization", + "summary": "Update Organization", + "tags": [ + "Organizations" + ] + } + }, + "/v2/orgs/{org_id}/invites": { + "get": { + "operationId": "list_organization_invites", + "summary": "List Organization Invites", + "tags": [ + "Invites" + ] + }, + "post": { + "operationId": "invite_members_to_organization", + "summary": "Invite Members To Organization", + "tags": [ + "Invites" + ] + } + }, + "/v2/orgs/{org_id}/invites/{invite_id}": { + "delete": { + "operationId": "cancel_invite", + "summary": "Cancel Invite", + "tags": [ + "Invites" + ] + } + }, + "/v2/orgs/{org_id}/restore": { + "post": { + "operationId": "restore_organization", + "summary": "Restore Organization", + "tags": [ + "Organizations" + ] + } + }, + "/v2/orgs/{org_id}/stats": { + "get": { + "operationId": "stats_organization", + "summary": "Stats Organization", + "tags": [ + "Organizations" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/audit_logs": { + "get": { + "operationId": "list_audit_logs", + "summary": "List Audit Logs", + "tags": [ + "Audit Logs" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/audit_logs/{log_id}": { + "get": { + "operationId": "get_detailed_audit_log", + "summary": "Get detailed audit log", + "tags": [ + "Audit Logs" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/configs": { + "get": { + "operationId": "list_pdp_configs", + "summary": "List PDP configurations", + "tags": [ + "Policy Decision Points" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/configs/migrate-shards": { + "post": { + "operationId": "migrate_shards", + "summary": "Migrate PDP Config number of shards", + "tags": [ + "Policy Decision Points" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/configs/refresh": { + "post": { + "operationId": "refresh_environment_pdp_data", + "summary": "Refresh data for all PDPs in the environment", + "tags": [ + "Policy Decision Points" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/debug-audit-logs/disable": { + "put": { + "operationId": "disable_debug_audit_logs", + "summary": "Disable debug audit logs", + "tags": [ + "Policy Decision Points" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/debug-audit-logs/enable": { + "put": { + "operationId": "enable_debug_audit_logs", + "summary": "Enable debug audit logs", + "tags": [ + "Policy Decision Points" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/refresh": { + "post": { + "operationId": "refresh_pdp_data", + "summary": "Refresh PDP data", + "tags": [ + "Policy Decision Points" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/rotate-api-key": { + "post": { + "operationId": "rotate_pdp_api_key", + "summary": "Rotate PDP API Key", + "tags": [ + "Policy Decision Points" + ] + } + }, + "/v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/values": { + "get": { + "operationId": "get_pdp_config_values", + "summary": "Get PDP configuration", + "tags": [ + "Policy Decision Points" + ] + } + }, + "/v2/policy_guards/scopes": { + "get": { + "operationId": "list_policy_guard_scopes", + "summary": "List Policy Guard Scopes", + "tags": [ + "Policy Guards (EAP)" + ] + }, + "post": { + "operationId": "create_policy_guard_scope", + "summary": "Create Policy Guard Scope", + "tags": [ + "Policy Guards (EAP)" + ] + } + }, + "/v2/policy_guards/scopes/{policy_guard_scope_id}": { + "delete": { + "operationId": "delete_policy_guard_scope", + "summary": "Delete Policy Guard Scope", + "tags": [ + "Policy Guards (EAP)" + ] + }, + "get": { + "operationId": "get_policy_guard_scope", + "summary": "Get Policy Guard Scope", + "tags": [ + "Policy Guards (EAP)" + ] + } + }, + "/v2/policy_guards/scopes/{policy_guard_scope_id}/associate": { + "post": { + "operationId": "associate_policy_guard_scope", + "summary": "Associate Policy Guard Scope", + "tags": [ + "Policy Guards (EAP)" + ] + } + }, + "/v2/policy_guards/scopes/{policy_guard_scope_id}/disassociate": { + "delete": { + "operationId": "disassociate_policy_guard_scope", + "summary": "Disassociate Policy Guard Scope", + "tags": [ + "Policy Guards (EAP)" + ] + } + }, + "/v2/policy_guards/scopes/{policy_guard_scope_id}/rules": { + "delete": { + "operationId": "delete_policy_guard_rule", + "summary": "Delete Policy Guard Rule", + "tags": [ + "Policy Guards (EAP)" + ] + }, + "get": { + "operationId": "list_policy_guard_rules", + "summary": "List Policy Guard Rules", + "tags": [ + "Policy Guards (EAP)" + ] + }, + "post": { + "operationId": "create_policy_guard_rule", + "summary": "Create Policy Guard Rule", + "tags": [ + "Policy Guards (EAP)" + ] + } + }, + "/v2/projects": { + "get": { + "operationId": "list_projects", + "summary": "List Projects", + "tags": [ + "Projects" + ] + }, + "post": { + "operationId": "create_project", + "summary": "Create Project", + "tags": [ + "Projects" + ] + } + }, + "/v2/projects/{proj_id}": { + "delete": { + "operationId": "delete_project", + "summary": "Delete Project", + "tags": [ + "Projects" + ] + }, + "get": { + "operationId": "get_project", + "summary": "Get Project", + "tags": [ + "Projects" + ] + }, + "patch": { + "operationId": "update_project", + "summary": "Update Project", + "tags": [ + "Projects" + ] + } + }, + "/v2/projects/{proj_id}/envs": { + "get": { + "operationId": "list_environments", + "summary": "List Environments", + "tags": [ + "Environments" + ] + }, + "post": { + "operationId": "create_environment", + "summary": "Create Environment", + "tags": [ + "Environments" + ] + } + }, + "/v2/projects/{proj_id}/envs/{env_id}": { + "delete": { + "operationId": "delete_environment", + "summary": "Delete Environment", + "tags": [ + "Environments" + ] + }, + "get": { + "operationId": "get_environment", + "summary": "Get Environment", + "tags": [ + "Environments" + ] + }, + "patch": { + "operationId": "update_environment", + "summary": "Update Environment", + "tags": [ + "Environments" + ] + } + }, + "/v2/projects/{proj_id}/envs/{env_id}/copy": { + "post": { + "operationId": "copy_environment", + "summary": "Copy Environment", + "tags": [ + "Environments" + ] + } + }, + "/v2/projects/{proj_id}/envs/{env_id}/copy/async": { + "post": { + "operationId": "copy_environment_async", + "summary": "Copy Environment Async", + "tags": [ + "Environments" + ] + } + }, + "/v2/projects/{proj_id}/envs/{env_id}/copy/async/{task_id}/result": { + "get": { + "operationId": "get_copy_environment_async_result", + "summary": "Get Copy Environment Task Result", + "tags": [ + "Environments" + ] + } + }, + "/v2/projects/{proj_id}/envs/{env_id}/stats": { + "get": { + "operationId": "stats_environments", + "summary": "Stats Environments", + "tags": [ + "Environments" + ] + } + }, + "/v2/projects/{proj_id}/envs/{env_id}/test_jwks": { + "post": { + "operationId": "test_jwks_by_url", + "summary": "Test Jwks By Url", + "tags": [ + "Environments" + ] + } + }, + "/v2/projects/{proj_id}/repos": { + "get": { + "operationId": "list_policy_repos", + "summary": "List Policy Repos", + "tags": [ + "Policy Git Repositories" + ] + }, + "post": { + "operationId": "create_policy_repo", + "summary": "Create Policy Repo", + "tags": [ + "Policy Git Repositories" + ] + } + }, + "/v2/projects/{proj_id}/repos/active": { + "get": { + "operationId": "get_active_policy_repo", + "summary": "Get Active Policy Repo", + "tags": [ + "Policy Git Repositories" + ] + } + }, + "/v2/projects/{proj_id}/repos/disable": { + "put": { + "operationId": "disable_active_policy_repo", + "summary": "Disable Active Policy Repo", + "tags": [ + "Policy Git Repositories" + ] + } + }, + "/v2/projects/{proj_id}/repos/{repo_id}": { + "delete": { + "operationId": "delete_policy_repo", + "summary": "Delete Policy Repo", + "tags": [ + "Policy Git Repositories" + ] + }, + "get": { + "operationId": "get_policy_repo", + "summary": "Get Policy Repo", + "tags": [ + "Policy Git Repositories" + ] + } + }, + "/v2/projects/{proj_id}/repos/{repo_id}/activate": { + "put": { + "operationId": "activate_policy_repo", + "summary": "Activate Policy Repo", + "tags": [ + "Policy Git Repositories" + ] + } + }, + "/v2/projects/{proj_id}/{env_id}/opal_scope": { + "delete": { + "operationId": "reset_scope_config", + "summary": "Reset Scope Config", + "tags": [ + "Scope Configurations" + ] + }, + "get": { + "operationId": "get_scope_config", + "summary": "Get Scope Config", + "tags": [ + "Scope Configurations" + ] + }, + "put": { + "operationId": "set_scope_config", + "summary": "Set Scope Config", + "tags": [ + "Scope Configurations" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/bulk/roles": { + "put": { + "operationId": "bulk_create_or_replace_roles", + "summary": "Bulk Create Or Replace Roles", + "tags": [ + "Bulk Operations" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/condition_sets": { + "get": { + "operationId": "list_condition_sets", + "summary": "List Condition Sets", + "tags": [ + "Condition Sets" + ] + }, + "post": { + "operationId": "create_condition_set", + "summary": "Create Condition Set", + "tags": [ + "Condition Sets" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/condition_sets/{condition_set_id}": { + "delete": { + "operationId": "delete_condition_set", + "summary": "Delete Condition Set", + "tags": [ + "Condition Sets" + ] + }, + "get": { + "operationId": "get_condition_set", + "summary": "Get Condition Set", + "tags": [ + "Condition Sets" + ] + }, + "patch": { + "operationId": "update_condition_set", + "summary": "Update Condition Set", + "tags": [ + "Condition Sets" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/condition_sets/{condition_set_id}/ancestors": { + "get": { + "operationId": "get_condition_set_ancestors", + "summary": "Get Condition Set Ancestors", + "tags": [ + "Condition Sets" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/condition_sets/{condition_set_id}/descendants": { + "get": { + "operationId": "get_condition_set_descendants", + "summary": "Get Condition Set Descendants", + "tags": [ + "Condition Sets" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups": { + "get": { + "deprecated": true, + "operationId": "list_group", + "summary": "List Group", + "tags": [ + "Groups" + ] + }, + "post": { + "operationId": "create_group", + "summary": "Create Group", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups/direct": { + "get": { + "operationId": "list_direct_group", + "summary": "List Direct Group", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups/direct/{group_instance_key}": { + "get": { + "operationId": "get_direct_group", + "summary": "Get Direct Group", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}": { + "delete": { + "operationId": "delete_group", + "summary": "Delete Group", + "tags": [ + "Groups" + ] + }, + "get": { + "deprecated": true, + "operationId": "get_group", + "summary": "Get Group", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/assign_group": { + "delete": { + "operationId": "remove_group_from_group", + "summary": "Remove Group From Group", + "tags": [ + "Groups" + ] + }, + "put": { + "operationId": "assign_group_to_group", + "summary": "Assign Group To Group", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/children": { + "get": { + "operationId": "list_group_children", + "summary": "List group children (EAP)", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/parents": { + "get": { + "operationId": "list_group_parents", + "summary": "List group parents (EAP)", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/roles": { + "delete": { + "operationId": "remove_role_from_group", + "summary": "Remove Role From Group", + "tags": [ + "Groups" + ] + }, + "get": { + "operationId": "list_group_roles", + "summary": "List group roles (EAP)", + "tags": [ + "Groups" + ] + }, + "post": { + "operationId": "assign_role_to_group", + "summary": "Assign Role To Group", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/users": { + "get": { + "operationId": "list_group_users", + "summary": "List group users (EAP)", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/users/{user_id}": { + "delete": { + "operationId": "remove_user_from_group", + "summary": "Remove User From Group", + "tags": [ + "Groups" + ] + }, + "put": { + "operationId": "assign_user_to_group", + "summary": "Assign User To Group", + "tags": [ + "Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources": { + "get": { + "operationId": "list_resources", + "summary": "List Resources", + "tags": [ + "Resources" + ] + }, + "post": { + "operationId": "create_resource", + "summary": "Create Resource", + "tags": [ + "Resources" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}": { + "delete": { + "operationId": "delete_resource", + "summary": "Delete Resource", + "tags": [ + "Resources" + ] + }, + "get": { + "operationId": "get_resource", + "summary": "Get Resource", + "tags": [ + "Resources" + ] + }, + "patch": { + "operationId": "update_resource", + "summary": "Update Resource", + "tags": [ + "Resources" + ] + }, + "put": { + "operationId": "replace_resource", + "summary": "Replace Resource", + "tags": [ + "Resources" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/action_groups": { + "get": { + "operationId": "list_resource_action_groups", + "summary": "List Resource Action Groups", + "tags": [ + "Resource Action Groups" + ] + }, + "post": { + "operationId": "create_resource_action_group", + "summary": "Create Resource Action Group", + "tags": [ + "Resource Action Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/action_groups/{action_group_id}": { + "delete": { + "operationId": "delete_resource_action_group", + "summary": "Delete Resource Action Group", + "tags": [ + "Resource Action Groups" + ] + }, + "get": { + "operationId": "get_resource_action_group", + "summary": "Get Resource Action Group", + "tags": [ + "Resource Action Groups" + ] + }, + "patch": { + "operationId": "update_resource_action_group", + "summary": "Update Resource Action Group", + "tags": [ + "Resource Action Groups" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/actions": { + "get": { + "operationId": "list_resource_actions", + "summary": "List Resource Actions", + "tags": [ + "Resource Actions" + ] + }, + "post": { + "operationId": "create_resource_action", + "summary": "Create Resource Action", + "tags": [ + "Resource Actions" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/actions/{action_id}": { + "delete": { + "operationId": "delete_resource_action", + "summary": "Delete Resource Action", + "tags": [ + "Resource Actions" + ] + }, + "get": { + "operationId": "get_resource_action", + "summary": "Get Resource Action", + "tags": [ + "Resource Actions" + ] + }, + "patch": { + "operationId": "update_resource_action", + "summary": "Update Resource Action", + "tags": [ + "Resource Actions" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/attributes": { + "get": { + "operationId": "list_resource_attributes", + "summary": "List Resource Attributes", + "tags": [ + "Resource Attributes" + ] + }, + "post": { + "operationId": "create_resource_attribute", + "summary": "Create Resource Attribute", + "tags": [ + "Resource Attributes" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/attributes/{attribute_id}": { + "delete": { + "operationId": "delete_resource_attribute", + "summary": "Delete Resource Attribute", + "tags": [ + "Resource Attributes" + ] + }, + "get": { + "operationId": "get_resource_attribute", + "summary": "Get Resource Attribute", + "tags": [ + "Resource Attributes" + ] + }, + "patch": { + "operationId": "update_resource_attribute", + "summary": "Update Resource Attribute", + "tags": [ + "Resource Attributes" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/relations": { + "get": { + "operationId": "list_resource_relations", + "summary": "List Resource Relations", + "tags": [ + "Resource Relations" + ] + }, + "post": { + "operationId": "create_resource_relation", + "summary": "Create Resource Relation", + "tags": [ + "Resource Relations" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/relations/{relation_id}": { + "delete": { + "operationId": "delete_resource_relation", + "summary": "Delete Resource Relation", + "tags": [ + "Resource Relations" + ] + }, + "get": { + "operationId": "get_resource_relation", + "summary": "Get Resource Relation", + "tags": [ + "Resource Relations" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles": { + "get": { + "operationId": "list_resource_roles", + "summary": "List Resource Roles", + "tags": [ + "Resource Roles" + ] + }, + "post": { + "operationId": "create_resource_role", + "summary": "Create Resource Role", + "tags": [ + "Resource Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}": { + "delete": { + "operationId": "delete_resource_role", + "summary": "Delete Resource Role", + "tags": [ + "Resource Roles" + ] + }, + "get": { + "operationId": "get_resource_role", + "summary": "Get Resource Role", + "tags": [ + "Resource Roles" + ] + }, + "patch": { + "operationId": "update_resource_role", + "summary": "Update Resource Role", + "tags": [ + "Resource Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/ancestors": { + "get": { + "operationId": "get_resource_role_ancestors", + "summary": "Get Resource Role Ancestors", + "tags": [ + "Resource Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/descendants": { + "get": { + "operationId": "get_resource_role_descendants", + "summary": "Get Resource Role Descendants", + "tags": [ + "Resource Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/implicit_grants": { + "delete": { + "operationId": "delete_implicit_grant", + "summary": "Delete Implicit Grant", + "tags": [ + "Implicit Grants" + ] + }, + "post": { + "operationId": "create_implicit_grant", + "summary": "Create Implicit Grant", + "tags": [ + "Implicit Grants" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/implicit_grants/conditions": { + "put": { + "operationId": "update_implicit_grants_conditions", + "summary": "Update Implicit Grants Conditions", + "tags": [ + "Implicit Grants" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/permissions": { + "delete": { + "operationId": "remove_permissions_from_resource_role", + "summary": "Remove Permissions from Role", + "tags": [ + "Resource Roles" + ] + }, + "post": { + "operationId": "assign_permissions_to_resource_role", + "summary": "Assign Permissions to Role", + "tags": [ + "Resource Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/roles": { + "get": { + "operationId": "list_roles", + "summary": "List Roles", + "tags": [ + "Roles" + ] + }, + "post": { + "operationId": "create_role", + "summary": "Create Role", + "tags": [ + "Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/roles/{role_id}": { + "delete": { + "operationId": "delete_role", + "summary": "Delete Role", + "tags": [ + "Roles" + ] + }, + "get": { + "operationId": "get_role", + "summary": "Get Role", + "tags": [ + "Roles" + ] + }, + "patch": { + "operationId": "update_role", + "summary": "Update Role", + "tags": [ + "Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/roles/{role_id}/ancestors": { + "get": { + "operationId": "get_role_ancestors", + "summary": "Get Role Ancestors", + "tags": [ + "Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/roles/{role_id}/descendants": { + "get": { + "operationId": "get_role_descendants", + "summary": "Get Role Descendants", + "tags": [ + "Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/roles/{role_id}/permissions": { + "delete": { + "operationId": "remove_permissions_from_role", + "summary": "Remove Permissions From Role", + "tags": [ + "Roles" + ] + }, + "post": { + "operationId": "assign_permissions_to_role", + "summary": "Assign Permissions To Role", + "tags": [ + "Roles" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/users/attributes": { + "get": { + "operationId": "list_user_attributes", + "summary": "List User Attributes", + "tags": [ + "User Attributes" + ] + }, + "post": { + "operationId": "create_user_attribute", + "summary": "Create User Attribute", + "tags": [ + "User Attributes" + ] + } + }, + "/v2/schema/{proj_id}/{env_id}/users/attributes/{attribute_id}": { + "delete": { + "operationId": "delete_user_attribute", + "summary": "Delete User Attribute", + "tags": [ + "User Attributes" + ] + }, + "get": { + "operationId": "get_user_attribute", + "summary": "Get User Attribute", + "tags": [ + "User Attributes" + ] + }, + "patch": { + "operationId": "update_user_attribute", + "summary": "Update User Attribute", + "tags": [ + "User Attributes" + ] + } + } + } +} diff --git a/.github/api-specs/control-plane.source.json b/.github/api-specs/control-plane.source.json new file mode 100644 index 00000000..b9dbea9a --- /dev/null +++ b/.github/api-specs/control-plane.source.json @@ -0,0 +1,5 @@ +{ + "fetched": "2026-10-01", + "operations": 263, + "source": "https://api.permit.io/v2/openapi.json" +} diff --git a/.github/api-specs/pdp.json b/.github/api-specs/pdp.json new file mode 100644 index 00000000..41fc75e9 --- /dev/null +++ b/.github/api-specs/pdp.json @@ -0,0 +1,306 @@ +{ + "info": { + "title": "Permit.io PDP", + "version": "0.2.0" + }, + "openapi": "3.1.0", + "paths": { + "/allowed": { + "post": { + "operationId": "is_allowed_allowed_post", + "summary": "Is Allowed", + "tags": [ + "Authorization API" + ] + } + }, + "/allowed/all-tenants": { + "post": { + "operationId": "is_allowed_all_tenants_allowed_all_tenants_post", + "summary": "Is Allowed All Tenants", + "tags": [ + "Authorization API" + ] + } + }, + "/allowed/bulk": { + "post": { + "operationId": "is_allowed_bulk_allowed_bulk_post", + "summary": "Is Allowed Bulk", + "tags": [ + "Authorization API" + ] + } + }, + "/allowed_url": { + "post": { + "operationId": "is_allowed_url_allowed_url_post", + "summary": "Is Allowed Url", + "tags": [ + "Authorization API" + ] + } + }, + "/authorized_users": { + "post": { + "operationId": "authorized_users_authorized_users_post", + "summary": "Authorized Users", + "tags": [ + "Authorization API" + ] + } + }, + "/callbacks": { + "get": { + "operationId": "list_callbacks_callbacks_get", + "summary": "List Callbacks", + "tags": [ + "Callbacks" + ] + }, + "post": { + "operationId": "register_callback_callbacks_post", + "summary": "Register Callback", + "tags": [ + "Callbacks" + ] + } + }, + "/callbacks/{key}": { + "delete": { + "operationId": "get_callback_by_key_callbacks__key__delete", + "summary": "Get Callback By Key", + "tags": [ + "Callbacks" + ] + }, + "get": { + "operationId": "get_callback_by_key_callbacks__key__get", + "summary": "Get Callback By Key", + "tags": [ + "Callbacks" + ] + } + }, + "/data-updater/trigger": { + "post": { + "operationId": "trigger_data_update_data_updater_trigger_post", + "summary": "Trigger a full base-data reload", + "tags": [ + "Data Updater" + ] + } + }, + "/facts/relationship_tuples": { + "post": { + "operationId": "create_relationship_tuple_facts_relationship_tuples_post", + "summary": "Create Relationship Tuple", + "tags": [ + "Local Facts API" + ] + } + }, + "/facts/resource_instances": { + "post": { + "operationId": "create_resource_instance_facts_resource_instances_post", + "summary": "Create Resource Instance", + "tags": [ + "Local Facts API" + ] + } + }, + "/facts/resource_instances/{instance_id}": { + "patch": { + "operationId": "update_resource_instance_facts_resource_instances__instance_id__patch", + "summary": "Update Resource Instance", + "tags": [ + "Local Facts API" + ] + } + }, + "/facts/role_assignments": { + "delete": { + "operationId": "delete_role_assignment_facts_role_assignments_delete", + "summary": "Delete Role Assignment", + "tags": [ + "Local Facts API" + ] + }, + "post": { + "operationId": "create_role_assignment_facts_role_assignments_post", + "summary": "Create Role Assignment", + "tags": [ + "Local Facts API" + ] + } + }, + "/facts/tenants": { + "post": { + "operationId": "create_tenant_facts_tenants_post", + "summary": "Create Tenant", + "tags": [ + "Local Facts API" + ] + } + }, + "/facts/users": { + "post": { + "operationId": "create_user_facts_users_post", + "summary": "Create User", + "tags": [ + "Local Facts API" + ] + } + }, + "/facts/users/{user_id}": { + "patch": { + "operationId": "update_user_facts_users__user_id__patch", + "summary": "Update User", + "tags": [ + "Local Facts API" + ] + }, + "put": { + "operationId": "sync_user_facts_users__user_id__put", + "summary": "Sync User", + "tags": [ + "Local Facts API" + ] + } + }, + "/facts/users/{user_id}/roles": { + "delete": { + "operationId": "unassign_user_role_facts_users__user_id__roles_delete", + "summary": "Unassign User Role", + "tags": [ + "Local Facts API" + ] + }, + "post": { + "operationId": "assign_user_role_facts_users__user_id__roles_post", + "summary": "Assign User Role", + "tags": [ + "Local Facts API" + ] + } + }, + "/healthchecks/opa/healthy": { + "get": { + "operationId": "health_opa_healthcheck_healthchecks_opa_healthy_get", + "summary": "Proxy healthy healthcheck - OPAL_OPA_HEALTH_CHECK_POLICY_ENABLED must be set to True", + "tags": [ + "Cloud API Proxy" + ] + } + }, + "/healthchecks/opa/ready": { + "get": { + "operationId": "ready_opa_healthcheck_healthchecks_opa_ready_get", + "summary": "Proxy ready healthcheck - OPAL_OPA_HEALTH_CHECK_POLICY_ENABLED must be set to True", + "tags": [ + "Cloud API Proxy" + ] + } + }, + "/healthchecks/opa/system": { + "get": { + "operationId": "system_opa_healthcheck_healthchecks_opa_system_get", + "summary": "Proxy system data - OPAL_OPA_HEALTH_CHECK_POLICY_ENABLED must be set to True", + "tags": [ + "Cloud API Proxy" + ] + } + }, + "/kong": { + "post": { + "operationId": "is_allowed_kong_kong_post", + "summary": "Is Allowed Kong", + "tags": [ + "Authorization API" + ] + } + }, + "/local/role_assignments": { + "get": { + "operationId": "list_role_assignments_local_role_assignments_get", + "summary": "List Role Assignments", + "tags": [ + "Local Queries" + ] + } + }, + "/nginx_allowed": { + "post": { + "operationId": "is_allowed_nginx_nginx_allowed_post", + "summary": "Is Allowed Nginx", + "tags": [ + "Authorization API" + ] + } + }, + "/opal-server/connectivity": { + "get": { + "operationId": "get_connectivity_status_opal_server_connectivity_get", + "summary": "Get OPAL server connectivity status", + "tags": [ + "OPAL Server Connectivity" + ] + } + }, + "/opal-server/connectivity/disable": { + "post": { + "operationId": "disable_connectivity_opal_server_connectivity_disable_post", + "summary": "Disable OPAL server connectivity", + "tags": [ + "OPAL Server Connectivity" + ] + } + }, + "/opal-server/connectivity/enable": { + "post": { + "operationId": "enable_connectivity_opal_server_connectivity_enable_post", + "summary": "Enable OPAL server connectivity", + "tags": [ + "OPAL Server Connectivity" + ] + } + }, + "/policy-store/config": { + "get": { + "deprecated": true, + "operationId": "get_policy_store_details_policy_store_config_get", + "summary": "Get Policy Store Details", + "tags": [ + "Policy Store" + ] + } + }, + "/policy-updater/trigger": { + "post": { + "operationId": "trigger_policy_update_policy_updater_trigger_post", + "summary": "Trigger a full policy reload", + "tags": [ + "Policy Updater" + ] + } + }, + "/user-permissions": { + "post": { + "operationId": "Get_User_Permissions_user_permissions_post", + "summary": "Get User Permissions", + "tags": [ + "Authorization API" + ] + } + }, + "/user-tenants": { + "post": { + "operationId": "Get_User_Tenants_user_tenants_post", + "summary": "Get User Tenants", + "tags": [ + "Authorization API" + ] + } + } + } +} diff --git a/.github/api-specs/pdp.source.json b/.github/api-specs/pdp.source.json new file mode 100644 index 00000000..86452868 --- /dev/null +++ b/.github/api-specs/pdp.source.json @@ -0,0 +1,5 @@ +{ + "fetched": "2026-10-01", + "operations": 34, + "source": "GET /openapi.json on a container of permitio/pdp-v2:0.9.16@sha256:e3cf30794ec2d256636b4714641df46e51ee58a3f1f0d24c606e214e0bf8669a (PINNED_PDP_IMAGE in .github/workflows/test.yml)" +} diff --git a/.github/scripts/api_coverage.py b/.github/scripts/api_coverage.py new file mode 100755 index 00000000..714573ea --- /dev/null +++ b/.github/scripts/api_coverage.py @@ -0,0 +1,1313 @@ +#!/usr/bin/env python3 +"""Report which Permit API operations the SDK covers, from the requests its tests send. + +PER-16336 section 7, for permit-python (PER-16337). + +Where the numbers come from: + +* The operations are those of two OpenAPI documents: the control plane's + (https://api.permit.io/v2/openapi.json) and the container PDP's (served at + /openapi.json by the PDP image test.yml pins). Pull requests read the operation + inventories committed under .github/api-specs/, so their result depends only on the + commit. The weekly job reads the live control-plane spec instead. +* What the SDK calls comes from a record of the requests the offline tests actually + sent (tests/api_coverage_recorder.py writes it). Each request's method and path is + matched to an operation's path template; the template with the most literal segments + wins. An operation is covered when an offline test sent a request that matches it, so + an SDK method that no offline test calls does not count. +* The end-to-end column comes from the records of the e2e runs, when there are any: an + operation is exercised end to end when an e2e test got a 2xx or 3xx answer from it. + With no e2e record the column says "not run", never "no". + +Every operation is covered, allowlisted, or missing. The allowlist +(.github/scripts/api_coverage_allowlist.json) gives each operation left out on purpose a +status and one reason: `excluded` (out of scope for the SDK), `deferred` (planned, with +its ticket) or `untested` (an SDK method calls it, but no offline test sends the request +yet). A request that matches no operation in either spec is SDK-only; the allowlist's +`sdk_only` entries explain the known ones. An operation's stage is `deprecated` when the +spec says so, `EAP` when one of its tags names EAP, and `GA` otherwise. + +The report fails (exit 1) on: + +* a GA operation that is neither covered nor allowlisted; +* a stale allowlist entry: its operation is covered now, or is not in the spec; +* a changed operation: an entry whose recorded stage is not the spec's stage; +* an SDK-only request that no `sdk_only` entry explains, or an `sdk_only` entry that no + request matches. + +EAP and deprecated operations that are neither covered nor allowlisted are listed, but do +not fail the report. Request and response shapes are the Schema Drift check's job +(.github/workflows/schema-drift.yml), not this one's. + +Contract (the workflows depend on it): + +* Exit 0: none of the failures above. Exit 1: at least one of them. +* Exit 2: the report did not run, and is never reported as clean. That is a spec that + cannot be read or lists fewer operations than its minimum, an invalid allowlist, a + request record that is missing, malformed, from a session that did not finish or that + failed, or that holds fewer offline requests than the minimum, or any other error. +* The Markdown report goes to --summary (default stdout), the full result as JSON to + --json, and --github-output receives the failure counts. + +The `snapshot` subcommand writes the operation inventory of a downloaded spec, and its +source and fetch date next to it, which is how the committed snapshots are refreshed. + +Stdlib only. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import re +import sys +import traceback +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + +CONTROL_PLANE = "control-plane" +PDP = "pdp" +APIS = (CONTROL_PLANE, PDP) +API_TITLES = {CONTROL_PLANE: "Control plane", PDP: "PDP"} + +GA = "GA" +EAP = "EAP" +DEPRECATED = "deprecated" +STAGES = (GA, EAP, DEPRECATED) + +COVERED = "covered" +MISSING = "missing" +EXCLUDED = "excluded" +DEFERRED = "deferred" +UNTESTED = "untested" +ALLOWLIST_STATUSES = (EXCLUDED, DEFERRED, UNTESTED) + +UNDOCUMENTED = "undocumented" +TEST_ONLY = "test-only" +SDK_ONLY_STATUSES = (UNDOCUMENTED, TEST_ONLY) + +HTTP_METHODS = ("get", "put", "post", "delete", "patch", "head", "options", "trace") +EAP_TAG = re.compile(r"\bEAP\b") +TICKET = re.compile(r"^[A-Z][A-Z0-9]*-\d+$") +PARAMETER = re.compile(r"\{[^/{}]*\}") + +# The request record format tests/api_coverage_recorder.py writes. +RECORD_VERSION = 1 +# Far below what the suite sends today (about 590 requests), so the sentinel only trips +# when the record is truncated or the recorder stopped seeing requests. +DEFAULT_MIN_RECORDS = 400 +# Far below today's counts (263 and 34), for the same reason. +DEFAULT_MIN_OPERATIONS = {CONTROL_PLANE: 200, PDP: 20} +# How many test ids the JSON report keeps per operation. +TESTS_PER_OPERATION = 5 +SUCCESS_STATUSES = range(200, 400) + + +class CoverageError(Exception): + """The report could not run. Maps to exit code 2.""" + + +# --- specs -------------------------------------------------------------------- + + +def normalize(path: str) -> str: + """A path template with its parameter names dropped: `/users/{user_id}` is `/users/{}`.""" + return PARAMETER.sub("{}", path) + + +def template_pattern(path: str) -> re.Pattern[str]: + """A regular expression that matches the concrete paths of a path template. + + A parameter matches one non-empty path segment. The request path is matched still + percent-encoded, so a `%2F` inside a key stays inside its segment. + """ + parts = PARAMETER.split(path) + return re.compile("[^/]+".join(re.escape(part) for part in parts) + r"\Z") + + +def specificity(path: str) -> tuple[int, ...]: + """Rank a path template: literal segments beat parameters, from the left.""" + return tuple(0 if PARAMETER.fullmatch(segment) else 1 for segment in path.split("/")) + + +def stage_of(operation: dict[str, Any]) -> str: + """The stage of a spec operation: deprecated, EAP (a tag that names EAP) or GA.""" + if operation.get("deprecated") is True: + return DEPRECATED + if any(EAP_TAG.search(str(tag)) for tag in operation.get("tags") or []): + return EAP + return GA + + +@dataclass(frozen=True) +class Operation: + """One operation of a spec: an HTTP method on a path template.""" + + api: str + method: str + path: str + stage: str + tags: tuple[str, ...] + summary: str + pattern: re.Pattern[str] = field(compare=False, repr=False) + + @property + def name(self) -> str: + """How the report and the allowlist write the operation: `GET /v2/...`.""" + return f"{self.method} {self.path}" + + @property + def key(self) -> tuple[str, str, str]: + """The operation's identity: its API, method and path with parameter names dropped.""" + return (self.api, self.method, normalize(self.path)) + + +@dataclass +class Spec: + """The operations of one API, and where they were read from.""" + + api: str + source: str + operations: list[Operation] + + def match(self, method: str, path: str) -> Operation | None: + """The operation a request's method and path belong to, if any.""" + candidates = [ + op for op in self.operations if op.method == method and op.pattern.match(path) + ] + if not candidates: + return None + return max(candidates, key=lambda op: (specificity(op.path), op.path)) + + +def read_json(path: Path, what: str) -> Any: # noqa: ANN401 - whatever the JSON document holds + """Read a JSON file. + + Raises: + CoverageError: If the file cannot be read or is not JSON. + """ + try: + return json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + msg = f"could not read {what} at {path}: {exc}" + raise CoverageError(msg) from exc + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + msg = f"{what} at {path} is not valid JSON: {exc}" + raise CoverageError(msg) from exc + + +def operations_of(document: Any, api: str, label: str) -> list[Operation]: # noqa: ANN401 + """List the operations of an OpenAPI document or of a committed operation inventory. + + Raises: + CoverageError: If the document has no `paths` object, or two operations share a + method and a path that differ only in parameter names. + """ + paths = document.get("paths") if isinstance(document, dict) else None + if not isinstance(paths, dict): + msg = f"{label} has no `paths` object" + raise CoverageError(msg) + operations: list[Operation] = [] + seen: dict[tuple[str, str, str], str] = {} + for path, item in paths.items(): + if not isinstance(item, dict): + continue + for method in HTTP_METHODS: + spec_operation = item.get(method) + if not isinstance(spec_operation, dict): + continue + operation = Operation( + api=api, + method=method.upper(), + path=str(path), + stage=stage_of(spec_operation), + tags=tuple(str(tag) for tag in spec_operation.get("tags") or []), + summary=str(spec_operation.get("summary") or ""), + pattern=template_pattern(str(path)), + ) + if operation.key in seen: + msg = f"{label}: {operation.name} and {seen[operation.key]} are the same operation" + raise CoverageError(msg) + seen[operation.key] = operation.name + operations.append(operation) + return operations + + +def load_spec(api: str, path: Path, minimum: int) -> Spec: + """Read one API's spec and check it lists at least `minimum` operations. + + Raises: + CoverageError: If the spec cannot be read, or lists fewer operations than + `minimum`. + """ + label = f"the {API_TITLES[api]} spec" + operations = operations_of(read_json(path, label), api, f"{label} at {path}") + if len(operations) < minimum: + msg = ( + f"{label} at {path} lists {len(operations)} operations, fewer than the " + f"minimum of {minimum}; it is truncated or not the spec" + ) + raise CoverageError(msg) + return Spec(api=api, source=_describe_source(path), operations=operations) + + +def _describe_source(path: Path) -> str: + """Name a spec by its file, and by the source and date its sidecar records.""" + sidecar = path.with_name(path.name.removesuffix(".json") + ".source.json") + if not sidecar.is_file(): + return f"`{path}`" + try: + source = json.loads(sidecar.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return f"`{path}`" + if not isinstance(source, dict): + return f"`{path}`" + return f"`{path}`, a snapshot of {source.get('source')} taken {source.get('fetched')}" + + +# --- request records ---------------------------------------------------------- + + +@dataclass(frozen=True) +class Request: + """One recorded request.""" + + method: str + path: str + status: int | None + test: str + e2e: bool + + +@dataclass +class Record: + """A request record: the requests one test session sent, and how the session ended.""" + + path: Path + requests: list[Request] + exitstatus: int + tests: int + + +def load_record(path: Path) -> Record: + """Read a request record written by tests/api_coverage_recorder.py. + + Raises: + CoverageError: If the file cannot be read, a line is malformed, the format + version is not this script's, or the session line is missing (the session + did not finish). + """ + lines = _record_lines(path) + header = _record_line(path, 1, lines[0]) + if header.get("kind") != "header" or header.get("version") != RECORD_VERSION: + msg = ( + f"the request record {path} does not start with a version {RECORD_VERSION} " + f"header: {lines[0][:200]}" + ) + raise CoverageError(msg) + requests: list[Request] = [] + session: dict[str, Any] | None = None + for number, text in enumerate(lines[1:], start=2): + line = _record_line(path, number, text) + if session is not None: + msg = f"the request record {path} continues after its session line (line {number})" + raise CoverageError(msg) + if line.get("kind") == "request": + requests.append(_request(path, number, line)) + elif line.get("kind") == "session": + session = line + else: + msg = f"line {number} of the request record {path} has an unknown kind" + raise CoverageError(msg) + if session is None: + msg = f"the request record {path} has no session line: the test session did not finish" + raise CoverageError(msg) + exitstatus, tests = session.get("exitstatus"), session.get("tests") + if not isinstance(exitstatus, int) or not isinstance(tests, int): + msg = f"the session line of the request record {path} is malformed" + raise CoverageError(msg) + return Record(path=path, requests=requests, exitstatus=exitstatus, tests=tests) + + +def _record_lines(path: Path) -> list[str]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + msg = f"could not read the request record {path}: {exc}" + raise CoverageError(msg) from exc + except UnicodeDecodeError as exc: + msg = f"the request record {path} is not UTF-8 text: {exc}" + raise CoverageError(msg) from exc + if not lines: + msg = f"the request record {path} is empty; the recorder never ran" + raise CoverageError(msg) + return lines + + +def _record_line(path: Path, number: int, text: str) -> dict[str, Any]: + try: + line = json.loads(text) + except json.JSONDecodeError as exc: + msg = f"line {number} of the request record {path} is not JSON: {exc}" + raise CoverageError(msg) from exc + if not isinstance(line, dict): + msg = f"line {number} of the request record {path} is not a JSON object" + raise CoverageError(msg) + return line + + +def _request(path: Path, number: int, line: dict[str, Any]) -> Request: + method, request_path, status = line.get("method"), line.get("path"), line.get("status") + test, e2e = line.get("test"), line.get("e2e") + if ( + not isinstance(method, str) + or not isinstance(request_path, str) + or not request_path.startswith("/") + or not (status is None or isinstance(status, int)) + or not isinstance(test, str | None) + or not isinstance(e2e, bool) + ): + msg = f"line {number} of the request record {path} is not a well-formed request" + raise CoverageError(msg) + return Request(method.upper(), request_path, status, test or "(outside any test)", e2e) + + +def check_offline_record(record: Record, minimum: int) -> list[Request]: + """The offline requests of a record, once the record has passed its sentinels. + + Raises: + CoverageError: If the session failed, or sent fewer offline requests than + `minimum`. + """ + if record.exitstatus != 0: + msg = ( + f"the offline test session that wrote {record.path} exited {record.exitstatus}, " + "so its record is not a complete account of what the tests send" + ) + raise CoverageError(msg) + offline = [request for request in record.requests if not request.e2e] + if len(offline) < minimum: + msg = ( + f"the request record {record.path} holds {len(offline)} offline requests, fewer " + f"than the minimum of {minimum}; the recorder missed requests or tests did not run" + ) + raise CoverageError(msg) + return offline + + +# --- allowlist ---------------------------------------------------------------- + + +@dataclass(frozen=True) +class OperationEntry: + """An operation left uncovered on purpose.""" + + api: str + method: str + path: str + stage: str + status: str + reason: str + ticket: str + + @property + def name(self) -> str: + """The operation as the allowlist writes it.""" + return f"{self.method} {self.path}" + + @property + def key(self) -> tuple[str, str, str]: + """The identity of the operation the entry is about (see Operation.key).""" + return (self.api, self.method, normalize(self.path)) + + +@dataclass(frozen=True) +class SdkOnlyEntry: + """A request that matches no spec operation, and why the SDK sends it.""" + + method: str + path: str + status: str + reason: str + ticket: str + pattern: re.Pattern[str] = field(compare=False, repr=False) + + @property + def name(self) -> str: + """The request as the allowlist writes it.""" + return f"{self.method} {self.path}" + + +@dataclass +class Allowlist: + """The operation and SDK-only entries of the allowlist.""" + + operations: list[OperationEntry] + sdk_only: list[SdkOnlyEntry] + + +def _split_name(text: object, where: str) -> tuple[str, str]: + if not isinstance(text, str): + msg = f"{where} needs a string naming the request, such as `GET /v2/...`" + raise CoverageError(msg) + method, _, path = text.partition(" ") + if method not in {m.upper() for m in HTTP_METHODS}: + msg = f"{where}: {text!r} does not start with an upper-case HTTP method" + raise CoverageError(msg) + if not path.startswith("/") or " " in path: + msg = f"{where}: {text!r} does not name a path after the method" + raise CoverageError(msg) + return method, path + + +def _text(raw: dict[str, Any], key: str, where: str, *, required: bool = True) -> str: + value = raw.get(key, "") + if not isinstance(value, str) or (required and not value.strip()): + msg = f'{where} needs a non-empty string "{key}"' + raise CoverageError(msg) + return value + + +def _choice(raw: dict[str, Any], key: str, choices: Sequence[str], where: str) -> str: + value = raw.get(key) + if value not in choices: + msg = f'{where}: "{key}" must be one of {", ".join(choices)}, not {value!r}' + raise CoverageError(msg) + return str(value) + + +def _ticket(raw: dict[str, Any], where: str, *, required: bool) -> str: + ticket = _text(raw, "ticket", where, required=required) + if ticket and not TICKET.match(ticket): + msg = f"{where}: ticket {ticket!r} is not a ticket id such as PER-123" + raise CoverageError(msg) + return ticket + + +def _entries(doc: dict[str, Any], key: str, path: Path) -> list[dict[str, Any]]: + raw = doc.get(key) + if not isinstance(raw, list) or not all(isinstance(item, dict) for item in raw): + msg = f'the allowlist {path} needs a "{key}" list of objects' + raise CoverageError(msg) + return raw + + +def load_allowlist(path: Path) -> Allowlist: + """Read and validate the allowlist. + + Raises: + CoverageError: If the file is missing or not JSON, or an entry lacks a field, + uses an unknown value, repeats another entry, or (for `deferred`) names no + ticket. + """ + doc = read_json(path, "the allowlist") + if not isinstance(doc, dict): + msg = f"the allowlist {path} is not a JSON object" + raise CoverageError(msg) + operations: list[OperationEntry] = [] + seen: set[tuple[str, str, str]] = set() + for index, raw in enumerate(_entries(doc, "operations", path)): + where = f"allowlist operation entry {index}" + method, op_path = _split_name(raw.get("operation"), where) + status = _choice(raw, "status", ALLOWLIST_STATUSES, where) + entry = OperationEntry( + api=_choice(raw, "api", APIS, where), + method=method, + path=op_path, + stage=_choice(raw, "stage", STAGES, where), + status=status, + reason=_text(raw, "reason", where), + ticket=_ticket(raw, where, required=status == DEFERRED), + ) + if entry.key in seen: + msg = f"{where}: {entry.api} {entry.name} is listed more than once" + raise CoverageError(msg) + seen.add(entry.key) + operations.append(entry) + sdk_only: list[SdkOnlyEntry] = [] + seen_requests: set[tuple[str, str]] = set() + for index, raw in enumerate(_entries(doc, "sdk_only", path)): + where = f"allowlist sdk_only entry {index}" + method, request_path = _split_name(raw.get("request"), where) + status = _choice(raw, "status", SDK_ONLY_STATUSES, where) + if (method, normalize(request_path)) in seen_requests: + msg = f"{where}: {method} {request_path} is listed more than once" + raise CoverageError(msg) + seen_requests.add((method, normalize(request_path))) + sdk_only.append( + SdkOnlyEntry( + method=method, + path=request_path, + status=status, + reason=_text(raw, "reason", where), + ticket=_ticket(raw, where, required=status == UNDOCUMENTED), + pattern=template_pattern(request_path), + ) + ) + return Allowlist(operations=operations, sdk_only=sdk_only) + + +# --- comparison --------------------------------------------------------------- + + +@dataclass +class OperationResult: + """Where one spec operation stands.""" + + operation: Operation + status: str + tests: list[str] + e2e: bool | None + entry: OperationEntry | None + + +@dataclass(frozen=True) +class Problem: + """One reason the report fails.""" + + kind: str + subject: str + detail: str + + +@dataclass +class SdkOnlyResult: + """Requests that match no spec operation: those one entry explains, or one unexplained path. + + `name` is the entry's request template, or the concrete request when no entry + explains it. + """ + + entry: SdkOnlyEntry | None + name: str + requests: list[Request] + + +@dataclass +class Baseline: + """How a spec differs from the snapshot it is checked against.""" + + api: str + source: str + added: list[Operation] + removed: list[Operation] + restaged: list[tuple[Operation, str]] + + +@dataclass +class Report: + """Everything the report says.""" + + specs: dict[str, Spec] + results: list[OperationResult] + sdk_only: list[SdkOnlyResult] + problems: list[Problem] + offline_requests: int + offline_tests: int + e2e_records: list[Record] + e2e_unmatched: list[str] + baselines: list[Baseline] + + @property + def exit_code(self) -> int: + """1 when anything fails the report, else 0.""" + return 1 if self.problems else 0 + + @property + def e2e_ran(self) -> bool: + """Whether any e2e test sent a request, which is what fills the e2e column.""" + return any(request.e2e for record in self.e2e_records for request in record.requests) + + +def _match(specs: dict[str, Spec], request: Request) -> list[Operation]: + matched = (spec.match(request.method, request.path) for spec in specs.values()) + return [operation for operation in matched if operation is not None] + + +def _tests_by_operation( + specs: dict[str, Spec], requests: Iterable[Request] +) -> tuple[dict[tuple[str, str, str], set[str]], list[Request]]: + """Which tests sent each operation, and the requests that match no operation.""" + tests: dict[tuple[str, str, str], set[str]] = {} + unmatched: list[Request] = [] + for request in requests: + operations = _match(specs, request) + if not operations: + unmatched.append(request) + for operation in operations: + tests.setdefault(operation.key, set()).add(request.test) + return tests, unmatched + + +def _sdk_only_entry(request: Request, entries: list[SdkOnlyEntry]) -> SdkOnlyEntry | None: + candidates = [ + entry + for entry in entries + if entry.method == request.method and entry.pattern.match(request.path) + ] + if not candidates: + return None + return max(candidates, key=lambda entry: (specificity(entry.path), entry.path)) + + +def _sdk_only( + unmatched: list[Request], entries: list[SdkOnlyEntry] +) -> tuple[list[SdkOnlyResult], list[Problem]]: + explained: dict[SdkOnlyEntry, list[Request]] = {} + unexplained: dict[str, list[Request]] = {} + for request in unmatched: + entry = _sdk_only_entry(request, entries) + if entry is None: + unexplained.setdefault(f"{request.method} {request.path}", []).append(request) + else: + explained.setdefault(entry, []).append(request) + problems = [ + Problem( + "sdk-only", + f"{name} (sent by {requests[0].test})", + "matches no spec operation and no sdk_only allowlist entry", + ) + for name, requests in sorted(unexplained.items()) + ] + problems += [ + Problem("stale", entry.name, "an sdk_only entry that no recorded request matches") + for entry in entries + if entry not in explained + ] + results = [ + SdkOnlyResult(None, name, requests) for name, requests in sorted(unexplained.items()) + ] + results += sorted( + (SdkOnlyResult(entry, entry.name, requests) for entry, requests in explained.items()), + key=lambda result: result.name, + ) + return results, problems + + +def _operation_results( + specs: dict[str, Spec], + covered: dict[tuple[str, str, str], set[str]], + exercised: dict[tuple[str, str, str], set[str]] | None, + allowlist: Allowlist, +) -> tuple[list[OperationResult], list[Problem]]: + entries = {entry.key: entry for entry in allowlist.operations} + results: list[OperationResult] = [] + problems: list[Problem] = [] + for spec in specs.values(): + for operation in spec.operations: + tests = sorted(covered.get(operation.key, set())) + entry = entries.pop(operation.key, None) + e2e = None if exercised is None else operation.key in exercised + if tests: + status = COVERED + if entry is not None: + problems.append( + Problem( + "stale", + _label(operation), + f"allowlisted as {entry.status} but covered now", + ) + ) + elif entry is not None: + status = entry.status + if entry.stage != operation.stage: + problems.append( + Problem( + "changed", + _label(operation), + f"allowlisted as {entry.stage}, now {operation.stage} in the spec", + ) + ) + else: + status = MISSING + if operation.stage == GA: + problems.append( + Problem("missing", _label(operation), "GA, neither covered nor allowlisted") + ) + results.append(OperationResult(operation, status, tests, e2e, entry)) + problems += [ + Problem("stale", f"{API_TITLES[entry.api]} {entry.name}", "allowlisted but not in the spec") + for entry in entries.values() + ] + return results, problems + + +def _label(operation: Operation) -> str: + return f"{API_TITLES[operation.api]} {operation.name}" + + +def _baseline(spec: Spec, baseline: Spec) -> Baseline: + current = {operation.key: operation for operation in spec.operations} + before = {operation.key: operation for operation in baseline.operations} + return Baseline( + api=spec.api, + source=baseline.source, + added=[op for key, op in current.items() if key not in before], + removed=[op for key, op in before.items() if key not in current], + restaged=[ + (op, before[key].stage) + for key, op in current.items() + if key in before and before[key].stage != op.stage + ], + ) + + +def build_report( + *, + specs: dict[str, Spec], + offline: list[Request], + offline_tests: int, + e2e_records: list[Record], + allowlist: Allowlist, + baselines: dict[str, Spec] | None = None, +) -> Report: + """Compare the specs with the recorded requests and the allowlist. + + Args: + specs: The spec of each API, by API name. + offline: The requests the offline tests sent. + offline_tests: How many tests the offline session ran. + e2e_records: The records of the e2e sessions; empty when none ran. + allowlist: The allowlist. + baselines: Snapshots to list the specs' changes against, by API name. + + Returns: + The report. + """ + covered, unmatched = _tests_by_operation(specs, offline) + exercised: dict[tuple[str, str, str], set[str]] | None = None + e2e_unmatched: list[str] = [] + e2e_requests = [request for record in e2e_records for request in record.requests if request.e2e] + if e2e_requests: + successful = [r for r in e2e_requests if r.status in SUCCESS_STATUSES] + exercised, missed = _tests_by_operation(specs, successful) + e2e_unmatched = sorted({f"{request.method} {request.path}" for request in missed}) + results, problems = _operation_results(specs, covered, exercised, allowlist) + sdk_only, sdk_only_problems = _sdk_only(unmatched, allowlist.sdk_only) + return Report( + specs=specs, + results=results, + sdk_only=sdk_only, + problems=problems + sdk_only_problems, + offline_requests=len(offline), + offline_tests=offline_tests, + e2e_records=e2e_records, + e2e_unmatched=e2e_unmatched, + baselines=[_baseline(specs[api], baseline) for api, baseline in (baselines or {}).items()], + ) + + +# --- rendering ---------------------------------------------------------------- + + +def _cell(text: object) -> str: + """Make external text safe inside a Markdown table cell or inline code.""" + return " ".join(str(text).split()).replace("|", "\\|").replace("`", "'") + + +def _e2e_cell(result: OperationResult) -> str: + if result.e2e is None: + return "not run" + return "yes" if result.e2e else "no" + + +def _row(*cells: object) -> str: + return "| " + " | ".join(str(cell) for cell in cells) + " |" + + +def _code(text: object) -> str: + return f"`{_cell(text)}`" + + +def _counts_table(report: Report) -> list[str]: + statuses = (COVERED, EXCLUDED, DEFERRED, UNTESTED, MISSING) + out = [ + _row("API", "Stage", "Operations", *(s.capitalize() for s in statuses), "End to end"), + _row(*["---"] * (len(statuses) + 4)), + ] + for api in APIS: + for stage in STAGES: + rows = [ + r for r in report.results if r.operation.api == api and r.operation.stage == stage + ] + if not rows: + continue + counts = [sum(1 for r in rows if r.status == status) for status in statuses] + e2e = sum(1 for r in rows if r.e2e) if report.e2e_ran else "not run" + out.append(_row(API_TITLES[api], stage, len(rows), *counts, e2e)) + return out + + +def _inputs(report: Report) -> list[str]: + out = [f"- {API_TITLES[api]} spec: {report.specs[api].source}." for api in APIS] + out.append( + f"- Offline record: {report.offline_requests} requests from {report.offline_tests} tests." + ) + if not report.e2e_records: + out.append("- End to end: **not run** (no end-to-end record was given).") + elif not report.e2e_ran: + out.append("- End to end: **not run** (the end-to-end records hold no e2e request).") + else: + for record in report.e2e_records: + e2e = sum(1 for request in record.requests if request.e2e) + partial = ( + "" + if record.exitstatus == 0 + else f" The session exited {record.exitstatus}, so the column may be incomplete." + ) + out.append(f"- End to end: `{_cell(record.path.name)}`, {e2e} e2e requests.{partial}") + return out + + +def _problems(report: Report) -> list[str]: + titles = { + "missing": "GA operations neither covered nor allowlisted", + "changed": "Allowlisted operations whose stage changed", + "stale": "Stale allowlist entries", + "sdk-only": "SDK-only requests no allowlist entry explains", + } + out: list[str] = [] + for kind, title in titles.items(): + problems = [p for p in report.problems if p.kind == kind] + if problems: + out += [f"### {title}", ""] + out += [f"- `{_cell(p.subject)}`: {_cell(p.detail)}" for p in problems] + out.append("") + out.append( + "To resolve: add an offline test that sends the request, or triage the operation in " + "`.github/scripts/api_coverage_allowlist.json` (one reason each, and the stage the " + "spec gives it); refresh the snapshot under `.github/api-specs/` when the spec " + 'changed (see CONTRIBUTING.md, "API coverage report").' + ) + out.append("") + return out + + +def _details(summary: str, lines: list[str]) -> list[str]: + return ["
", f"{summary}", "", *lines, "", "
", ""] + + +def _operation_tables(report: Report) -> list[str]: + out: list[str] = [] + covered = [r for r in report.results if r.status == COVERED] + out += _details( + f"Covered operations ({len(covered)})", + [ + _row("API", "Operation", "Stage", "Tests", "End to end"), + _row(*["---"] * 5), + *( + _row( + API_TITLES[r.operation.api], + _code(r.operation.name), + r.operation.stage, + len(r.tests), + _e2e_cell(r), + ) + for r in covered + ), + ], + ) + missing = [r for r in report.results if r.status == MISSING] + if missing: + out += _details( + f"Missing operations ({len(missing)})", + [ + _row("API", "Operation", "Stage", "Summary"), + _row(*["---"] * 4), + *( + _row( + API_TITLES[r.operation.api], + _code(r.operation.name), + r.operation.stage, + _cell(r.operation.summary), + ) + for r in missing + ), + ], + ) + for status in ALLOWLIST_STATUSES: + listed = [(r, r.entry) for r in report.results if r.status == status and r.entry] + if listed: + out += _details( + f"{status.capitalize()} operations ({len(listed)})", + [ + _row("API", "Operation", "Stage", "Ticket", "Reason"), + _row(*["---"] * 5), + *( + _row( + API_TITLES[r.operation.api], + _code(r.operation.name), + r.operation.stage, + _cell(entry.ticket), + _cell(entry.reason), + ) + for r, entry in listed + ), + ], + ) + return out + + +def _sdk_only_table(report: Report) -> list[str]: + if not report.sdk_only: + return [] + rows = [ + _row( + _code(result.name), + result.entry.status if result.entry else "**not allowlisted**", + len(result.requests), + _cell(result.entry.reason) if result.entry else "", + ) + for result in report.sdk_only + ] + return _details( + f"SDK-only requests ({len(report.sdk_only)})", + [_row("Request", "Status", "Requests", "Reason"), _row(*["---"] * 4), *rows], + ) + + +def _baseline_section(report: Report) -> list[str]: + out: list[str] = [] + for baseline in report.baselines: + title = API_TITLES[baseline.api] + changes = ( + [f"- added: `{_cell(op.name)}` ({op.stage})" for op in baseline.added] + + [f"- removed: `{_cell(op.name)}` ({op.stage})" for op in baseline.removed] + + [f"- `{_cell(op.name)}`: {before} -> {op.stage}" for op, before in baseline.restaged] + ) + out += [f"### {title} spec changes since the snapshot", ""] + out += [f"_Compared with {baseline.source}._", ""] + out += changes or ["None."] + out.append("") + return out + + +def render(report: Report) -> str: + """Render the Markdown report, ending with a newline.""" + out = ["## API coverage", ""] + if report.exit_code == 0: + out.append( + ":white_check_mark: **Every GA operation is covered or allowlisted**, and the " + "allowlist is current." + ) + else: + out.append(f":x: **The API coverage report fails: {len(report.problems)} problem(s).**") + out += ["", *_inputs(report), "", *_counts_table(report), ""] + out += [ + ( + "_Covered: an offline test sent the request. Request and response shapes are " + "checked by the weekly Schema Drift workflow, not here._" + ), + "", + ] + if report.problems: + out += _problems(report) + out += _baseline_section(report) + out += _operation_tables(report) + out += _sdk_only_table(report) + if report.e2e_unmatched: + out += _details( + f"End-to-end requests that match no operation ({len(report.e2e_unmatched)})", + [f"- `{_cell(name)}`" for name in report.e2e_unmatched], + ) + return "\n".join(out) + + +def as_json(report: Report) -> dict[str, Any]: + """The full result, for the JSON artifact.""" + return { + "result": "pass" if report.exit_code == 0 else "fail", + "exit_code": report.exit_code, + "specs": {api: spec.source for api, spec in report.specs.items()}, + "offline": {"requests": report.offline_requests, "tests": report.offline_tests}, + "e2e": ( + [ + {"record": str(r.path), "exitstatus": r.exitstatus, "tests": r.tests} + for r in report.e2e_records + ] + if report.e2e_ran + else "not run" + ), + "problems": [vars(problem) for problem in report.problems], + "operations": [ + { + "api": r.operation.api, + "operation": r.operation.name, + "stage": r.operation.stage, + "tags": list(r.operation.tags), + "summary": r.operation.summary, + "status": r.status, + "tests": r.tests[:TESTS_PER_OPERATION], + "test_count": len(r.tests), + "e2e": "not run" if r.e2e is None else r.e2e, + "ticket": r.entry.ticket if r.entry else "", + "reason": r.entry.reason if r.entry else "", + } + for r in report.results + ], + "sdk_only": [ + { + "request": r.name, + "status": r.entry.status if r.entry else "not allowlisted", + "requests": len(r.requests), + "tests": sorted({request.test for request in r.requests})[:TESTS_PER_OPERATION], + "reason": r.entry.reason if r.entry else "", + } + for r in report.sdk_only + ], + "e2e_unmatched": report.e2e_unmatched, + "baselines": [ + { + "api": b.api, + "added": [op.name for op in b.added], + "removed": [op.name for op in b.removed], + "restaged": [ + {"operation": op.name, "was": was, "now": op.stage} for op, was in b.restaged + ], + } + for b in report.baselines + ], + } + + +def _did_not_run(reason: str) -> str: + first_line = (reason.splitlines() or [""])[0] + return ( + "## API coverage\n\n" + ":warning: **The report did not run**, so this is not a clean result.\n\n" + f"`{_cell(first_line)}`\n" + ) + + +# --- command line ------------------------------------------------------------- + + +def _named_values(values: list[str] | None, option: str) -> dict[str, str]: + named: dict[str, str] = {} + for value in values or []: + name, sep, rest = value.partition("=") + if not sep or name not in APIS or not rest: + msg = f"{option} takes NAME=VALUE with NAME one of {', '.join(APIS)}, not {value!r}" + raise CoverageError(msg) + if name in named: + msg = f"{option} {name} is given more than once" + raise CoverageError(msg) + named[name] = rest + return named + + +def _minimums(values: list[str] | None) -> dict[str, int]: + minimums = dict(DEFAULT_MIN_OPERATIONS) + for name, text in _named_values(values, "--min-operations").items(): + if not text.isdigit(): + msg = f"--min-operations {name} needs a whole number, not {text!r}" + raise CoverageError(msg) + minimums[name] = int(text) + return minimums + + +def run(args: argparse.Namespace) -> Report: + """Read every input and build the report. + + Raises: + CoverageError: If any input fails its checks (exit 2). + """ + spec_paths = _named_values(args.spec, "--spec") + if set(spec_paths) != set(APIS): + msg = f"--spec is needed for each of {', '.join(APIS)}" + raise CoverageError(msg) + minimums = _minimums(args.min_operations) + allowlist = load_allowlist(Path(args.allowlist)) + specs = {api: load_spec(api, Path(spec_paths[api]), minimums[api]) for api in APIS} + baselines = { + api: load_spec(api, Path(path), 1) + for api, path in _named_values(args.baseline, "--baseline").items() + } + record = load_record(Path(args.record)) + offline = check_offline_record(record, args.min_records) + e2e_records = [load_record(Path(path)) for path in args.e2e_record or []] + return build_report( + specs=specs, + offline=offline, + offline_tests=record.tests, + e2e_records=e2e_records, + allowlist=allowlist, + baselines=baselines, + ) + + +def report_command(args: argparse.Namespace) -> int: + """Run the report and write its outputs; return the exit status (0, 1 or 2).""" + try: + report = run(args) + except CoverageError as exc: + reason = str(exc) + print(f"API coverage report did not run: {reason}", file=sys.stderr) + # Any other error is also a run that did not finish, not a coverage failure: exit 1 + # would read as a failing report with nothing listed. + except Exception as exc: # noqa: BLE001 - mapped to exit 2 with its traceback on stderr + traceback.print_exc() + reason = f"{type(exc).__name__}: {exc}" + else: + return _write_report(args, report) + _emit(_did_not_run(reason), args.summary) + _write_json(args.json, {"result": "did-not-run", "exit_code": 2, "reason": reason}) + return 2 + + +def _write_report(args: argparse.Namespace, report: Report) -> int: + _emit(render(report), args.summary) + _write_json(args.json, as_json(report)) + if args.github_output: + kinds = ("missing", "stale", "changed", "sdk-only") + counts = {kind: sum(1 for p in report.problems if p.kind == kind) for kind in kinds} + with Path(args.github_output).open("a", encoding="utf-8") as handle: + handle.write( + "".join(f"{kind.replace('-', '_')}={count}\n" for kind, count in counts.items()) + ) + for problem in report.problems: + print(f"{problem.kind}: {problem.subject}: {problem.detail}", file=sys.stderr) + return report.exit_code + + +def inventory(document: Any, api: str, label: str) -> dict[str, Any]: # noqa: ANN401 + """The part of a spec the report reads: each operation's id, summary, tags and stage. + + Raises: + CoverageError: If the document has no operations. + """ + paths: dict[str, dict[str, Any]] = {} + for operation in operations_of(document, api, label): + source = document["paths"][operation.path][operation.method.lower()] + kept = {"operationId": source.get("operationId"), "summary": source.get("summary")} + kept["tags"] = source.get("tags") or [] + if source.get("deprecated") is True: + kept["deprecated"] = True + paths.setdefault(operation.path, {})[operation.method.lower()] = kept + if not paths: + msg = f"{label} has no operations" + raise CoverageError(msg) + info = document.get("info") or {} + return { + "openapi": document.get("openapi"), + "info": {"title": info.get("title"), "version": info.get("version")}, + "paths": paths, + } + + +def snapshot_command(args: argparse.Namespace) -> int: + """Write the operation inventory of a spec, and its source next to it.""" + try: + label = f"the {API_TITLES[args.api]} spec" + document = read_json(Path(args.spec), label) + snapshot = inventory(document, args.api, f"{label} at {args.spec}") + except CoverageError as exc: + print(f"could not write the snapshot: {exc}", file=sys.stderr) + return 2 + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + operations = sum(len(item) for item in snapshot["paths"].values()) + fetched = args.fetched or dt.datetime.now(dt.timezone.utc).date().isoformat() + source = {"source": args.source, "fetched": fetched, "operations": operations} + _write(out_dir / f"{args.api}.json", snapshot) + _write(out_dir / f"{args.api}.source.json", source) + print(f"wrote {operations} {API_TITLES[args.api]} operations to {out_dir / args.api}.json") + return 0 + + +def _write(path: Path, document: object) -> None: + path.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _write_json(target: str | None, document: object) -> None: + if target: + Path(target).parent.mkdir(parents=True, exist_ok=True) + _write(Path(target), document) + + +def _emit(report: str, summary: str | None) -> None: + if summary: + with Path(summary).open("a", encoding="utf-8") as handle: + handle.write(report + "\n") + else: + print(report) + + +def parser() -> argparse.ArgumentParser: + """The command line: `report` and `snapshot`.""" + root = argparse.ArgumentParser(description=(__doc__ or "").split("\n", 1)[0]) + commands = root.add_subparsers(dest="command", required=True) + + report = commands.add_parser("report", help="compare the specs with a request record") + report.add_argument( + "--spec", + action="append", + metavar="NAME=PATH", + help=f"the spec of each API ({', '.join(APIS)}): an OpenAPI document or a snapshot", + ) + report.add_argument("--allowlist", required=True, help="the allowlist (JSON)") + report.add_argument("--record", required=True, help="the offline tests' request record") + report.add_argument( + "--e2e-record", action="append", metavar="PATH", help="an e2e run's request record" + ) + report.add_argument( + "--baseline", + action="append", + metavar="NAME=PATH", + help="a snapshot to list the spec's changes against", + ) + report.add_argument( + "--min-records", + type=int, + default=DEFAULT_MIN_RECORDS, + help="fewest offline requests the record must hold (default: %(default)s)", + ) + report.add_argument( + "--min-operations", + action="append", + metavar="NAME=N", + help="fewest operations a spec must list (defaults: " + + ", ".join(f"{api}={n}" for api, n in DEFAULT_MIN_OPERATIONS.items()) + + ")", + ) + report.add_argument("--summary", help="append the Markdown report here instead of stdout") + report.add_argument("--json", help="write the full result here as JSON") + report.add_argument( + "--github-output", help="append missing=, stale=, changed= and sdk_only= counts here" + ) + report.set_defaults(handler=report_command) + + snapshot = commands.add_parser("snapshot", help="write a spec's operation inventory") + snapshot.add_argument("api", choices=APIS) + snapshot.add_argument("spec", help="the downloaded OpenAPI document") + snapshot.add_argument("--source", required=True, help="where the document came from") + snapshot.add_argument("--fetched", help="when it was fetched (default: today, UTC)") + snapshot.add_argument( + "--out-dir", default=".github/api-specs", help="where to write (default: %(default)s)" + ) + snapshot.set_defaults(handler=snapshot_command) + return root + + +def main(argv: list[str] | None = None) -> int: + """Run a subcommand; return its exit status. + + Bad arguments exit 2 through argparse, and so does an error while writing the + outputs: neither is a coverage result. + """ + args = parser().parse_args(argv) + try: + status: int = args.handler(args) + except Exception: # noqa: BLE001 - mapped to exit 2 with its traceback on stderr + traceback.print_exc() + return 2 + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/api_coverage_allowlist.json b/.github/scripts/api_coverage_allowlist.json new file mode 100644 index 00000000..bed0d2e4 --- /dev/null +++ b/.github/scripts/api_coverage_allowlist.json @@ -0,0 +1,1876 @@ +{ + "operations": [ + { + "api": "control-plane", + "operation": "GET /v2/activity", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (the activity log), which the dashboard covers." + }, + { + "api": "control-plane", + "operation": "GET /v2/activity/types", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (the activity log), which the dashboard covers." + }, + { + "api": "control-plane", + "operation": "GET /v2/api-key", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16337", + "reason": "API key administration needs an organization-level key and returns key secrets; add on a request for key-rotation automation." + }, + { + "api": "control-plane", + "operation": "POST /v2/api-key", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16337", + "reason": "API key administration needs an organization-level key and returns key secrets; add on a request for key-rotation automation." + }, + { + "api": "control-plane", + "operation": "GET /v2/api-key/{api_key_id}", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16337", + "reason": "API key administration needs an organization-level key and returns key secrets; add on a request for key-rotation automation." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/api-key/{api_key_id}", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16337", + "reason": "API key administration needs an organization-level key and returns key secrets; add on a request for key-rotation automation." + }, + { + "api": "control-plane", + "operation": "POST /v2/api-key/{api_key_id}/rotate-secret", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16337", + "reason": "API key administration needs an organization-level key and returns key secrets; add on a request for key-rotation automation." + }, + { + "api": "control-plane", + "operation": "POST /v2/audit-log-replay", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "A testing tool open only to allow-listed organizations." + }, + { + "api": "control-plane", + "operation": "POST /v2/data-export", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (exporting the organization's data), which the dashboard covers." + }, + { + "api": "control-plane", + "operation": "GET /v2/data-export/{task_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (exporting the organization's data), which the dashboard covers." + }, + { + "api": "control-plane", + "operation": "GET /v2/deprecated/activity", + "stage": "deprecated", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Deprecated; replaced by the /v2/activity routes, which are excluded as account administration." + }, + { + "api": "control-plane", + "operation": "GET /v2/deprecated/activity/types", + "stage": "deprecated", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Deprecated; replaced by the /v2/activity routes, which are excluded as account administration." + }, + { + "api": "control-plane", + "operation": "GET /v2/deprecated/history", + "stage": "deprecated", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Deprecated; replaced by the /v2/history routes, which are excluded as account administration." + }, + { + "api": "control-plane", + "operation": "GET /v2/deprecated/history/{event_id}", + "stage": "deprecated", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Deprecated; replaced by the /v2/history routes, which are excluded as account administration." + }, + { + "api": "control-plane", + "operation": "GET /v2/deprecated/history/{event_id}/request", + "stage": "deprecated", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Deprecated; replaced by the /v2/history routes, which are excluded as account administration." + }, + { + "api": "control-plane", + "operation": "GET /v2/deprecated/history/{event_id}/response", + "stage": "deprecated", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Deprecated; replaced by the /v2/history routes, which are excluded as account administration." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements configuration, which is dashboard setup." + }, + { + "api": "control-plane", + "operation": "POST /v2/elements/{proj_id}/{env_id}/config", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements configuration, which is dashboard setup." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements configuration, which is dashboard setup." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements configuration, which is dashboard setup." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Access Requests for Elements, which need an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "POST /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Access Requests for Elements, which need an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Access Requests for Elements, which need an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "PUT /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}/approve", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Access Requests for Elements, which need an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "PUT /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}/cancel", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Access Requests for Elements, which need an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "PUT /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}/deny", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Access Requests for Elements, which need an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/access_requests/{access_request_id}/reviewer", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Access Requests for Elements, which need an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "POST /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/active", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements back end: it needs an Elements end-user or member session, and API keys get 403." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/audit_logs", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements back end: it needs an Elements end-user or member session, and API keys get 403." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/roles", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements back end: it needs an Elements end-user or member session, and API keys get 403." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/user-invites", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements back end: it needs an Elements end-user or member session, and API keys get 403." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/users", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements back end: it needs an Elements end-user or member session, and API keys get 403." + }, + { + "api": "control-plane", + "operation": "POST /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/users", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements back end: it needs an Elements end-user or member session, and API keys get 403." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/users/{user_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements back end: it needs an Elements end-user or member session, and API keys get 403." + }, + { + "api": "control-plane", + "operation": "POST /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/users/{user_id}/roles", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements back end: it needs an Elements end-user or member session, and API keys get 403." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/data/users/{user_id}/roles", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements back end: it needs an Elements end-user or member session, and API keys get 403." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements Operation Approval, which needs an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "POST /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements Operation Approval, which needs an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements Operation Approval, which needs an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "PUT /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}/approve", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements Operation Approval, which needs an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "PUT /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}/cancel", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements Operation Approval, which needs an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "PUT /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}/deny", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements Operation Approval, which needs an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/operation_approval/{operation_approval_id}/reviewer", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements Operation Approval, which needs an Elements session rather than an API key." + }, + { + "api": "control-plane", + "operation": "GET /v2/elements/{proj_id}/{env_id}/config/{elements_config_id}/runtime", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements configuration, which is dashboard setup." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/elements/{proj_id}/{env_id}/{elements_config_id}", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Elements configuration, which is dashboard setup." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}", + "stage": "EAP", + "status": "deferred", + "ticket": "PER-16337", + "reason": "EAP Access Requests; add it once the feature leaves EAP." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}", + "stage": "EAP", + "status": "deferred", + "ticket": "PER-16337", + "reason": "EAP Access Requests; add it once the feature leaves EAP." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}", + "stage": "EAP", + "status": "deferred", + "ticket": "PER-16337", + "reason": "EAP Access Requests; add it once the feature leaves EAP." + }, + { + "api": "control-plane", + "operation": "PUT /v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}/approve", + "stage": "EAP", + "status": "deferred", + "ticket": "PER-16337", + "reason": "EAP Access Requests; add it once the feature leaves EAP." + }, + { + "api": "control-plane", + "operation": "PUT /v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}/cancel", + "stage": "EAP", + "status": "deferred", + "ticket": "PER-16337", + "reason": "EAP Access Requests; add it once the feature leaves EAP." + }, + { + "api": "control-plane", + "operation": "PUT /v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}/deny", + "stage": "EAP", + "status": "deferred", + "ticket": "PER-16337", + "reason": "EAP Access Requests; add it once the feature leaves EAP." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/facts/{proj_id}/{env_id}/access_requests/{elements_config_id}/user/{user_id}/tenant/{tenant_id}/{access_request_id}/reviewer", + "stage": "EAP", + "status": "deferred", + "ticket": "PER-16337", + "reason": "EAP Access Requests; add it once the feature leaves EAP." + }, + { + "api": "control-plane", + "operation": "PUT /v2/facts/{proj_id}/{env_id}/bulk/resource_instances", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_instances.bulk_replace(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/bulk/resource_instances", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_instances.bulk_delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/bulk/tenants", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.tenants.bulk_delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PUT /v2/facts/{proj_id}/{env_id}/bulk/users", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.users.bulk_replace(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/bulk/users", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.users.bulk_create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/bulk/users", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.users.bulk_delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/email_configurations", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements email setup, done in the dashboard." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/email_configurations", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements email setup, done in the dashboard." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/email_configurations/send_test_email", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements email setup, done in the dashboard." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/email_templates/", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements email setup, done in the dashboard." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/email_templates/{template_type}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements email setup, done in the dashboard." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/email_templates/{template_type}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements email setup, done in the dashboard." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/email_templates/{template_type}/send_test_email", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Elements email setup, done in the dashboard." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/proxy_configs", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time setup (Permit proxy configs), which the CLI and Terraform cover; revisit together with check_url (PER-16737) if there is demand." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/proxy_configs", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time setup (Permit proxy configs), which the CLI and Terraform cover; revisit together with check_url (PER-16737) if there is demand." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/proxy_configs/{proxy_config_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time setup (Permit proxy configs), which the CLI and Terraform cover; revisit together with check_url (PER-16737) if there is demand." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/proxy_configs/{proxy_config_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time setup (Permit proxy configs), which the CLI and Terraform cover; revisit together with check_url (PER-16737) if there is demand." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/facts/{proj_id}/{env_id}/proxy_configs/{proxy_config_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time setup (Permit proxy configs), which the CLI and Terraform cover; revisit together with check_url (PER-16737) if there is demand." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/relationship_tuples", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.relationship_tuples.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/relationship_tuples/bulk", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.relationship_tuples.bulk_create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/relationship_tuples/bulk", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.relationship_tuples.bulk_delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/resource_instances", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_instances.create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/resource_instances/{instance_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_instances.get(), permit.api.resource_instances.get_by_id(), permit.api.resource_instances.get_by_key(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/resource_instances/{instance_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_instances.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/facts/{proj_id}/{env_id}/resource_instances/{instance_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_instances.update(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/role_assignments", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.role_assignments.assign(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/role_assignments", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.role_assignments.unassign(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/role_assignments/bulk", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.role_assignments.bulk_assign(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/role_assignments/bulk", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.role_assignments.bulk_unassign(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/set_rules", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.condition_set_rules.list(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/set_rules", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.condition_set_rules.create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/set_rules", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.condition_set_rules.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}/users", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.tenants.list_tenant_users(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/facts/{proj_id}/{env_id}/user_invites", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.user_invites.list(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/user_invites", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.user_invites.create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/facts/{proj_id}/{env_id}/user_invites/{user_invite_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.user_invites.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/facts/{proj_id}/{env_id}/user_invites/{user_invite_id}", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: user_invites.update()." + }, + { + "api": "control-plane", + "operation": "POST /v2/facts/{proj_id}/{env_id}/user_invites/{user_invite_id}/approve", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.user_invites.approve(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/history", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (the API call history), which the dashboard covers." + }, + { + "api": "control-plane", + "operation": "GET /v2/history/{event_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (the API call history), which the dashboard covers." + }, + { + "api": "control-plane", + "operation": "GET /v2/history/{event_id}/request", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (the API call history), which the dashboard covers." + }, + { + "api": "control-plane", + "operation": "GET /v2/history/{event_id}/response", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (the API call history), which the dashboard covers." + }, + { + "api": "control-plane", + "operation": "GET /v2/internal/opal_data/{org_id}/{proj_id}/{env_id}", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP data routes the PDP reads its data from; not an application API." + }, + { + "api": "control-plane", + "operation": "GET /v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/optimized", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP data routes the PDP reads its data from; not an application API." + }, + { + "api": "control-plane", + "operation": "GET /v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/relationships", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP data routes the PDP reads its data from; not an application API." + }, + { + "api": "control-plane", + "operation": "GET /v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/resource_instances", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP data routes the PDP reads its data from; not an application API." + }, + { + "api": "control-plane", + "operation": "GET /v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/role_assignments", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP data routes the PDP reads its data from; not an application API." + }, + { + "api": "control-plane", + "operation": "GET /v2/internal/opal_data/{org_id}/{proj_id}/{env_id}/users", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP data routes the PDP reads its data from; not an application API." + }, + { + "api": "control-plane", + "operation": "GET /v2/members", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organization members), which the dashboard and the CLI cover; /v2/members/me rejects API keys." + }, + { + "api": "control-plane", + "operation": "POST /v2/members", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organization members), which the dashboard and the CLI cover; /v2/members/me rejects API keys." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/members", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organization members), which the dashboard and the CLI cover; /v2/members/me rejects API keys." + }, + { + "api": "control-plane", + "operation": "GET /v2/members/me", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organization members), which the dashboard and the CLI cover; /v2/members/me rejects API keys." + }, + { + "api": "control-plane", + "operation": "GET /v2/members/{member_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organization members), which the dashboard and the CLI cover; /v2/members/me rejects API keys." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/members/{member_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organization members), which the dashboard and the CLI cover; /v2/members/me rejects API keys." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/members/{member_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organization members), which the dashboard and the CLI cover; /v2/members/me rejects API keys." + }, + { + "api": "control-plane", + "operation": "GET /v2/orgs", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organizations), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "POST /v2/orgs", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organizations), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "GET /v2/orgs/active/org", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organizations), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "GET /v2/orgs/{org_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organizations), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/orgs/{org_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organizations), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/orgs/{org_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organizations), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "GET /v2/orgs/{org_id}/invites", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (inviting members to the organization), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "POST /v2/orgs/{org_id}/invites", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (inviting members to the organization), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/orgs/{org_id}/invites/{invite_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (inviting members to the organization), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "POST /v2/orgs/{org_id}/restore", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organizations), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "GET /v2/orgs/{org_id}/stats", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Account administration (organizations), which the dashboard and the CLI cover." + }, + { + "api": "control-plane", + "operation": "GET /v2/pdps/{proj_id}/{env_id}/audit_logs", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16337", + "reason": "PDP decision logs; add when a customer asks for programmatic decision-log export (the CLI covers it today)." + }, + { + "api": "control-plane", + "operation": "GET /v2/pdps/{proj_id}/{env_id}/audit_logs/{log_id}", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16337", + "reason": "PDP decision logs; add when a customer asks for programmatic decision-log export (the CLI covers it today)." + }, + { + "api": "control-plane", + "operation": "GET /v2/pdps/{proj_id}/{env_id}/configs", + "stage": "GA", + "status": "deferred", + "ticket": "PER-14243", + "reason": "Waits for PER-14243 to be done." + }, + { + "api": "control-plane", + "operation": "POST /v2/pdps/{proj_id}/{env_id}/configs/migrate-shards", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route (configuration values, debug logs, shard migration), not an application API." + }, + { + "api": "control-plane", + "operation": "PUT /v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/debug-audit-logs/disable", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route (configuration values, debug logs, shard migration), not an application API." + }, + { + "api": "control-plane", + "operation": "PUT /v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/debug-audit-logs/enable", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route (configuration values, debug logs, shard migration), not an application API." + }, + { + "api": "control-plane", + "operation": "POST /v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/refresh", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: pdps.refresh_pdp()." + }, + { + "api": "control-plane", + "operation": "POST /v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/rotate-api-key", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route: rotating the key revokes every PDP in the environment." + }, + { + "api": "control-plane", + "operation": "GET /v2/pdps/{proj_id}/{env_id}/configs/{pdp_id}/values", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route (configuration values, debug logs, shard migration), not an application API." + }, + { + "api": "control-plane", + "operation": "GET /v2/policy_guards/scopes", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Policy Guards, organization-level policy setup." + }, + { + "api": "control-plane", + "operation": "POST /v2/policy_guards/scopes", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Policy Guards, organization-level policy setup." + }, + { + "api": "control-plane", + "operation": "GET /v2/policy_guards/scopes/{policy_guard_scope_id}", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Policy Guards, organization-level policy setup." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/policy_guards/scopes/{policy_guard_scope_id}", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Policy Guards, organization-level policy setup." + }, + { + "api": "control-plane", + "operation": "POST /v2/policy_guards/scopes/{policy_guard_scope_id}/associate", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Policy Guards, organization-level policy setup." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/policy_guards/scopes/{policy_guard_scope_id}/disassociate", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Policy Guards, organization-level policy setup." + }, + { + "api": "control-plane", + "operation": "GET /v2/policy_guards/scopes/{policy_guard_scope_id}/rules", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Policy Guards, organization-level policy setup." + }, + { + "api": "control-plane", + "operation": "POST /v2/policy_guards/scopes/{policy_guard_scope_id}/rules", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Policy Guards, organization-level policy setup." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/policy_guards/scopes/{policy_guard_scope_id}/rules", + "stage": "EAP", + "status": "excluded", + "ticket": "PER-16337", + "reason": "EAP Policy Guards, organization-level policy setup." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.projects.list(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/projects", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.projects.create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects/{proj_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.projects.get(), permit.api.projects.get_by_id(), permit.api.projects.get_by_key(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/projects/{proj_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.projects.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/projects/{proj_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.projects.update(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects/{proj_id}/envs", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.environments.list(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/projects/{proj_id}/envs", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.environments.create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects/{proj_id}/envs/{env_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.environments.get(), permit.api.environments.get_by_id(), permit.api.environments.get_by_key(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/projects/{proj_id}/envs/{env_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.environments.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/projects/{proj_id}/envs/{env_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.environments.update(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/projects/{proj_id}/envs/{env_id}/copy/async", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: environments.copy_async() and get_copy_result()." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects/{proj_id}/envs/{env_id}/copy/async/{task_id}/result", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: environments.copy_async() and get_copy_result()." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects/{proj_id}/envs/{env_id}/stats", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.environments.get_stats(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/projects/{proj_id}/envs/{env_id}/test_jwks", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "A dashboard form helper." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects/{proj_id}/repos", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (policy Git repositories), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "POST /v2/projects/{proj_id}/repos", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (policy Git repositories), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects/{proj_id}/repos/active", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (policy Git repositories), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "PUT /v2/projects/{proj_id}/repos/disable", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (policy Git repositories), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects/{proj_id}/repos/{repo_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (policy Git repositories), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/projects/{proj_id}/repos/{repo_id}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (policy Git repositories), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "PUT /v2/projects/{proj_id}/repos/{repo_id}/activate", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (policy Git repositories), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "GET /v2/projects/{proj_id}/{env_id}/opal_scope", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (OPAL scope configuration), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "PUT /v2/projects/{proj_id}/{env_id}/opal_scope", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (OPAL scope configuration), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/projects/{proj_id}/{env_id}/opal_scope", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "One-time project setup (OPAL scope configuration), which the CLI and Terraform cover." + }, + { + "api": "control-plane", + "operation": "PUT /v2/schema/{proj_id}/{env_id}/bulk/roles", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: roles.bulk_replace()." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/condition_sets", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.condition_sets.list(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/schema/{proj_id}/{env_id}/condition_sets", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.condition_sets.create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/condition_sets/{condition_set_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.condition_sets.get(), permit.api.condition_sets.get_by_id(), permit.api.condition_sets.get_by_key(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/schema/{proj_id}/{env_id}/condition_sets/{condition_set_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.condition_sets.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/schema/{proj_id}/{env_id}/condition_sets/{condition_set_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.condition_sets.update(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/condition_sets/{condition_set_id}/ancestors", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Hierarchy helper for the dashboard, derivable from extends and parent_id; no SDK has it and nobody has asked." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/condition_sets/{condition_set_id}/descendants", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Hierarchy helper for the dashboard, derivable from extends and parent_id; no SDK has it and nobody has asked." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/groups", + "stage": "deprecated", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Deprecated; permit.api.groups reads groups through the /groups/direct routes instead." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}", + "stage": "deprecated", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Deprecated; permit.api.groups reads groups through the /groups/direct routes instead." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/children", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16342", + "reason": "The spec's summary labels it EAP while its tag is GA; add it once the label is settled." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/parents", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16342", + "reason": "The spec's summary labels it EAP while its tag is GA; add it once the label is settled." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/roles", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16342", + "reason": "The spec's summary labels it EAP while its tag is GA; add it once the label is settled." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/groups/{group_instance_key}/users", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16342", + "reason": "The spec's summary labels it EAP while its tag is GA; add it once the label is settled." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/resources", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resources.list(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PUT /v2/schema/{proj_id}/{env_id}/resources/{resource_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resources.replace(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/attributes", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_attributes.list(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/attributes", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_attributes.create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/attributes/{attribute_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_attributes.get(), permit.api.resource_attributes.get_by_id(), permit.api.resource_attributes.get_by_key(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/attributes/{attribute_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_attributes.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/attributes/{attribute_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_attributes.update(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "POST /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/relations", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_relations.create(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/relations/{relation_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_relations.get(), permit.api.resource_relations.get_by_id(), permit.api.resource_relations.get_by_key(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/relations/{relation_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_relations.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_roles.list(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_roles.get(), permit.api.resource_roles.get_by_id(), permit.api.resource_roles.get_by_key(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_roles.delete(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_roles.update(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/ancestors", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Hierarchy helper for the dashboard, derivable from extends and parent_id; no SDK has it and nobody has asked." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/descendants", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Hierarchy helper for the dashboard, derivable from extends and parent_id; no SDK has it and nobody has asked." + }, + { + "api": "control-plane", + "operation": "POST /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/implicit_grants", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_roles.create_role_derivation(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/implicit_grants", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_roles.delete_role_derivation(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "PUT /v2/schema/{proj_id}/{env_id}/resources/{resource_id}/roles/{role_id}/implicit_grants/conditions", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_roles.update_role_derivation_conditions(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/roles/{role_id}/ancestors", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Hierarchy helper for the dashboard, derivable from extends and parent_id; no SDK has it and nobody has asked." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/roles/{role_id}/descendants", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Hierarchy helper for the dashboard, derivable from extends and parent_id; no SDK has it and nobody has asked." + }, + { + "api": "control-plane", + "operation": "POST /v2/schema/{proj_id}/{env_id}/roles/{role_id}/permissions", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.roles.assign_permissions(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/schema/{proj_id}/{env_id}/roles/{role_id}/permissions", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.roles.remove_permissions(); no offline test sends this request yet." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/users/attributes", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: a permit.api.user_attributes module." + }, + { + "api": "control-plane", + "operation": "POST /v2/schema/{proj_id}/{env_id}/users/attributes", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: a permit.api.user_attributes module." + }, + { + "api": "control-plane", + "operation": "GET /v2/schema/{proj_id}/{env_id}/users/attributes/{attribute_id}", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: a permit.api.user_attributes module." + }, + { + "api": "control-plane", + "operation": "DELETE /v2/schema/{proj_id}/{env_id}/users/attributes/{attribute_id}", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: a permit.api.user_attributes module." + }, + { + "api": "control-plane", + "operation": "PATCH /v2/schema/{proj_id}/{env_id}/users/attributes/{attribute_id}", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: a permit.api.user_attributes module." + }, + { + "api": "pdp", + "operation": "POST /allowed/all-tenants", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16015", + "reason": "Being retired; get_user_permissions() without a tenant filter is the replacement." + }, + { + "api": "pdp", + "operation": "POST /allowed_url", + "stage": "GA", + "status": "deferred", + "ticket": "PER-16737", + "reason": "P2: check_url(), container PDP only." + }, + { + "api": "pdp", + "operation": "GET /callbacks", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP integration: data-update callbacks the PDP operator configures." + }, + { + "api": "pdp", + "operation": "POST /callbacks", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP integration: data-update callbacks the PDP operator configures." + }, + { + "api": "pdp", + "operation": "GET /callbacks/{key}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP integration: data-update callbacks the PDP operator configures." + }, + { + "api": "pdp", + "operation": "DELETE /callbacks/{key}", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP integration: data-update callbacks the PDP operator configures." + }, + { + "api": "pdp", + "operation": "POST /data-updater/trigger", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route: forces a policy or data reload." + }, + { + "api": "pdp", + "operation": "PATCH /facts/resource_instances/{instance_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.resource_instances.update() with proxy_facts_via_pdp; no offline test sends this request yet." + }, + { + "api": "pdp", + "operation": "DELETE /facts/role_assignments", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.role_assignments.unassign() with proxy_facts_via_pdp; no offline test sends this request yet." + }, + { + "api": "pdp", + "operation": "PUT /facts/users/{user_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.users.sync() with proxy_facts_via_pdp; no offline test sends this request yet." + }, + { + "api": "pdp", + "operation": "PATCH /facts/users/{user_id}", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.users.update() with proxy_facts_via_pdp; no offline test sends this request yet." + }, + { + "api": "pdp", + "operation": "DELETE /facts/users/{user_id}/roles", + "stage": "GA", + "status": "untested", + "ticket": "PER-16177", + "reason": "Called by permit.api.users.unassign_role() with proxy_facts_via_pdp; no offline test sends this request yet." + }, + { + "api": "pdp", + "operation": "GET /healthchecks/opa/healthy", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP health check for the deployment, not an application API." + }, + { + "api": "pdp", + "operation": "GET /healthchecks/opa/ready", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP health check for the deployment, not an application API." + }, + { + "api": "pdp", + "operation": "GET /healthchecks/opa/system", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP health check for the deployment, not an application API." + }, + { + "api": "pdp", + "operation": "POST /kong", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP integration with an API gateway (Kong or nginx), which calls it directly." + }, + { + "api": "pdp", + "operation": "POST /nginx_allowed", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP integration with an API gateway (Kong or nginx), which calls it directly." + }, + { + "api": "pdp", + "operation": "GET /opal-server/connectivity", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route: control-plane connectivity for offline mode." + }, + { + "api": "pdp", + "operation": "POST /opal-server/connectivity/disable", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route: control-plane connectivity for offline mode." + }, + { + "api": "pdp", + "operation": "POST /opal-server/connectivity/enable", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route: control-plane connectivity for offline mode." + }, + { + "api": "pdp", + "operation": "GET /policy-store/config", + "stage": "deprecated", + "status": "excluded", + "ticket": "PER-16337", + "reason": "Deprecated PDP infrastructure route." + }, + { + "api": "pdp", + "operation": "POST /policy-updater/trigger", + "stage": "GA", + "status": "excluded", + "ticket": "PER-16337", + "reason": "PDP operator route: forces a policy or data reload." + } + ], + "sdk_only": [ + { + "request": "DELETE /facts/bulk/resource_instances", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.resource_instances.bulk_delete() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/bulk/resource_instances without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "PUT /facts/bulk/resource_instances", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.resource_instances.bulk_replace() here. The PDP passes it through to the control plane's PUT /v2/facts/{proj_id}/{env_id}/bulk/resource_instances without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "POST /facts/bulk/tenants", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.tenants.bulk_create() here. The PDP passes it through to the control plane's POST /v2/facts/{proj_id}/{env_id}/bulk/tenants without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "DELETE /facts/bulk/tenants", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.tenants.bulk_delete() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/bulk/tenants without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "POST /facts/bulk/users", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.users.bulk_create() here. The PDP passes it through to the control plane's POST /v2/facts/{proj_id}/{env_id}/bulk/users without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "DELETE /facts/tenants/{tenant_id}", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.tenants.delete() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id} without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "DELETE /facts/tenants/{tenant_id}/users/{user_id}", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.tenants.delete_tenant_user() here. The PDP passes it through to the control plane's DELETE /v2/facts/{proj_id}/{env_id}/tenants/{tenant_id}/users/{user_id} without listing it in its spec, and ignores X-Wait-Timeout on it." + }, + { + "request": "POST /v2/auth/elements_login_as", + "status": "undocumented", + "ticket": "PER-16342", + "reason": "permit.elements.login_as() calls it; the API serves it, but the public spec does not list it." + }, + { + "request": "POST /v2/echo", + "status": "test-only", + "ticket": "", + "reason": "A made-up route tests/test_fix_serialization.py sends request bodies to through SimpleHttpClient; no SDK method calls it." + }, + { + "request": "PUT /v2/echo", + "status": "test-only", + "ticket": "", + "reason": "A made-up route tests/test_fix_serialization.py sends request bodies to through SimpleHttpClient; no SDK method calls it." + }, + { + "request": "PATCH /v2/echo", + "status": "test-only", + "ticket": "", + "reason": "A made-up route tests/test_fix_serialization.py sends request bodies to through SimpleHttpClient; no SDK method calls it." + }, + { + "request": "DELETE /v2/nobody", + "status": "test-only", + "ticket": "", + "reason": "A made-up route tests/test_fix_serialization.py sends a body-less request to through SimpleHttpClient; no SDK method calls it." + }, + { + "request": "GET /probe", + "status": "test-only", + "ticket": "", + "reason": "A made-up route tests/test_offline_regressions.py uses to check how SimpleHttpClient handles success statuses; no SDK method calls it." + } + ] +} diff --git a/.github/scripts/test_api_coverage.py b/.github/scripts/test_api_coverage.py new file mode 100644 index 00000000..78e21051 --- /dev/null +++ b/.github/scripts/test_api_coverage.py @@ -0,0 +1,1086 @@ +"""Contract tests for api_coverage.py. + +These pin what the workflows rely on: which operations count as covered, which +results fail the report and which are only listed, that the allowlist explains exactly +what it records and goes stale when it no longer does, and that a report that could not +run exits 2 and never reads as clean. The last tests run the report on the committed +snapshots and allowlist, with planted failures. + +Run with: +uv run --only-dev pytest -c .github/scripts/pytest.ini .github/scripts/test_api_coverage.py +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest + +SCRIPT = Path(__file__).parent / "api_coverage.py" +REPO_ROOT = Path(__file__).resolve().parents[2] +SNAPSHOTS = REPO_ROOT / ".github" / "api-specs" +ALLOWLIST = Path(__file__).parent / "api_coverage_allowlist.json" + +sys.path.insert(0, str(Path(__file__).parent)) + +import api_coverage # noqa: E402 - importable only once sys.path has its directory +from api_coverage import ( # noqa: E402 + DEPRECATED, + EAP, + GA, + load_spec, + main, + normalize, + stage_of, + template_pattern, +) + +CONTROL_PLANE = api_coverage.CONTROL_PLANE + +# A small control plane: a list route, a literal route beside a parameterized one, and a +# route with a trailing slash. +CP_OPS: list[tuple[str, str, dict[str, Any]]] = [ + ("GET", "/v2/users", {"tags": ["Users"]}), + ("GET", "/v2/users/{user_id}", {"tags": ["Users"]}), + ("GET", "/v2/groups/direct", {"tags": ["Groups"]}), + ("GET", "/v2/groups/{group_key}", {"tags": ["Groups"], "deprecated": True}), + ("GET", "/v2/templates/", {"tags": ["Email Templates"]}), + ("POST", "/v2/requests", {"tags": ["Access Requests (EAP)"]}), +] +PDP_OPS: list[tuple[str, str, dict[str, Any]]] = [ + ("POST", "/allowed", {"tags": ["Authorization API"]}), +] +# A request for every GA operation above, so a report on them passes. +COVERING = [ + ("GET", "/v2/users"), + ("GET", "/v2/users/u1"), + ("GET", "/v2/groups/direct"), + ("GET", "/v2/templates/"), + ("POST", "/allowed"), +] + + +def spec_document(operations: list[tuple[str, str, dict[str, Any]]]) -> dict[str, Any]: + paths: dict[str, dict[str, Any]] = {} + for method, path, extra in operations: + paths.setdefault(path, {})[method.lower()] = {"summary": f"{method} {path}", **extra} + return {"openapi": "3.1.0", "info": {"title": "test"}, "paths": paths} + + +def request_line( + method: str, path: str, *, status: int | None = 200, test: str = "t::a", e2e: bool = False +) -> dict[str, Any]: + return { + "kind": "request", + "method": method, + "path": path, + "status": status, + "test": test, + "e2e": e2e, + } + + +def write_record( + path: Path, + requests: list[dict[str, Any]], + *, + exitstatus: int = 0, + header: bool = True, + session: bool = True, +) -> Path: + lines = [{"kind": "header", "version": 1}] if header else [] + lines += requests + if session: + lines.append({"kind": "session", "exitstatus": exitstatus, "tests": 3}) + path.write_text("".join(json.dumps(line) + "\n" for line in lines), encoding="utf-8") + return path + + +def entry( + operation: str, + *, + api: str = CONTROL_PLANE, + stage: str = GA, + status: str = "excluded", + ticket: str = "PER-1", + reason: str = "a reason", +) -> dict[str, Any]: + return { + "api": api, + "operation": operation, + "stage": stage, + "status": status, + "ticket": ticket, + "reason": reason, + } + + +def sdk_only(request: str, *, status: str = "test-only", ticket: str = "") -> dict[str, Any]: + return {"request": request, "status": status, "ticket": ticket, "reason": "a reason"} + + +@dataclass +class Outcome: + code: int + summary: str + result: dict[str, Any] + output: str + + +def report( + tmp_path: Path, + *, + requests: list[tuple[str, str]] | list[dict[str, Any]] = COVERING, + cp_ops: list[tuple[str, str, dict[str, Any]]] = CP_OPS, + pdp_ops: list[tuple[str, str, dict[str, Any]]] = PDP_OPS, + operations: list[dict[str, Any]] | None = None, + sdk_only_entries: list[dict[str, Any]] | None = None, + e2e: list[list[dict[str, Any]]] | None = None, + extra: tuple[str, ...] = (), +) -> Outcome: + """Write every input to tmp_path and run the report on them in-process.""" + (tmp_path / "cp.json").write_text(json.dumps(spec_document(cp_ops)), encoding="utf-8") + (tmp_path / "pdp.json").write_text(json.dumps(spec_document(pdp_ops)), encoding="utf-8") + lines = [r if isinstance(r, dict) else request_line(*r) for r in requests] + write_record(tmp_path / "offline.jsonl", lines) + allowlist = {"operations": operations or [], "sdk_only": sdk_only_entries or []} + (tmp_path / "allowlist.json").write_text(json.dumps(allowlist), encoding="utf-8") + e2e_args: list[str] = [] + for index, record in enumerate(e2e or []): + e2e_args += ["--e2e-record", str(write_record(tmp_path / f"e2e-{index}.jsonl", record))] + return run_report( + tmp_path, + "--spec", + f"control-plane={tmp_path / 'cp.json'}", + "--spec", + f"pdp={tmp_path / 'pdp.json'}", + "--allowlist", + str(tmp_path / "allowlist.json"), + "--record", + str(tmp_path / "offline.jsonl"), + "--min-records", + "1", + "--min-operations", + "control-plane=1", + "--min-operations", + "pdp=1", + *e2e_args, + *extra, + ) + + +def run_report(tmp_path: Path, *args: str) -> Outcome: + summary, result, output = tmp_path / "summary.md", tmp_path / "result.json", tmp_path / "out" + for stale_output in (summary, result, output): + stale_output.unlink(missing_ok=True) + code = main( + [ + "report", + *args, + "--summary", + str(summary), + "--json", + str(result), + "--github-output", + str(output), + ] + ) + return Outcome( + code=code, + summary=summary.read_text(encoding="utf-8"), + result=json.loads(result.read_text(encoding="utf-8")), + output=output.read_text(encoding="utf-8") if output.exists() else "", + ) + + +def statuses(outcome: Outcome) -> dict[str, str]: + return {op["operation"]: op["status"] for op in outcome.result["operations"]} + + +def problems(outcome: Outcome, kind: str) -> list[str]: + return [p["subject"] for p in outcome.result["problems"] if p["kind"] == kind] + + +# --- passing and failing ------------------------------------------------------ + + +def test_every_ga_operation_covered_passes(tmp_path: Path) -> None: + outcome = report(tmp_path) + assert outcome.code == 0, outcome.summary + assert "Every GA operation is covered or allowlisted" in outcome.summary + assert outcome.result["result"] == "pass" + assert statuses(outcome)["GET /v2/users/{user_id}"] == "covered" + assert outcome.output == "missing=0\nstale=0\nchanged=0\nsdk_only=0\n" + + +def test_a_ga_operation_no_test_sends_fails_and_is_named(tmp_path: Path) -> None: + outcome = report(tmp_path, requests=[r for r in COVERING if r[1] != "/v2/users/u1"]) + assert outcome.code == 1 + assert problems(outcome, "missing") == ["Control plane GET /v2/users/{user_id}"] + assert statuses(outcome)["GET /v2/users/{user_id}"] == "missing" + assert "GA operations neither covered nor allowlisted" in outcome.summary + assert "missing=1\n" in outcome.output + + +def test_missing_eap_and_deprecated_operations_are_listed_but_do_not_fail(tmp_path: Path) -> None: + outcome = report(tmp_path) + assert outcome.code == 0 + assert statuses(outcome)["POST /v2/requests"] == "missing" + assert statuses(outcome)["GET /v2/groups/{group_key}"] == "missing" + assert "Missing operations (2)" in outcome.summary + + +def test_a_missing_pdp_operation_fails_like_a_control_plane_one(tmp_path: Path) -> None: + outcome = report(tmp_path, requests=[r for r in COVERING if r[1] != "/allowed"]) + assert outcome.code == 1 + assert problems(outcome, "missing") == ["PDP POST /allowed"] + + +def test_allowlisted_operations_do_not_fail(tmp_path: Path) -> None: + outcome = report( + tmp_path, + requests=[r for r in COVERING if r[1] != "/v2/users/u1"], + operations=[entry("GET /v2/users/{user_id}", status="untested", reason="sdk.users.get()")], + ) + assert outcome.code == 0, outcome.summary + assert statuses(outcome)["GET /v2/users/{user_id}"] == "untested" + assert "sdk.users.get()" in outcome.summary + + +def test_offline_requests_count_whatever_status_they_got(tmp_path: Path) -> None: + requests = [request_line(method, path, status=None) for method, path in COVERING] + assert report(tmp_path, requests=requests).code == 0 + + +def test_requests_from_e2e_tests_in_the_offline_record_do_not_count(tmp_path: Path) -> None: + requests = [request_line(m, p, e2e=p == "/v2/users/u1") for m, p in COVERING] + outcome = report(tmp_path, requests=requests) + assert outcome.code == 1 + assert problems(outcome, "missing") == ["Control plane GET /v2/users/{user_id}"] + + +# --- stages and matching ------------------------------------------------------ + + +@pytest.mark.parametrize( + ("operation", "stage"), + [ + ({"tags": ["Users"]}, GA), + ({"tags": ["Access Requests (EAP)"]}, EAP), + ({"tags": ["OPAL Data ( EAP )"]}, EAP), + ({"tags": ["Users"], "summary": "List group users (EAP)"}, GA), + ({"tags": ["Groups"], "deprecated": True}, DEPRECATED), + ({"tags": ["Policy Guards (EAP)"], "deprecated": True}, DEPRECATED), + ({"tags": ["LEAP year"]}, GA), + ({}, GA), + ], +) +def test_stage_follows_the_deprecated_flag_then_the_tags( + operation: dict[str, Any], stage: str +) -> None: + assert stage_of(operation) == stage + + +def test_a_literal_segment_beats_a_parameter(tmp_path: Path) -> None: + outcome = report(tmp_path) + tests = {op["operation"]: op["test_count"] for op in outcome.result["operations"]} + assert tests["GET /v2/groups/direct"] == 1 + assert tests["GET /v2/groups/{group_key}"] == 0 + + +@pytest.mark.parametrize( + ("template", "path", "matches"), + [ + ("/v2/users/{user_id}", "/v2/users/u1", True), + ("/v2/users/{user_id}", "/v2/users/a%2Fb", True), + ("/v2/users/{user_id}", "/v2/users/a/b", False), + ("/v2/users/{user_id}", "/v2/users/", False), + ("/v2/users/{user_id}", "/v2/users/u1/roles", False), + ("/v2/templates/", "/v2/templates", False), + ("/v2/templates/", "/v2/templates/", True), + ("/v2/users", "/v2/users.json", False), + ("/v2/a.b", "/v2/aXb", False), + ], +) +def test_template_pattern(template: str, path: str, *, matches: bool) -> None: + assert bool(template_pattern(template).match(path)) is matches + + +def test_a_request_with_another_method_does_not_cover_the_operation(tmp_path: Path) -> None: + requests = [*COVERING[:1], ("DELETE", "/v2/users/u1"), *COVERING[2:]] + outcome = report(tmp_path, requests=requests) + assert problems(outcome, "missing") == ["Control plane GET /v2/users/{user_id}"] + assert problems(outcome, "sdk-only") == ["DELETE /v2/users/u1 (sent by t::a)"] + + +def test_parameter_names_do_not_decide_identity() -> None: + assert normalize("/v2/{proj_id}/users/{user_id}") == normalize("/v2/{p}/users/{key}") + + +def test_an_entry_matches_an_operation_whose_parameters_were_renamed(tmp_path: Path) -> None: + outcome = report( + tmp_path, + requests=[r for r in COVERING if r[1] != "/v2/users/u1"], + operations=[entry("GET /v2/users/{user_key}")], + ) + assert outcome.code == 0, outcome.summary + + +# --- allowlist staleness and changes ------------------------------------------ + + +def test_an_entry_for_an_operation_a_test_now_covers_is_stale(tmp_path: Path) -> None: + outcome = report(tmp_path, operations=[entry("GET /v2/users/{user_id}", status="deferred")]) + assert outcome.code == 1 + assert problems(outcome, "stale") == ["Control plane GET /v2/users/{user_id}"] + assert statuses(outcome)["GET /v2/users/{user_id}"] == "covered" + assert "Stale allowlist entries" in outcome.summary + + +def test_an_entry_for_an_operation_not_in_the_spec_is_stale(tmp_path: Path) -> None: + outcome = report(tmp_path, operations=[entry("GET /v2/gone")]) + assert outcome.code == 1 + assert problems(outcome, "stale") == ["Control plane GET /v2/gone"] + + +def test_an_entry_for_the_other_api_is_stale(tmp_path: Path) -> None: + outcome = report( + tmp_path, + requests=[r for r in COVERING if r[1] != "/allowed"], + operations=[entry("POST /allowed", api=CONTROL_PLANE)], + ) + assert problems(outcome, "stale") == ["Control plane POST /allowed"] + assert problems(outcome, "missing") == ["PDP POST /allowed"] + + +def test_an_entry_whose_stage_changed_fails(tmp_path: Path) -> None: + outcome = report(tmp_path, operations=[entry("POST /v2/requests", stage=GA)]) + assert outcome.code == 1 + assert problems(outcome, "changed") == ["Control plane POST /v2/requests"] + detail = next(p["detail"] for p in outcome.result["problems"] if p["kind"] == "changed") + assert detail == "allowlisted as GA, now EAP in the spec" + + +def test_an_eap_operation_that_turns_ga_fails_even_though_it_is_allowlisted( + tmp_path: Path, +) -> None: + cp_ops = [*CP_OPS[:-1], ("POST", "/v2/requests", {"tags": ["Access Requests"]})] + outcome = report(tmp_path, cp_ops=cp_ops, operations=[entry("POST /v2/requests", stage=EAP)]) + assert outcome.code == 1 + assert problems(outcome, "changed") == ["Control plane POST /v2/requests"] + + +# --- SDK-only requests -------------------------------------------------------- + + +def test_a_request_no_spec_operation_matches_fails(tmp_path: Path) -> None: + outcome = report(tmp_path, requests=[*COVERING, ("POST", "/v2/echo")]) + assert outcome.code == 1 + assert problems(outcome, "sdk-only") == ["POST /v2/echo (sent by t::a)"] + assert "**not allowlisted**" in outcome.summary + + +def test_an_sdk_only_entry_explains_matching_requests(tmp_path: Path) -> None: + requests = [*COVERING, ("DELETE", "/facts/tenants/t1"), ("DELETE", "/facts/tenants/t2")] + outcome = report( + tmp_path, + requests=requests, + sdk_only_entries=[ + sdk_only("DELETE /facts/tenants/{tenant_id}", status="undocumented", ticket="PER-2") + ], + ) + assert outcome.code == 0, outcome.summary + assert outcome.result["sdk_only"][0]["request"] == "DELETE /facts/tenants/{tenant_id}" + assert outcome.result["sdk_only"][0]["requests"] == 2 + + +def test_an_sdk_only_entry_no_request_matches_is_stale(tmp_path: Path) -> None: + outcome = report(tmp_path, sdk_only_entries=[sdk_only("POST /v2/echo")]) + assert outcome.code == 1 + assert problems(outcome, "stale") == ["POST /v2/echo"] + + +def test_an_sdk_only_entry_for_a_route_the_spec_now_lists_goes_stale(tmp_path: Path) -> None: + outcome = report( + tmp_path, + requests=[*COVERING, ("POST", "/v2/echo")], + cp_ops=[*CP_OPS, ("POST", "/v2/echo", {"tags": ["Echo"]})], + sdk_only_entries=[sdk_only("POST /v2/echo")], + ) + assert outcome.code == 1 + assert problems(outcome, "stale") == ["POST /v2/echo"] + assert statuses(outcome)["POST /v2/echo"] == "covered" + + +# --- the end-to-end column ---------------------------------------------------- + + +def test_without_an_e2e_record_the_column_says_not_run(tmp_path: Path) -> None: + outcome = report(tmp_path) + assert "End to end: **not run** (no end-to-end record was given)" in outcome.summary + assert outcome.result["e2e"] == "not run" + assert {op["e2e"] for op in outcome.result["operations"]} == {"not run"} + assert "| Control plane | GA | 4 | 4 | 0 | 0 | 0 | 0 | not run |" in outcome.summary + + +def test_an_e2e_record_without_e2e_requests_also_says_not_run(tmp_path: Path) -> None: + outcome = report(tmp_path, e2e=[[request_line("GET", "/v2/users", e2e=False)]]) + assert "the end-to-end records hold no e2e request" in outcome.summary + assert {op["e2e"] for op in outcome.result["operations"]} == {"not run"} + + +def test_e2e_requests_fill_the_column_only_on_success(tmp_path: Path) -> None: + e2e = [ + request_line("GET", "/v2/users", status=200, e2e=True), + request_line("GET", "/v2/users/u1", status=404, e2e=True), + request_line("POST", "/allowed", status=None, e2e=True), + request_line("GET", "/v2/groups/direct", status=200, e2e=False), + ] + outcome = report(tmp_path, e2e=[e2e]) + exercised = {op["operation"]: op["e2e"] for op in outcome.result["operations"]} + assert exercised["GET /v2/users"] is True + assert exercised["GET /v2/users/{user_id}"] is False + assert exercised["POST /allowed"] is False + assert exercised["GET /v2/groups/direct"] is False + assert "| Control plane | GA | 4 | 4 | 0 | 0 | 0 | 0 | 1 |" in outcome.summary + + +def test_e2e_requests_never_make_an_operation_covered(tmp_path: Path) -> None: + outcome = report( + tmp_path, + requests=[r for r in COVERING if r[1] != "/v2/users/u1"], + e2e=[[request_line("GET", "/v2/users/u1", e2e=True)]], + ) + assert outcome.code == 1 + assert problems(outcome, "missing") == ["Control plane GET /v2/users/{user_id}"] + + +def test_records_from_several_e2e_runs_add_up(tmp_path: Path) -> None: + outcome = report( + tmp_path, + e2e=[ + [request_line("GET", "/v2/users", e2e=True)], + [request_line("POST", "/allowed", e2e=True)], + ], + ) + exercised = {op["operation"] for op in outcome.result["operations"] if op["e2e"] is True} + assert exercised == {"GET /v2/users", "POST /allowed"} + + +def test_an_e2e_record_from_a_failed_session_is_used_and_flagged(tmp_path: Path) -> None: + record = write_record( + tmp_path / "failed.jsonl", [request_line("GET", "/v2/users", e2e=True)], exitstatus=1 + ) + outcome = report(tmp_path, extra=("--e2e-record", str(record))) + assert outcome.code == 0 + assert "The session exited 1, so the column may be incomplete." in outcome.summary + exercised = {op["operation"] for op in outcome.result["operations"] if op["e2e"] is True} + assert exercised == {"GET /v2/users"} + + +def test_e2e_requests_that_match_nothing_are_listed(tmp_path: Path) -> None: + outcome = report(tmp_path, e2e=[[request_line("GET", "/nowhere", e2e=True)]]) + assert outcome.code == 0 + assert outcome.result["e2e_unmatched"] == ["GET /nowhere"] + + +# --- did not run -------------------------------------------------------------- + + +def assert_did_not_run(outcome: Outcome, message: str) -> None: + assert outcome.code == 2 + assert "The report did not run" in outcome.summary + assert "Every GA operation" not in outcome.summary + assert outcome.result == { + "result": "did-not-run", + "exit_code": 2, + "reason": outcome.result["reason"], + } + assert re.search(message, outcome.result["reason"]), outcome.result["reason"] + + +@pytest.mark.parametrize( + ("record", "message"), + [ + ("", "is empty; the recorder never ran"), + ("not json\n", "line 1 of the request record .* is not JSON"), + ('{"kind": "header", "version": 2}\n', "does not start with a version 1 header"), + ('{"kind": "request"}\n', "does not start with a version 1 header"), + ('{"kind": "header", "version": 1}\n', "has no session line"), + ( + '{"kind": "header", "version": 1}\n{"kind": "other"}\n', + "line 2 of the request record .* has an unknown kind", + ), + ( + '{"kind": "header", "version": 1}\n{"kind": "request", "method": "GET"}\n', + "line 2 .* is not a well-formed request", + ), + ( + ( + '{"kind": "header", "version": 1}\n' + '{"kind": "session", "exitstatus": 0, "tests": 1}\n' + '{"kind": "session", "exitstatus": 0, "tests": 1}\n' + ), + "continues after its session line", + ), + ( + '{"kind": "header", "version": 1}\n{"kind": "session", "exitstatus": "0"}\n', + "session line .* is malformed", + ), + ( + '{"kind": "header", "version": 1}\n{"kind": "session", "exitstatus": 0, "tests": 1}\n', + "holds 0 offline requests, fewer than the minimum of 1", + ), + ], +) +def test_a_record_that_cannot_be_trusted_exits_2(tmp_path: Path, record: str, message: str) -> None: + report(tmp_path) + (tmp_path / "offline.jsonl").write_text(record, encoding="utf-8") + outcome = rerun(tmp_path) + assert_did_not_run(outcome, message) + + +def rerun( + tmp_path: Path, + *extra: str, + record: str = "offline.jsonl", + min_operations: tuple[str, ...] = ("control-plane=1", "pdp=1"), +) -> Outcome: + """Run the report again on the inputs report() wrote, with some of them replaced.""" + minimums = [arg for minimum in min_operations for arg in ("--min-operations", minimum)] + return run_report( + tmp_path, + "--spec", + f"control-plane={tmp_path / 'cp.json'}", + "--spec", + f"pdp={tmp_path / 'pdp.json'}", + "--allowlist", + str(tmp_path / "allowlist.json"), + "--record", + str(tmp_path / record), + "--min-records", + "1", + *minimums, + *extra, + ) + + +def test_a_missing_record_exits_2(tmp_path: Path) -> None: + report(tmp_path) + assert_did_not_run(rerun(tmp_path, record="absent.jsonl"), "could not read the request record") + + +def test_a_record_from_a_failed_offline_session_exits_2(tmp_path: Path) -> None: + report(tmp_path) + write_record(tmp_path / "failed.jsonl", [request_line("GET", "/v2/users")], exitstatus=1) + assert_did_not_run(rerun(tmp_path, record="failed.jsonl"), "exited 1") + + +def test_fewer_offline_requests_than_the_minimum_exits_2(tmp_path: Path) -> None: + report(tmp_path) + assert_did_not_run( + rerun(tmp_path, "--min-records", "6"), + "holds 5 offline requests, fewer than the minimum of 6", + ) + + +def test_exactly_the_minimum_of_requests_and_operations_runs(tmp_path: Path) -> None: + report(tmp_path) + outcome = rerun( + tmp_path, + "--min-records", + str(len(COVERING)), + min_operations=(f"control-plane={len(CP_OPS)}", f"pdp={len(PDP_OPS)}"), + ) + assert outcome.code == 0, outcome.summary + + +def test_one_operation_below_the_minimum_exits_2(tmp_path: Path) -> None: + report(tmp_path) + outcome = rerun(tmp_path, min_operations=("control-plane=1", f"pdp={len(PDP_OPS) + 1}")) + assert_did_not_run(outcome, "PDP spec at .* lists 1 operations, fewer than the minimum of 2") + + +def test_e2e_requests_do_not_count_towards_the_offline_minimum(tmp_path: Path) -> None: + report(tmp_path) + requests = [request_line(m, p) for m, p in COVERING] + [ + request_line("GET", "/v2/users", e2e=True) + ] + write_record(tmp_path / "mixed.jsonl", requests) + outcome = rerun(tmp_path, "--min-records", "6", record="mixed.jsonl") + assert_did_not_run(outcome, "holds 5 offline requests") + + +def test_an_unreadable_e2e_record_exits_2(tmp_path: Path) -> None: + report(tmp_path) + (tmp_path / "e2e.jsonl").write_text("{}\n", encoding="utf-8") + assert_did_not_run( + rerun(tmp_path, "--e2e-record", str(tmp_path / "e2e.jsonl")), + "does not start with a version 1", + ) + + +@pytest.mark.parametrize( + ("spec", "message"), + [ + (None, "could not read the Control plane spec"), + ("502", "Control plane spec at .* is not valid JSON"), + ("[]", "has no `paths` object"), + ('{"paths": {}}', "lists 0 operations, fewer than the minimum of 1"), + ( + '{"paths": {"/a/{x}": {"get": {}}, "/a/{y}": {"get": {}}}}', + "GET /a/{y} and GET /a/{x} are the same operation", + ), + ], +) +def test_a_spec_that_cannot_be_read_exits_2(tmp_path: Path, spec: str | None, message: str) -> None: + report(tmp_path) + if spec is None: + (tmp_path / "cp.json").unlink() + else: + (tmp_path / "cp.json").write_text(spec, encoding="utf-8") + assert_did_not_run(rerun(tmp_path), message) + + +def test_a_spec_with_fewer_operations_than_the_default_minimum_exits_2(tmp_path: Path) -> None: + report(tmp_path) + outcome = run_report( + tmp_path, + "--spec", + f"control-plane={tmp_path / 'cp.json'}", + "--spec", + f"pdp={tmp_path / 'pdp.json'}", + "--allowlist", + str(tmp_path / "allowlist.json"), + "--record", + str(tmp_path / "offline.jsonl"), + "--min-records", + "1", + ) + assert_did_not_run(outcome, "lists 6 operations, fewer than the minimum of 200") + + +@pytest.mark.parametrize( + ("allowlist", "message"), + [ + ("not json", "the allowlist at .* is not valid JSON"), + ("[]", "is not a JSON object"), + ('{"operations": []}', 'needs a "sdk_only" list of objects'), + ('{"operations": [1], "sdk_only": []}', 'needs a "operations" list of objects'), + (json.dumps({"operations": [entry("GET /v2/x", reason=" ")], "sdk_only": []}), '"reason"'), + ( + json.dumps({"operations": [entry("GET /v2/x", status="ignored")], "sdk_only": []}), + '"status" must be one of', + ), + ( + json.dumps({"operations": [entry("GET /v2/x", stage="beta")], "sdk_only": []}), + '"stage" must be one of', + ), + ( + json.dumps({"operations": [entry("GET /v2/x", api="cloud")], "sdk_only": []}), + '"api" must be one of', + ), + ( + json.dumps({"operations": [entry("get /v2/x")], "sdk_only": []}), + "upper-case HTTP method", + ), + (json.dumps({"operations": [entry("GET v2/x")], "sdk_only": []}), "does not name a path"), + ( + json.dumps( + {"operations": [entry("GET /v2/x", status="deferred", ticket="")], "sdk_only": []} + ), + '"ticket"', + ), + ( + json.dumps({"operations": [entry("GET /v2/x", ticket="soon")], "sdk_only": []}), + "not a ticket id", + ), + ( + json.dumps( + {"operations": [entry("GET /v2/x/{a}"), entry("GET /v2/x/{b}")], "sdk_only": []} + ), + "listed more than once", + ), + ( + json.dumps({"operations": [], "sdk_only": [sdk_only("POST /v2/echo", status="odd")]}), + '"status" must be', + ), + ( + json.dumps( + {"operations": [], "sdk_only": [sdk_only("POST /v2/a", status="undocumented")]} + ), + '"ticket"', + ), + ( + json.dumps( + {"operations": [], "sdk_only": [sdk_only("POST /v2/a"), sdk_only("POST /v2/a")]} + ), + "listed more than once", + ), + ], +) +def test_an_invalid_allowlist_exits_2(tmp_path: Path, allowlist: str, message: str) -> None: + report(tmp_path) + (tmp_path / "allowlist.json").write_text(allowlist, encoding="utf-8") + assert_did_not_run(rerun(tmp_path), message) + + +@pytest.mark.parametrize( + ("args", "message"), + [ + (("--spec", "control-plane=a.json"), "--spec is needed for each of control-plane, pdp"), + (("--spec", "cloud=a.json", "--spec", "pdp=b.json"), "NAME one of control-plane, pdp"), + (("--spec", "pdp=a.json", "--spec", "pdp=b.json"), "--spec pdp is given more than once"), + ], +) +def test_bad_spec_options_exit_2(tmp_path: Path, args: tuple[str, ...], message: str) -> None: + report(tmp_path) + outcome = run_report( + tmp_path, + *args, + "--allowlist", + str(tmp_path / "allowlist.json"), + "--record", + str(tmp_path / "x"), + ) + assert_did_not_run(outcome, message) + + +def test_an_unexpected_error_exits_2_not_1(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def explode(**_: object) -> None: + msg = "boom" + raise RuntimeError(msg) + + report(tmp_path) + monkeypatch.setattr(api_coverage, "build_report", explode) + assert_did_not_run(rerun(tmp_path), "RuntimeError: boom") + + +def test_an_error_while_writing_the_outputs_exits_2(tmp_path: Path) -> None: + report(tmp_path) + code = main( + [ + "report", + "--spec", + f"control-plane={tmp_path / 'cp.json'}", + "--spec", + f"pdp={tmp_path / 'pdp.json'}", + "--allowlist", + str(tmp_path / "allowlist.json"), + "--record", + str(tmp_path / "offline.jsonl"), + "--min-records", + "1", + "--min-operations", + "control-plane=1", + "--min-operations", + "pdp=1", + "--summary", + str(tmp_path), + ] + ) + assert code == 2 + + +# --- outputs ------------------------------------------------------------------ + + +def test_github_output_carries_one_count_per_failure_kind(tmp_path: Path) -> None: + outcome = report( + tmp_path, + requests=[*COVERING[1:], ("POST", "/v2/echo")], + operations=[entry("GET /v2/gone"), entry("POST /v2/requests")], + ) + assert outcome.output == "missing=1\nstale=1\nchanged=1\nsdk_only=1\n" + + +def test_the_summary_is_appended_to_the_given_file(tmp_path: Path) -> None: + report(tmp_path) + summary = tmp_path / "step-summary.md" + summary.write_text("earlier step\n", encoding="utf-8") + code = main( + [ + "report", + "--spec", + f"control-plane={tmp_path / 'cp.json'}", + "--spec", + f"pdp={tmp_path / 'pdp.json'}", + "--allowlist", + str(tmp_path / "allowlist.json"), + "--record", + str(tmp_path / "offline.jsonl"), + "--min-records", + "1", + "--min-operations", + "control-plane=1", + "--min-operations", + "pdp=1", + "--summary", + str(summary), + ] + ) + assert code == 0 + assert summary.read_text(encoding="utf-8").startswith("earlier step\n## API coverage\n") + + +def test_pipes_and_backticks_cannot_break_the_tables(tmp_path: Path) -> None: + cp_ops = [*CP_OPS, ("GET", "/v2/odd", {"tags": ["Odd"], "summary": "a | b `c`"})] + outcome = report(tmp_path, cp_ops=cp_ops, operations=[entry("GET /v2/odd", reason="x | `y`")]) + assert "x \\| 'y'" in outcome.summary + assert "x | `y`" not in outcome.summary + + +def test_the_json_result_lists_every_operation_with_its_tests(tmp_path: Path) -> None: + requests = [request_line(m, p, test=f"t::{i}") for i, (m, p) in enumerate(COVERING)] + requests += [request_line("GET", "/v2/users", test=f"t::more{i}") for i in range(6)] + outcome = report(tmp_path, requests=requests) + users = next(op for op in outcome.result["operations"] if op["operation"] == "GET /v2/users") + assert users["test_count"] == 7 + assert len(users["tests"]) == 5 + assert users["stage"] == GA + assert users["tags"] == ["Users"] + assert len(outcome.result["operations"]) == len(CP_OPS) + len(PDP_OPS) + assert outcome.result["offline"] == {"requests": 11, "tests": 3} + + +def test_a_baseline_lists_the_spec_changes_since_the_snapshot(tmp_path: Path) -> None: + baseline = tmp_path / "baseline.json" + old = [ + *CP_OPS[:-1], + ("POST", "/v2/requests", {"tags": ["Access Requests"]}), + ("GET", "/v2/old", {}), + ] + baseline.write_text(json.dumps(spec_document(old)), encoding="utf-8") + outcome = report(tmp_path, extra=("--baseline", f"control-plane={baseline}")) + assert outcome.result["baselines"] == [ + { + "api": CONTROL_PLANE, + "added": [], + "removed": ["GET /v2/old"], + "restaged": [{"operation": "POST /v2/requests", "was": GA, "now": EAP}], + } + ] + assert "- removed: `GET /v2/old` (GA)" in outcome.summary + + +# --- snapshots ---------------------------------------------------------------- + + +def test_a_snapshot_keeps_what_the_report_reads_and_records_its_source(tmp_path: Path) -> None: + document = spec_document(CP_OPS) + document["components"] = {"schemas": {"Big": {"type": "object"}}} + document["paths"]["/v2/users"]["get"]["responses"] = {"200": {"description": "ok"}} + document["paths"]["/v2/users"]["parameters"] = [{"name": "x"}] + source = tmp_path / "full.json" + source.write_text(json.dumps(document), encoding="utf-8") + code = main( + [ + "snapshot", + "control-plane", + str(source), + "--source", + "https://example.test/openapi.json", + "--fetched", + "2026-01-02", + "--out-dir", + str(tmp_path / "out"), + ] + ) + assert code == 0 + snapshot = json.loads((tmp_path / "out" / "control-plane.json").read_text(encoding="utf-8")) + assert "components" not in snapshot + assert snapshot["paths"]["/v2/users"] == { + "get": {"operationId": None, "summary": "GET /v2/users", "tags": ["Users"]} + } + assert snapshot["paths"]["/v2/groups/{group_key}"]["get"]["deprecated"] is True + sidecar = json.loads( + (tmp_path / "out" / "control-plane.source.json").read_text(encoding="utf-8") + ) + assert sidecar == { + "source": "https://example.test/openapi.json", + "fetched": "2026-01-02", + "operations": len(CP_OPS), + } + full = load_spec(CONTROL_PLANE, source, 1) + reduced = load_spec(CONTROL_PLANE, tmp_path / "out" / "control-plane.json", 1) + assert sorted((o.name, o.stage, o.tags) for o in reduced.operations) == sorted( + (o.name, o.stage, o.tags) for o in full.operations + ) + assert "a snapshot of https://example.test/openapi.json taken 2026-01-02" in reduced.source + + +def test_a_snapshot_of_a_document_without_operations_fails(tmp_path: Path) -> None: + source = tmp_path / "empty.json" + source.write_text('{"paths": {}}', encoding="utf-8") + code = main(["snapshot", "pdp", str(source), "--source", "x", "--out-dir", str(tmp_path)]) + assert code == 2 + assert not (tmp_path / "pdp.json").exists() + + +@pytest.mark.parametrize("api", ["control-plane", "pdp"]) +def test_the_committed_snapshots_are_their_own_inventory(tmp_path: Path, api: str) -> None: + committed = SNAPSHOTS / f"{api}.json" + sidecar = json.loads((SNAPSHOTS / f"{api}.source.json").read_text(encoding="utf-8")) + code = main( + [ + "snapshot", + api, + str(committed), + "--source", + sidecar["source"], + "--fetched", + sidecar["fetched"], + "--out-dir", + str(tmp_path), + ] + ) + assert code == 0 + assert (tmp_path / f"{api}.json").read_text(encoding="utf-8") == committed.read_text( + encoding="utf-8" + ) + assert json.loads((tmp_path / f"{api}.source.json").read_text(encoding="utf-8")) == sidecar + + +# --- the committed snapshots and allowlist, with planted failures ------------- + + +def concrete(template: str) -> str: + return re.sub(r"\{[^/{}]*\}", "x", template) + + +def complete_record(allowlist: dict[str, Any]) -> list[dict[str, Any]]: + """A request for every committed operation the allowlist leaves out, and per sdk_only entry.""" + listed = {(e["api"], normalize(e["operation"])) for e in allowlist["operations"]} + requests = [ + request_line(operation.method, concrete(operation.path)) + for api in ("control-plane", "pdp") + for operation in load_spec(api, SNAPSHOTS / f"{api}.json", 1).operations + if (api, normalize(operation.name)) not in listed + ] + method_paths = [e["request"].split(" ", 1) for e in allowlist["sdk_only"]] + requests += [request_line(method, concrete(path)) for method, path in method_paths] + return requests + + +def committed_report( + tmp_path: Path, + *, + requests: list[dict[str, Any]] | None = None, + allowlist: dict[str, Any] | None = None, + control_plane: dict[str, Any] | None = None, +) -> Outcome: + allowlist = allowlist or json.loads(ALLOWLIST.read_text(encoding="utf-8")) + allowlist_path = tmp_path / "allowlist.json" + allowlist_path.write_text(json.dumps(allowlist), encoding="utf-8") + cp_path = SNAPSHOTS / "control-plane.json" + if control_plane is not None: + cp_path = tmp_path / "control-plane.json" + cp_path.write_text(json.dumps(control_plane), encoding="utf-8") + record = write_record( + tmp_path / "offline.jsonl", complete_record(allowlist) if requests is None else requests + ) + return run_report( + tmp_path, + "--spec", + f"control-plane={cp_path}", + "--spec", + f"pdp={SNAPSHOTS / 'pdp.json'}", + "--allowlist", + str(allowlist_path), + "--record", + str(record), + "--min-records", + "1", + ) + + +def test_the_committed_allowlist_agrees_with_the_committed_snapshots(tmp_path: Path) -> None: + """Every entry names an operation in the snapshot, at the stage the snapshot gives it.""" + outcome = committed_report(tmp_path) + assert outcome.code == 0, outcome.summary + allowlist = json.loads(ALLOWLIST.read_text(encoding="utf-8")) + assert all(e["reason"].strip() for e in allowlist["operations"] + allowlist["sdk_only"]) + + +def test_planted_a_new_ga_operation_in_the_snapshot_fails(tmp_path: Path) -> None: + snapshot = json.loads((SNAPSHOTS / "control-plane.json").read_text(encoding="utf-8")) + snapshot["paths"]["/v2/planted/{planted_id}"] = { + "get": {"summary": "Planted", "tags": ["Planted"]} + } + allowlist = json.loads(ALLOWLIST.read_text(encoding="utf-8")) + outcome = committed_report( + tmp_path, requests=complete_record(allowlist), control_plane=snapshot + ) + assert outcome.code == 1 + assert problems(outcome, "missing") == ["Control plane GET /v2/planted/{planted_id}"] + + +def test_planted_a_new_eap_operation_in_the_snapshot_is_listed_but_passes(tmp_path: Path) -> None: + snapshot = json.loads((SNAPSHOTS / "control-plane.json").read_text(encoding="utf-8")) + snapshot["paths"]["/v2/planted"] = {"get": {"summary": "Planted", "tags": ["Planted (EAP)"]}} + allowlist = json.loads(ALLOWLIST.read_text(encoding="utf-8")) + outcome = committed_report( + tmp_path, requests=complete_record(allowlist), control_plane=snapshot + ) + assert outcome.code == 0, outcome.summary + assert statuses(outcome)["GET /v2/planted"] == "missing" + + +def test_planted_a_stale_allowlist_entry_fails(tmp_path: Path) -> None: + allowlist = json.loads(ALLOWLIST.read_text(encoding="utf-8")) + requests = complete_record(allowlist) + allowlist["operations"].append(entry("GET /v2/api-key/scope", status="excluded")) + outcome = committed_report(tmp_path, requests=requests, allowlist=allowlist) + assert outcome.code == 1 + assert problems(outcome, "stale") == ["Control plane GET /v2/api-key/scope"] + + +def test_planted_an_empty_record_does_not_run(tmp_path: Path) -> None: + assert_did_not_run(committed_report(tmp_path, requests=[]), "holds 0 offline requests") + + +def test_the_report_runs_as_a_script(tmp_path: Path) -> None: + report(tmp_path) + completed = subprocess.run( # noqa: S603 - runs the script under test with this interpreter + [ + sys.executable, + str(SCRIPT), + "report", + "--spec", + f"control-plane={tmp_path / 'cp.json'}", + "--spec", + f"pdp={tmp_path / 'pdp.json'}", + "--allowlist", + str(tmp_path / "allowlist.json"), + "--record", + str(tmp_path / "offline.jsonl"), + "--min-records", + "1", + "--min-operations", + "control-plane=1", + "--min-operations", + "pdp=1", + ], + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr + assert completed.stdout.startswith("## API coverage") + + +def test_bad_arguments_exit_2(tmp_path: Path) -> None: + completed = subprocess.run( # noqa: S603 - runs the script under test with this interpreter + [sys.executable, str(SCRIPT), "report", "--record", str(tmp_path / "x")], + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 2 + assert "the following arguments are required: --allowlist" in completed.stderr diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index e34e4880..174ed1a7 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -335,13 +335,15 @@ jobs: # fail this job through .github/scripts/pytest.ini, which turns every # warning into an error; -c reads that file rather than the SDK's # [tool.pytest] in pyproject.toml. The scripts under test are stdlib only, - # so --only-dev leaves the project uninstalled. The schema drift check's - # tests run here too: they live next to the audit scripts and need no more. + # so --only-dev leaves the project uninstalled. The schema drift check's and + # the API coverage report's tests run here too: they live next to the audit + # scripts and need no more. - name: Run CI script tests run: >- uv run --locked --only-dev pytest -c .github/scripts/pytest.ini -q .github/scripts/test_format_audit.py .github/scripts/test_check_schema_drift.py + .github/scripts/test_api_coverage.py - name: Shellcheck the shell scripts run: shellcheck .github/scripts/audit-deps.sh scripts/generate_models.sh From 272137d9c75ea4eebeacd47ae86ae0a94672dd86 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:57:06 +0300 Subject: [PATCH 10/32] Run the API coverage report on every pull request A new API Coverage job in test.yml runs the offline suite with the request recorder and reports against the committed snapshots, in the job summary and the api-coverage-report artifact. The pytest lanes record their requests too and upload the record, which fills the report's end-to-end column. Part of PER-16337. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 113 ++++++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4ac68314..37746828 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,9 @@ env: # Dependabot does not update this. The `e2e (latest PDP image)` job runs the # suite against permitio/pdp-v2:latest, so a new release shows up there first. # To move the pin, take the version's `digest` from - # https://hub.docker.com/v2/repositories/permitio/pdp-v2/tags/. + # https://hub.docker.com/v2/repositories/permitio/pdp-v2/tags/, and refresh + # the PDP spec snapshot the API coverage report reads (.github/api-specs/pdp.json; + # CONTRIBUTING.md, "Moving the PDP pin"). PINNED_PDP_IMAGE: >- permitio/pdp-v2:0.9.16@sha256:e3cf30794ec2d256636b4714641df46e51ee58a3f1f0d24c606e214e0bf8669a @@ -193,6 +195,9 @@ jobs: exit 1 + # PERMIT_API_COVERAGE_RECORD makes tests/api_coverage_recorder.py write down every + # request the tests send. The `API Coverage` job below reads the e2e tests' requests + # from it, for the operations exercised against the real API and PDP. - name: Test with pytest env: PDP_URL: http://localhost:7766 @@ -200,8 +205,20 @@ jobs: ORG_PDP_API_KEY: ${{ env.ENV_API_KEY }} PROJECT_PDP_API_KEY: ${{ env.ENV_API_KEY }} PDP_API_KEY: ${{ env.ENV_API_KEY }} + PERMIT_API_COVERAGE_RECORD: >- + ${{ runner.temp }}/api-coverage/e2e-${{ matrix.dependency-group }}.jsonl run: uv run --no-sync pytest -s --cache-clear tests/ + # The record holds methods and paths only: no query strings, headers or bodies. + - name: Upload the request record + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: api-coverage-e2e-${{ matrix.dependency-group }} + path: ${{ runner.temp }}/api-coverage/ + retention-days: 7 + if-no-files-found: warn + # Most of the PDP's log is its once-a-second health checks, which push the # startup out of any tail. Without them, the tail shows how the policy and # data fetches went: response codes, retries, restarts and errors. @@ -439,6 +456,100 @@ jobs: echo "::warning title=Scratch env leaked::${leaked}" fi + # The API coverage report (PER-16337, .github/scripts/api_coverage.py): which + # operations of the control-plane and PDP specs the offline tests send a request + # to, checked against the snapshots committed under .github/api-specs/ and the + # allowlist in .github/scripts/api_coverage_allowlist.json. The job summary holds + # the report, and the api-coverage-report artifact the full result as JSON. Exit 1 + # (a GA operation neither covered nor allowlisted, a stale or changed allowlist + # entry, an unexplained SDK-only request) and exit 2 (the report did not run) both + # fail the job. The weekly api-coverage.yml runs it against the live spec. + api-coverage: + name: API Coverage + # Waits for `pytest` only to read its lanes' e2e request records, and runs + # whether they passed or failed. A fork PR has no secrets, so its `pytest` lanes + # fail before any test runs: no record, and the e2e column says "not run". + needs: pytest + if: ${{ !cancelled() }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version-file: "uv.lock" + python-version: "3.11.8" + enable-cache: true + # The entry the pydantic 2 lane of `pytest` saves. + cache-suffix: pydantic-v2 + + - name: Install dependencies + run: uv sync --locked --group pydantic-v2 + + - name: Offline tests with the request recorder + run: >- + uv run --no-sync pytest -q -m "not e2e" + --api-coverage-record "${RUNNER_TEMP}/api-coverage/offline.jsonl" + + # A missing artifact is not an error here: the report says "not run" for the + # e2e column. NODE_OPTIONS: see the same step in security.yml. + - name: Download the e2e request records + if: ${{ !cancelled() }} + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + env: + NODE_OPTIONS: --disable-warning=DEP0005 + with: + pattern: api-coverage-e2e-* + path: ${{ runner.temp }}/api-coverage/e2e + merge-multiple: true + + # Runs after a failed offline step too: the report then exits 2, saying the + # record comes from a failed session, rather than leaving no summary. + - name: Report + if: ${{ !cancelled() }} + run: | + set -uo pipefail + e2e_args=() + for record in "${RUNNER_TEMP}"/api-coverage/e2e/*.jsonl; do + if [ -f "${record}" ]; then + e2e_args+=(--e2e-record "${record}") + fi + done + set +e + uv run --no-sync python .github/scripts/api_coverage.py report \ + --spec control-plane=.github/api-specs/control-plane.json \ + --spec pdp=.github/api-specs/pdp.json \ + --allowlist .github/scripts/api_coverage_allowlist.json \ + --record "${RUNNER_TEMP}/api-coverage/offline.jsonl" \ + "${e2e_args[@]}" \ + --summary "$GITHUB_STEP_SUMMARY" \ + --json "${RUNNER_TEMP}/api-coverage/report.json" + report_exit=$? + set -e + if [ "${report_exit}" -eq 1 ]; then + echo "::error title=API coverage::An operation is uncovered and not allowlisted, or the allowlist is out of date. See the job summary." + elif [ "${report_exit}" -ne 0 ]; then + echo "::error title=API coverage report did not run::There is no result. See the log." + fi + exit "${report_exit}" + + - name: Upload the report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: api-coverage-report + path: ${{ runner.temp }}/api-coverage/ + retention-days: 30 + if-no-files-found: warn + # Offline suite on every supported Python. It needs no secrets and no PDP, so # it also runs on fork PRs. Kept apart from `pytest` above, whose name and # matrix are required status checks. From aa3a01bd96052d33e8c6210731ccedc36b56d445 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:57:06 +0300 Subject: [PATCH 11/32] Check API coverage against the live spec every week api-coverage.yml runs the report against the live control-plane spec on Mondays and on manual dispatch, lists how it differs from the committed snapshot, uploads a refreshed snapshot, and posts to Slack when it fails or cannot run. Part of PER-16337. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/api-coverage.yml | 186 +++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 .github/workflows/api-coverage.yml diff --git a/.github/workflows/api-coverage.yml b/.github/workflows/api-coverage.yml new file mode 100644 index 00000000..4f3d80d9 --- /dev/null +++ b/.github/workflows/api-coverage.yml @@ -0,0 +1,186 @@ +name: API Coverage (live spec) + +# Runs the API coverage report (.github/scripts/api_coverage.py) against the live +# control-plane spec, https://api.permit.io/v2/openapi.json. Pull requests run the same +# report in test.yml's `API Coverage` job against the snapshot committed under +# .github/api-specs/, so their result depends only on the commit; this is what notices +# that the API moved. A new GA operation, or one whose stage changed, that no offline +# test sends a request to and the allowlist does not list fails the run until someone +# triages it: add an offline test or an allowlist entry, and commit the refreshed +# snapshot this run uploads (CONTRIBUTING.md, "API coverage report"). The PDP's spec is +# the committed snapshot here too: it changes only when PINNED_PDP_IMAGE moves. +# +# A scheduled run that fails or cannot run posts the counts and a link to Slack, and +# so does every manual run; the operations themselves are in the job summary. +# +# NOT a required status check: it reads a live external spec, which can change +# without any change to this repository. +on: + schedule: + - cron: "0 10 * * 1" # Mondays 10:00 UTC + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: api-coverage-live-${{ github.ref }} + cancel-in-progress: false + +jobs: + coverage: + name: API Coverage (live spec) + runs-on: ubuntu-24.04 + # The offline suite takes under a minute; this also bounds the setup steps and the + # spec download's retries. + timeout-minutes: 20 + outputs: + exit: ${{ steps.report.outputs.exit }} + missing: ${{ steps.report.outputs.missing }} + stale: ${{ steps.report.outputs.stale }} + changed: ${{ steps.report.outputs.changed }} + sdk_only: ${{ steps.report.outputs.sdk_only }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version-file: "uv.lock" + python-version: "3.11.8" + enable-cache: true + cache-suffix: pydantic-v2 + + - name: Install dependencies + run: uv sync --locked --group pydantic-v2 + + - name: Offline tests with the request recorder + run: >- + uv run --no-sync pytest -q -m "not e2e" + --api-coverage-record "${RUNNER_TEMP}/api-coverage/offline.jsonl" + + # Writes the live spec's operation inventory the way the committed snapshot was + # written, so the artifact holds a ready replacement for it. + - name: Fetch the live control-plane spec + if: ${{ !cancelled() }} + env: + SPEC_URL: https://api.permit.io/v2/openapi.json + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/api-coverage/live" + curl --fail --silent --show-error --location --retry 3 --retry-all-errors \ + --max-time 60 --output "${RUNNER_TEMP}/api-coverage/openapi.json" "${SPEC_URL}" + uv run --no-sync python .github/scripts/api_coverage.py snapshot control-plane \ + "${RUNNER_TEMP}/api-coverage/openapi.json" --source "${SPEC_URL}" \ + --out-dir "${RUNNER_TEMP}/api-coverage/live" + + # Exit 1 is an untriaged operation or a stale or changed allowlist entry; exit 2 + # means the report did not run (a failed download leaves no spec to read). Both + # fail the job; the summary says which. + - name: Report + id: report + if: ${{ !cancelled() }} + run: | + set -uo pipefail + set +e + uv run --no-sync python .github/scripts/api_coverage.py report \ + --spec "control-plane=${RUNNER_TEMP}/api-coverage/live/control-plane.json" \ + --spec pdp=.github/api-specs/pdp.json \ + --baseline control-plane=.github/api-specs/control-plane.json \ + --allowlist .github/scripts/api_coverage_allowlist.json \ + --record "${RUNNER_TEMP}/api-coverage/offline.jsonl" \ + --summary "$GITHUB_STEP_SUMMARY" \ + --json "${RUNNER_TEMP}/api-coverage/report.json" \ + --github-output "$GITHUB_OUTPUT" + report_exit=$? + set -e + echo "exit=${report_exit}" >> "$GITHUB_OUTPUT" + if [ "${report_exit}" -eq 1 ]; then + echo "::error title=API coverage::The live spec has an operation that is uncovered and not allowlisted, or the allowlist is out of date. See the job summary." + elif [ "${report_exit}" -ne 0 ]; then + echo "::error title=API coverage report did not run::There is no result. See the log." + fi + exit "${report_exit}" + + - name: Upload the report and the live snapshot + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: api-coverage-live + path: ${{ runner.temp }}/api-coverage/ + retention-days: 30 + if-no-files-found: warn + + # A scheduled run has no PR to report on, so Slack is the only channel that reaches + # a person; it posts only when the run did not pass. A manual run always posts, pass + # or fail, so the Slack path can be tried on demand. The message carries counts and a + # link; the operations themselves are in the job summary. + notify: + name: Notify Slack + runs-on: ubuntu-24.04 + timeout-minutes: 5 + needs: [coverage] + if: | + always() && ( + github.event_name == 'workflow_dispatch' || + (github.event_name == 'schedule' && needs.coverage.result != 'success') + ) + env: + # The secrets context is not available in a job-level `if:`, so the webhook is + # read into the environment here and the steps below gate on whether it is set. + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + steps: + # A fork, or any repository without SLACK_WEBHOOK_URL, gets a warning here + # instead of a failed job. + - name: Check Slack webhook is configured + id: check + run: | + set -uo pipefail + if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then + echo "::warning title=Slack not configured::SLACK_WEBHOOK_URL is not set on this repository, so the API coverage result was not posted. Add the secret to enable notifications." + echo "configured=false" >> "$GITHUB_OUTPUT" + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + + - name: Render Slack message + id: slack + if: steps.check.outputs.configured == 'true' + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO: ${{ github.repository }} + REPORT_EXIT: ${{ needs.coverage.outputs.exit }} + MISSING: ${{ needs.coverage.outputs.missing }} + STALE: ${{ needs.coverage.outputs.stale }} + CHANGED: ${{ needs.coverage.outputs.changed }} + SDK_ONLY: ${{ needs.coverage.outputs.sdk_only }} + run: | + set -uo pipefail + { + echo "text<Every GA operation in the live control-plane spec is covered or allowlisted." + elif [ "${REPORT_EXIT:-}" = "1" ]; then + echo ":warning: *${REPO} - API coverage: the live spec needs triage*" + echo ">${MISSING:-0} GA operation(s) neither covered nor allowlisted, ${CHANGED:-0} allowlisted operation(s) whose stage changed, ${STALE:-0} stale allowlist entry(ies), ${SDK_ONLY:-0} unexplained SDK-only request(s)." + else + echo ":warning: *${REPO} - API coverage check did not complete*" + echo ">The report did not run, so there is no result." + fi + echo ">${RUN_URL}" + echo "SLACK_EOF" + } >> "$GITHUB_OUTPUT" + + - name: Post to Slack + if: steps.check.outputs.configured == 'true' + uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + # toJSON quotes and escapes the rendered text for the payload. + payload: | + text: ${{ toJSON(steps.slack.outputs.text) }} From 1e30237a3db0a3969be1d209afb5cfec58cdabc0 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 20:57:06 +0300 Subject: [PATCH 12/32] Document the API coverage report in CONTRIBUTING.md How to run it, what the allowlist statuses mean, what fails it, and how to refresh the control-plane and PDP snapshots. Part of PER-16337. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d46f8db3..0267ef7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,14 +117,15 @@ See [skills/tests/README.md](skills/tests/README.md). ### The CI scripts' tests -`.github/scripts` holds the dependency audit's report formatter and the schema drift check, -with their tests. They need only pytest and the standard library, and run with their own -pytest config, which turns every warning into an error. The command is the one the -`Audit Script Tests` job runs: +`.github/scripts` holds the dependency audit's report formatter, the schema drift check and +the API coverage report, with their tests. They need only pytest and the standard library, +and run with their own pytest config, which turns every warning into an error. The command +is the one the `Audit Script Tests` job runs: ```sh uv run --only-dev pytest -c .github/scripts/pytest.ini \ - .github/scripts/test_format_audit.py .github/scripts/test_check_schema_drift.py + .github/scripts/test_format_audit.py .github/scripts/test_check_schema_drift.py \ + .github/scripts/test_api_coverage.py ``` ### End-to-end tests @@ -201,6 +202,9 @@ curl -s https://hub.docker.com/v2/repositories/permitio/pdp-v2/tags/ | Docker pulls by the digest; the tag only names it. +Then refresh the PDP spec snapshot the API coverage report reads, from a container of the +new image (see "API coverage report" below). + ## Regenerating the sync stubs The blocking client, `permit.sync.Permit`, wraps the async classes at runtime, which type @@ -294,6 +298,81 @@ manual dispatch and on pull requests that change `permit/api/models.py`, `.github/scripts/check_schema_drift.py`, `.github/scripts/schema_drift_allowlist.json` or the workflow itself. +## API coverage report + +`.github/scripts/api_coverage.py` reports which operations of the Permit API the SDK covers +(PER-16337). An operation counts as covered when an offline test sends a request that +matches it: `tests/api_coverage_recorder.py`, a pytest plugin that `tests/conftest.py` +loads, writes down the method and path of every request the tests send when it is given a +record file, and does nothing otherwise. The report matches each request to an operation +of two specs: the control plane's (`https://api.permit.io/v2/openapi.json`) and the +container PDP's (`/openapi.json` on the PDP image `PINNED_PDP_IMAGE` names). It reads them +from the operation inventories committed under `.github/api-specs/`, each with a +`.source.json` file that says where and when it was taken. Request and response shapes are +the schema drift check's job (above), not this one's. + +```sh +uv run pytest -q -m "not e2e" --api-coverage-record /tmp/offline.jsonl +uv run python .github/scripts/api_coverage.py report \ + --spec control-plane=.github/api-specs/control-plane.json \ + --spec pdp=.github/api-specs/pdp.json \ + --allowlist .github/scripts/api_coverage_allowlist.json \ + --record /tmp/offline.jsonl +``` + +An operation no offline test sends a request to must be in +`.github/scripts/api_coverage_allowlist.json`, with the stage the spec gives it (`GA`, +`EAP` or `deprecated`), a status and one reason: + +- `excluded`: the SDK does not mean to cover it. +- `deferred`: planned, with the ticket that plans it. +- `untested`: an SDK method sends it, but no offline test does. The reason names the method. + +A request that matches no operation in either spec is SDK-only, and needs an `sdk_only` +entry: `undocumented` (the SDK calls a route the spec does not list, with a ticket) or +`test-only` (a made-up route a test sends to). An entry's path may use `{name}` for a path +segment. + +The report exits 1 on a GA operation that is neither covered nor allowlisted, on a stale +entry (its operation is covered now, or is not in the spec, or no request matches an +`sdk_only` entry), on an entry whose stage no longer matches the spec, and on an SDK-only +request no entry explains. EAP and deprecated operations that are not allowlisted are +listed, but do not fail it. It exits 2 when it did not run: a spec it cannot read or that +lists too few operations, an invalid allowlist, or a record that is missing, comes from a +session that failed or did not finish, or holds too few requests. So when a test for an +`untested` operation lands, its allowlist entry has to go in the same change. + +CI runs it in two places: + +- The `API Coverage` job in `.github/workflows/test.yml`, on every pull request, against + the committed snapshots. The `pytest` jobs record their requests too, and the report's + end-to-end column shows which operations their e2e tests got a 2xx or 3xx answer from, + or "not run" when there is no record. +- `.github/workflows/api-coverage.yml`, weekly and on manual dispatch, against the live + control-plane spec. It lists how the live spec differs from the committed snapshot, + fails on an untriaged GA operation, and posts to Slack when it fails. + +When the live spec changes, refresh the control-plane snapshot. The weekly run's +`api-coverage-live` artifact holds a ready one under `live/`; or take it yourself: + +```sh +curl -fsS -o /tmp/openapi.json https://api.permit.io/v2/openapi.json +uv run python .github/scripts/api_coverage.py snapshot control-plane /tmp/openapi.json \ + --source https://api.permit.io/v2/openapi.json +``` + +For the PDP, start a container of the pinned image with an environment's API key, as in +"End-to-end tests" (it answers 503 until it has loaded that environment's configuration), +then: + +```sh +curl -fsS -o /tmp/pdp-openapi.json http://localhost:7766/openapi.json +uv run python .github/scripts/api_coverage.py snapshot pdp /tmp/pdp-openapi.json \ + --source "GET /openapi.json on a container of $PDP_IMAGE (PINNED_PDP_IMAGE in .github/workflows/test.yml)" +``` + +Commit the snapshot together with the allowlist entries for whatever it adds. + ## Building ```sh From e03e89dc26b77e583e1ac999f2a83af641830d9e Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:02:00 +0300 Subject: [PATCH 13/32] Fail the coverage report on a snapshot source file it cannot read A .source.json next to a snapshot that is not JSON, or does not say where and when the snapshot was taken, now stops the report (exit 2) instead of being skipped, and its text is escaped in the summary. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api_coverage.py | 26 ++++++++++++++++---------- .github/scripts/test_api_coverage.py | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/.github/scripts/api_coverage.py b/.github/scripts/api_coverage.py index 714573ea..cd1808fb 100755 --- a/.github/scripts/api_coverage.py +++ b/.github/scripts/api_coverage.py @@ -257,17 +257,23 @@ def load_spec(api: str, path: Path, minimum: int) -> Spec: def _describe_source(path: Path) -> str: - """Name a spec by its file, and by the source and date its sidecar records.""" + """Name a spec by its file, and by the source and date its sidecar records, if it has one. + + Raises: + CoverageError: If the sidecar exists but does not say where and when. + """ sidecar = path.with_name(path.name.removesuffix(".json") + ".source.json") - if not sidecar.is_file(): - return f"`{path}`" - try: - source = json.loads(sidecar.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError, UnicodeDecodeError): - return f"`{path}`" - if not isinstance(source, dict): - return f"`{path}`" - return f"`{path}`, a snapshot of {source.get('source')} taken {source.get('fetched')}" + if not sidecar.exists(): + return f"`{_cell(path)}`" + source = read_json(sidecar, "the snapshot's source file") + if not isinstance(source, dict) or not all( + isinstance(source.get(key), str) and source[key] for key in ("source", "fetched") + ): + msg = f'the snapshot\'s source file {sidecar} needs a "source" and a "fetched" string' + raise CoverageError(msg) + return ( + f"`{_cell(path)}`, a snapshot of {_cell(source['source'])} taken {_cell(source['fetched'])}" + ) # --- request records ---------------------------------------------------------- diff --git a/.github/scripts/test_api_coverage.py b/.github/scripts/test_api_coverage.py index 78e21051..2d1c562b 100644 --- a/.github/scripts/test_api_coverage.py +++ b/.github/scripts/test_api_coverage.py @@ -648,6 +648,31 @@ def test_a_spec_that_cannot_be_read_exits_2(tmp_path: Path, spec: str | None, me assert_did_not_run(rerun(tmp_path), message) +@pytest.mark.parametrize( + ("sidecar", "message"), + [ + ("not json", "the snapshot's source file at .* is not valid JSON"), + ('{"source": "https://example.test"}', 'needs a "source" and a "fetched" string'), + ('{"source": "", "fetched": "2026-01-02"}', 'needs a "source" and a "fetched" string'), + ], +) +def test_a_snapshot_whose_source_file_is_broken_exits_2( + tmp_path: Path, sidecar: str, message: str +) -> None: + report(tmp_path) + (tmp_path / "pdp.source.json").write_text(sidecar, encoding="utf-8") + assert_did_not_run(rerun(tmp_path), message) + + +def test_a_snapshot_names_its_source_in_the_report(tmp_path: Path) -> None: + (tmp_path / "pdp.source.json").write_text( + '{"source": "a PDP | image", "fetched": "2026-01-02"}', encoding="utf-8" + ) + outcome = report(tmp_path) + assert "pdp.json`, a snapshot of a PDP \\| image taken 2026-01-02." in outcome.summary + assert outcome.result["specs"]["pdp"].endswith("a snapshot of a PDP \\| image taken 2026-01-02") + + def test_a_spec_with_fewer_operations_than_the_default_minimum_exits_2(tmp_path: Path) -> None: report(tmp_path) outcome = run_report( From d685fcbc4eed962c4bd24d975caa139ab689ca14 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:04:18 +0300 Subject: [PATCH 14/32] Test the detailed lists end to end Each test builds a tenant, a folder and a document resource type joined by a parent relation, a tenant role, a user, one instance of each type, the tuple between them and two role assignments, and deletes them after. It checks what each list_detailed() fills in beyond list(), that it returns the same objects list() does, the role assignment filters and pages, the exact match of the detailed instance search, and that the blocking client gets the same pages. Co-Authored-By: Claude Opus 5.5 --- tests/test_detailed_lists_e2e.py | 322 +++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 tests/test_detailed_lists_e2e.py diff --git a/tests/test_detailed_lists_e2e.py b/tests/test_detailed_lists_e2e.py new file mode 100644 index 00000000..11a1edad --- /dev/null +++ b/tests/test_detailed_lists_e2e.py @@ -0,0 +1,322 @@ +"""The detailed lists against the Permit API (PER-16337). + +``list_detailed()`` on ``role_assignments``, ``resource_instances`` and +``relationship_tuples`` reads the API's ``/detailed`` routes. Each test builds one small +policy in the environment the API key belongs to: a tenant, a folder resource type, a +document resource type whose ``parent`` relation points at folders and which has a +``viewer`` resource role, a tenant role, a user, one folder and one document in the tenant, +the tuple that makes the folder the document's parent, and two role assignments for the +user (the tenant role, and ``viewer`` on the document). + +Every key is unique to the run, and every delete is registered before the create it +undoes, so a test that fails part way still removes what it made. Teardown runs in +reverse order of registration, and a 404 there counts as success. The lists are filtered +to the test's own objects, since the environment is shared. +""" + +import functools +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AsyncExitStack +from dataclasses import dataclass +from typing import Final + +import pytest + +from permit import Permit +from permit.api.models import RelationshipTupleBlockRead +from permit.sync import Permit as SyncPermit +from tests.utils import delete_quietly, unique_key + +pytestmark = pytest.mark.e2e + +READ: Final[str] = "read" +VIEWER: Final[str] = "viewer" +PARENT: Final[str] = "parent" +FOLDER_KEY: Final[str] = "docs" +DOCUMENT_KEY: Final[str] = "readme" +TENANT_ATTRIBUTES: Final[dict[str, str]] = {"tier": "gold"} +USER_ATTRIBUTES: Final[dict[str, str]] = {"department": "eng"} +DOCUMENT_ATTRIBUTES: Final[dict[str, bool]] = {"public": False} + + +@dataclass(frozen=True) +class Policy: + """The keys of one test's policy, all unique to it.""" + + tenant: str + folder_type: str + document_type: str + role: str + user: str + + @property + def folder(self) -> str: + return f"{self.folder_type}:{FOLDER_KEY}" + + @property + def document(self) -> str: + return f"{self.document_type}:{DOCUMENT_KEY}" + + @property + def email(self) -> str: + return f"{self.user}@example.com" + + +@pytest.fixture +async def policy(permit: Permit) -> AsyncIterator[Policy]: + """Create one test's policy, and delete it once the test ends.""" + policy = Policy( + tenant=unique_key("detailed-tenant"), + folder_type=unique_key("detailed-folder"), + document_type=unique_key("detailed-doc"), + role=unique_key("detailed-reader"), + user=unique_key("detailed-user"), + ) + api = permit.api + async with AsyncExitStack() as teardown: + + def on_teardown(delete: Callable[[], Awaitable[None]], description: str) -> None: + teardown.push_async_callback(delete_quietly, delete, f"'{description}'") + + on_teardown(functools.partial(api.tenants.delete, policy.tenant), policy.tenant) + await api.tenants.create( + { + "key": policy.tenant, + "name": f"Tenant {policy.tenant}", + "attributes": TENANT_ATTRIBUTES, + } + ) + on_teardown(functools.partial(api.resources.delete, policy.folder_type), policy.folder_type) + await api.resources.create( + {"key": policy.folder_type, "name": policy.folder_type, "actions": {READ: {}}} + ) + on_teardown( + functools.partial(api.resources.delete, policy.document_type), policy.document_type + ) + await api.resources.create( + { + "key": policy.document_type, + "name": policy.document_type, + "actions": {READ: {}}, + "roles": {VIEWER: {"name": "Viewer", "permissions": [READ]}}, + "relations": {PARENT: policy.folder_type}, + } + ) + on_teardown(functools.partial(api.roles.delete, policy.role), policy.role) + await api.roles.create( + { + "key": policy.role, + "name": f"Role {policy.role}", + "permissions": [f"{policy.document_type}:{READ}"], + } + ) + on_teardown(functools.partial(api.users.delete, policy.user), policy.user) + await api.users.create( + { + "key": policy.user, + "email": policy.email, + "first_name": "Ada", + "attributes": USER_ATTRIBUTES, + } + ) + on_teardown(functools.partial(api.resource_instances.delete, policy.folder), policy.folder) + await api.resource_instances.create( + {"key": FOLDER_KEY, "resource": policy.folder_type, "tenant": policy.tenant} + ) + on_teardown( + functools.partial(api.resource_instances.delete, policy.document), policy.document + ) + await api.resource_instances.create( + { + "key": DOCUMENT_KEY, + "resource": policy.document_type, + "tenant": policy.tenant, + "attributes": DOCUMENT_ATTRIBUTES, + } + ) + relationship = { + "subject": policy.folder, + "relation": PARENT, + "object": policy.document, + "tenant": policy.tenant, + } + on_teardown( + functools.partial(api.relationship_tuples.delete, relationship), str(relationship) + ) + await api.relationship_tuples.create(relationship) + for assignment in ( + {"user": policy.user, "role": policy.role, "tenant": policy.tenant}, + { + "user": policy.user, + "role": VIEWER, + "tenant": policy.tenant, + "resource_instance": policy.document, + }, + ): + on_teardown( + functools.partial(api.role_assignments.unassign, assignment), str(assignment) + ) + await api.role_assignments.assign(assignment) + yield policy + + +async def test_role_assignments_list_detailed_names_the_role_user_tenant_and_instance( + permit: Permit, policy: Policy +) -> None: + role_assignments = permit.api.role_assignments + + page = await role_assignments.list_detailed(user_key=policy.user) + + assert page.total_count == 2 + by_role = {assignment.role.key: assignment for assignment in page.data} + assert set(by_role) == {policy.role, VIEWER} + for assignment in page.data: + assert (assignment.user.key, assignment.user.email) == (policy.user, policy.email) + assert assignment.user.first_name == "Ada" + assert assignment.user.attributes == USER_ATTRIBUTES + assert (assignment.tenant.key, assignment.tenant.name) == ( + policy.tenant, + f"Tenant {policy.tenant}", + ) + assert assignment.tenant.attributes == TENANT_ATTRIBUTES + tenant_role = by_role[policy.role] + assert tenant_role.role.name == f"Role {policy.role}" + assert tenant_role.resource_instance is None + resource_role = by_role[VIEWER] + assert resource_role.role.name == "Viewer" + assert resource_role.resource_instance is not None + assert (resource_role.resource_instance.resource, resource_role.resource_instance.key) == ( + policy.document_type, + DOCUMENT_KEY, + ) + assert resource_role.resource_instance.attributes == DOCUMENT_ATTRIBUTES + + # The same assignments as list() returns for the same filter, by id. + listed = await role_assignments.list(user_key=policy.user) + assert {assignment.id for assignment in listed} == {assignment.id for assignment in page.data} + + on_instance = await role_assignments.list_detailed( + user_key=policy.user, tenant_key=policy.tenant, resource_instance_key=policy.document + ) + assert [assignment.role.key for assignment in on_instance.data] == [VIEWER] + assert on_instance.total_count == 1 + + first = await role_assignments.list_detailed(user_key=policy.user, per_page=1) + second = await role_assignments.list_detailed(user_key=policy.user, page=2, per_page=1) + assert (first.total_count, len(first.data), len(second.data)) == (2, 1, 1) + assert {first.data[0].role.key, second.data[0].role.key} == {policy.role, VIEWER} + + +async def test_resource_instances_list_detailed_lists_each_instances_relationships( + permit: Permit, policy: Policy +) -> None: + resource_instances = permit.api.resource_instances + relationship = RelationshipTupleBlockRead( + subject=policy.folder, relation=PARENT, object=policy.document + ) + + documents = await resource_instances.list_detailed( + resource_key=policy.document_type, tenant_key=policy.tenant + ) + folders = await resource_instances.list_detailed( + resource_key=policy.folder_type, tenant_key=policy.tenant + ) + + assert documents.total_count == 1 + (document,) = documents.data + assert (document.key, document.resource, document.tenant) == ( + DOCUMENT_KEY, + policy.document_type, + policy.tenant, + ) + assert document.attributes == DOCUMENT_ATTRIBUTES + assert document.relationships == [relationship] + assert folders.total_count == 1 + (folder,) = folders.data + assert folder.key == FOLDER_KEY + assert folder.relationships == [relationship] + + # The detailed search matches a key exactly, where list() also matches part of one. + exact = await resource_instances.list_detailed( + resource_key=policy.document_type, search_key=DOCUMENT_KEY + ) + assert [instance.key for instance in exact.data] == [DOCUMENT_KEY] + partial = await resource_instances.list_detailed( + resource_key=policy.document_type, search_key=DOCUMENT_KEY[:-1] + ) + assert (partial.total_count, partial.data) == (0, []) + + +async def test_relationship_tuples_list_detailed_fills_in_the_details( + permit: Permit, policy: Policy +) -> None: + relationship_tuples = permit.api.relationship_tuples + + page = await relationship_tuples.list_detailed( + subject_key=policy.folder, tenant_key=policy.tenant + ) + + assert page.total_count == 1 + (detailed,) = page.data + assert (detailed.subject, detailed.relation, detailed.object, detailed.tenant) == ( + policy.folder, + PARENT, + policy.document, + policy.tenant, + ) + assert detailed.subject_details is not None + assert (detailed.subject_details.resource, detailed.subject_details.key) == ( + policy.folder_type, + FOLDER_KEY, + ) + assert detailed.object_details is not None + assert (detailed.object_details.resource, detailed.object_details.key) == ( + policy.document_type, + DOCUMENT_KEY, + ) + assert detailed.object_details.attributes == DOCUMENT_ATTRIBUTES + assert detailed.relation_details is not None + assert detailed.relation_details.key == PARENT + assert detailed.tenant_details is not None + assert (detailed.tenant_details.key, detailed.tenant_details.name) == ( + policy.tenant, + f"Tenant {policy.tenant}", + ) + assert detailed.tenant_details.attributes == TENANT_ATTRIBUTES + + # The same tuple as list() returns for the same filter, where list() leaves the + # details out. + (listed,) = await relationship_tuples.list(subject_key=policy.folder, tenant_key=policy.tenant) + assert listed.id == detailed.id + assert listed.subject_details is None + + by_object = await relationship_tuples.list_detailed( + object_key=policy.document, relation_key=PARENT + ) + assert [found.id for found in by_object.data] == [detailed.id] + + +async def test_the_blocking_client_lists_the_same_detailed_pages( + permit: Permit, sync_permit: SyncPermit, policy: Policy +) -> None: + """The blocking client sends the same requests, so it gets the same pages back.""" + pages = ( + ( + await permit.api.role_assignments.list_detailed(user_key=policy.user), + sync_permit.api.role_assignments.list_detailed(user_key=policy.user), + ), + ( + await permit.api.resource_instances.list_detailed(tenant_key=policy.tenant), + sync_permit.api.resource_instances.list_detailed(tenant_key=policy.tenant), + ), + ( + await permit.api.relationship_tuples.list_detailed(tenant_key=policy.tenant), + sync_permit.api.relationship_tuples.list_detailed(tenant_key=policy.tenant), + ), + ) + + for awaited, blocking in pages: + assert type(blocking) is type(awaited) + assert blocking.total_count == awaited.total_count + assert sorted(item.id for item in blocking.data) == sorted(item.id for item in awaited.data) + assert [awaited.total_count for awaited, _ in pages] == [2, 2, 1] From d5b2a58264722eb0b5ba0f55127c1e5ee0b9fae6 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:04:18 +0300 Subject: [PATCH 15/32] Test pdps.refresh() end to end Two refreshes return distinct update ids for the same, non-empty set of PDP configurations, on the async and the blocking client. Co-Authored-By: Claude Opus 5.5 --- tests/test_pdps_e2e.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/test_pdps_e2e.py diff --git a/tests/test_pdps_e2e.py b/tests/test_pdps_e2e.py new file mode 100644 index 00000000..3c82dbf7 --- /dev/null +++ b/tests/test_pdps_e2e.py @@ -0,0 +1,38 @@ +"""permit.api.pdps.refresh() against the Permit API (PER-16337). + +``refresh()`` asks Permit to make every PDP of the environment the API key belongs to fetch +its data again. Every environment has at least one PDP configuration, and the call returns +once Permit has sent the refresh, so these tests check what it returns, not what the PDPs +do with it. The tests create nothing, so there is nothing to tear down. +""" + +from uuid import UUID + +import pytest + +from permit import Permit +from permit.api.models import PDPDataRefreshResponse +from permit.sync import Permit as SyncPermit + +pytestmark = pytest.mark.e2e + + +async def test_refresh_returns_a_new_update_for_the_environments_pdps(permit: Permit) -> None: + first = await permit.api.pdps.refresh(reason="permit-python e2e") + second = await permit.api.pdps.refresh() + + for refreshed in (first, second): + assert type(refreshed) is PDPDataRefreshResponse + assert isinstance(refreshed.update_id, UUID) + assert refreshed.pdp_ids + assert len(set(refreshed.pdp_ids)) == len(refreshed.pdp_ids) + # Each call sends an update of its own, to the same PDP configurations. + assert first.update_id != second.update_id + assert set(first.pdp_ids) == set(second.pdp_ids) + + +def test_the_blocking_client_refreshes_the_pdps(sync_permit: SyncPermit) -> None: + refreshed = sync_permit.api.pdps.refresh(reason="permit-python e2e, blocking client") + + assert type(refreshed) is PDPDataRefreshResponse + assert refreshed.pdp_ids From cd6ebad18e731d8f6a8eb7561364407f76c5c8c7 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:04:18 +0300 Subject: [PATCH 16/32] Test get_user_permissions() with a context end to end On an RBAC policy, which does not read the context, the PDP must accept a context and answer as it does without one: on the container PDP through both clients, with the context store holding a base context, and on the cloud PDP. What an ABAC policy makes of the context waits for the ABAC decision checks, which are pending PER-16209. Co-Authored-By: Claude Opus 5.5 --- tests/test_cloud_pdp_e2e.py | 21 ++- tests/test_user_permissions_context_e2e.py | 154 +++++++++++++++++++++ 2 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 tests/test_user_permissions_context_e2e.py diff --git a/tests/test_cloud_pdp_e2e.py b/tests/test_cloud_pdp_e2e.py index 423fd951..a66c572d 100644 --- a/tests/test_cloud_pdp_e2e.py +++ b/tests/test_cloud_pdp_e2e.py @@ -4,7 +4,7 @@ resource type with two actions, a role that grants one of them, a tenant where the user has that role and a second tenant where it has none. It waits for the cloud PDP to apply the policy, then asserts the exact answers of ``check``, ``bulk_check``, -``get_user_permissions`` and ``filter_objects``. +``get_user_permissions`` (with and without a context) and ``filter_objects``. RBAC decides on the resource type and tenant alone, so the resources these tests ask about need not exist as resource instances. @@ -238,6 +238,25 @@ async def tenant_grants() -> dict[str, dict[str, Any]]: assert await settled(tenant_grants, expected=expected) == expected +async def test_get_user_permissions_with_a_context( + permit_cloud: Permit, cloud_policy: CloudPolicy +) -> None: + """The cloud PDP accepts a context, and RBAC, which does not read it, answers the same.""" + policy = cloud_policy + + async def tenant_permissions() -> dict[str, list[str]]: + permissions = await permit_cloud.get_user_permissions( + policy.user, + tenants=[policy.tenant, policy.other_tenant], + context={"ip": "10.0.0.1", "flags": {"beta": True, "ratio": 0.5}}, + ) + return {key: entry["permissions"] for key, entry in permissions.items()} + + expected = {f"__tenant:{policy.tenant}": [policy.granted_permission]} + + assert await settled(tenant_permissions, expected=expected) == expected + + async def test_filter_objects(permit_cloud: Permit, cloud_policy: CloudPolicy) -> None: policy = cloud_policy resources = [ diff --git a/tests/test_user_permissions_context_e2e.py b/tests/test_user_permissions_context_e2e.py new file mode 100644 index 00000000..1c1a660d --- /dev/null +++ b/tests/test_user_permissions_context_e2e.py @@ -0,0 +1,154 @@ +"""get_user_permissions() with a context, against the Permit API and a PDP (PER-16337). + +Each test builds a small RBAC policy in the environment the API key belongs to: a resource +type with two actions, a role that grants one of them, a tenant where the user has that +role and a second tenant where it has none. It waits until the PDP answers +``get_user_permissions`` with the role's permission, then asks again with a context. + +RBAC does not read the context, so the PDP must accept the context and answer exactly as it +does without one. What an ABAC policy makes of the context is not checked here: the ABAC +decision checks in this suite are pending PER-16209 (see test_abac_e2e.py). + +Every key is unique to the run, and every delete is registered before the create it +undoes, so a test that fails part way still removes what it made. Teardown runs in +reverse order of registration, and a 404 there counts as success. +""" + +import functools +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack +from dataclasses import dataclass +from typing import Any, Final + +import pytest + +from permit import Permit +from permit.sync import Permit as SyncPermit +from tests.utils import delete_quietly, poll_for, unique_key + +pytestmark = pytest.mark.e2e + +GRANTED_ACTION: Final[str] = "read" +DENIED_ACTION: Final[str] = "write" +CONTEXTS: Final[list[dict[str, Any]]] = [ + {}, + {"ip": "10.0.0.1", "flags": {"beta": True, "ratio": 0.5, "unset": None}, "tags": ["a", 1]}, +] + +# Writes reach the PDP asynchronously. The bound is reached only when an answer never +# converges; polling returns as soon as it does. +PROPAGATION_TIMEOUT: Final[float] = 60.0 +POLL_INTERVAL: Final[float] = 0.5 + +settled = functools.partial(poll_for, timeout=PROPAGATION_TIMEOUT, interval=POLL_INTERVAL) + + +@dataclass(frozen=True) +class Policy: + """The keys of one test's policy, all unique to it.""" + + resource: str + role: str + tenant: str + other_tenant: str + user: str + + @property + def expected(self) -> dict[str, list[str]]: + """What the PDP answers for the user in the two tenants, permissions sorted.""" + return {f"__tenant:{self.tenant}": [f"{self.resource}:{GRANTED_ACTION}"]} + + +@pytest.fixture +async def policy(permit: Permit) -> AsyncIterator[Policy]: + """Create one test's policy, and delete it once the test ends.""" + policy = Policy( + resource=unique_key("context-doc"), + role=unique_key("context-reader"), + tenant=unique_key("context-tenant"), + other_tenant=unique_key("context-other-tenant"), + user=unique_key("context-user"), + ) + api = permit.api + async with AsyncExitStack() as teardown: + teardown.push_async_callback( + delete_quietly, + functools.partial(api.resources.delete, policy.resource), + f"resource '{policy.resource}'", + ) + await api.resources.create( + { + "key": policy.resource, + "name": policy.resource, + "actions": {GRANTED_ACTION: {}, DENIED_ACTION: {}}, + } + ) + teardown.push_async_callback( + delete_quietly, + functools.partial(api.roles.delete, policy.role), + f"role '{policy.role}'", + ) + await api.roles.create( + { + "key": policy.role, + "name": policy.role, + "permissions": [f"{policy.resource}:{GRANTED_ACTION}"], + } + ) + for tenant in (policy.tenant, policy.other_tenant): + teardown.push_async_callback( + delete_quietly, functools.partial(api.tenants.delete, tenant), f"tenant '{tenant}'" + ) + await api.tenants.create({"key": tenant, "name": tenant}) + teardown.push_async_callback( + delete_quietly, + functools.partial(api.users.delete, policy.user), + f"user '{policy.user}'", + ) + await api.users.create({"key": policy.user}) + assignment = {"user": policy.user, "role": policy.role, "tenant": policy.tenant} + teardown.push_async_callback( + delete_quietly, + functools.partial(api.users.unassign_role, assignment), + f"role assignment {assignment}", + ) + await api.users.assign_role(assignment) + yield policy + + +def permissions_by_tenant(permissions: dict[str, Any]) -> dict[str, list[str]]: + """Each tenant's permissions, sorted; the rest of the PDP's answer is not compared.""" + return {key: sorted(entry["permissions"]) for key, entry in permissions.items()} + + +async def test_the_pdp_answers_with_a_context_as_without_one( + permit: Permit, policy: Policy +) -> None: + tenants = [policy.tenant, policy.other_tenant] + + async def granted(context: dict[str, Any] | None = None) -> dict[str, list[str]]: + answer = await permit.get_user_permissions(policy.user, tenants, context=context) + return permissions_by_tenant(answer) + + assert await settled(granted, expected=policy.expected) == policy.expected + for context in CONTEXTS: + with_context = functools.partial(granted, context) + assert await settled(with_context, expected=policy.expected) == policy.expected, context + + # With the context store holding a base context, the merged context is accepted too. + permit._enforcer.context_store.add({"region": "eu"}) + assert await settled(lambda: granted({"ip": "10.0.0.2"}), expected=policy.expected) == ( + policy.expected + ) + + +async def test_the_blocking_client_sends_the_context_too( + sync_permit: SyncPermit, policy: Policy +) -> None: + async def granted() -> dict[str, list[str]]: + answer = sync_permit.get_user_permissions( + policy.user, [policy.tenant], context=CONTEXTS[-1] + ) + return permissions_by_tenant(answer) + + assert await settled(granted, expected=policy.expected) == policy.expected From 780f6b1a238d74f287d5c076143353747aecd281 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:05:28 +0300 Subject: [PATCH 17/32] Show the end-to-end column for every operation in the coverage report The missing and allowlisted tables now say whether an e2e test got an answer from the operation, as the covered table did, so an untested operation the e2e tests exercise is visible. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api_coverage.py | 10 ++++++---- .github/scripts/test_api_coverage.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/scripts/api_coverage.py b/.github/scripts/api_coverage.py index cd1808fb..c331b767 100755 --- a/.github/scripts/api_coverage.py +++ b/.github/scripts/api_coverage.py @@ -923,14 +923,15 @@ def _operation_tables(report: Report) -> list[str]: out += _details( f"Missing operations ({len(missing)})", [ - _row("API", "Operation", "Stage", "Summary"), - _row(*["---"] * 4), + _row("API", "Operation", "Stage", "Summary", "End to end"), + _row(*["---"] * 5), *( _row( API_TITLES[r.operation.api], _code(r.operation.name), r.operation.stage, _cell(r.operation.summary), + _e2e_cell(r), ) for r in missing ), @@ -942,8 +943,8 @@ def _operation_tables(report: Report) -> list[str]: out += _details( f"{status.capitalize()} operations ({len(listed)})", [ - _row("API", "Operation", "Stage", "Ticket", "Reason"), - _row(*["---"] * 5), + _row("API", "Operation", "Stage", "Ticket", "Reason", "End to end"), + _row(*["---"] * 6), *( _row( API_TITLES[r.operation.api], @@ -951,6 +952,7 @@ def _operation_tables(report: Report) -> list[str]: r.operation.stage, _cell(entry.ticket), _cell(entry.reason), + _e2e_cell(r), ) for r, entry in listed ), diff --git a/.github/scripts/test_api_coverage.py b/.github/scripts/test_api_coverage.py index 2d1c562b..70276568 100644 --- a/.github/scripts/test_api_coverage.py +++ b/.github/scripts/test_api_coverage.py @@ -459,6 +459,20 @@ def test_e2e_requests_never_make_an_operation_covered(tmp_path: Path) -> None: ) assert outcome.code == 1 assert problems(outcome, "missing") == ["Control plane GET /v2/users/{user_id}"] + assert "| Control plane | `GET /v2/users/{user_id}` | GA | GET /v2/users/{user_id} | yes |" in ( + outcome.summary + ) + + +def test_an_allowlisted_operation_shows_whether_e2e_tests_exercised_it(tmp_path: Path) -> None: + outcome = report( + tmp_path, + requests=[r for r in COVERING if r[1] != "/v2/users/u1"], + operations=[entry("GET /v2/users/{user_id}", status="untested", reason="users.get()")], + e2e=[[request_line("GET", "/v2/users/u1", e2e=True)]], + ) + assert outcome.code == 0, outcome.summary + assert "| `GET /v2/users/{user_id}` | GA | PER-1 | users.get() | yes |" in outcome.summary def test_records_from_several_e2e_runs_add_up(tmp_path: Path) -> None: From b563eb82f85edec941d29279f7174299f5f13359 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:16:49 +0300 Subject: [PATCH 18/32] Allowlist the proxy-mode detailed-list routes as SDK-only With proxy_facts_via_pdp, list_detailed() sends GET /facts//detailed to the PDP, which forwards it to the control plane through a route its spec does not list (PER-16338). The offline tests send these requests, so the coverage report needs an sdk_only entry for each of the three. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api_coverage_allowlist.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/scripts/api_coverage_allowlist.json b/.github/scripts/api_coverage_allowlist.json index bed0d2e4..9b32c0ee 100644 --- a/.github/scripts/api_coverage_allowlist.json +++ b/.github/scripts/api_coverage_allowlist.json @@ -1824,6 +1824,24 @@ "ticket": "PER-16338", "reason": "proxy_facts_via_pdp sends permit.api.users.bulk_create() here. The PDP passes it through to the control plane's POST /v2/facts/{proj_id}/{env_id}/bulk/users without listing it in its spec, and ignores X-Wait-Timeout on it." }, + { + "request": "GET /facts/relationship_tuples/detailed", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.relationship_tuples.list_detailed() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/relationship_tuples/detailed without listing it in its spec." + }, + { + "request": "GET /facts/resource_instances/detailed", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.resource_instances.list_detailed() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/resource_instances/detailed without listing it in its spec." + }, + { + "request": "GET /facts/role_assignments/detailed", + "status": "undocumented", + "ticket": "PER-16338", + "reason": "proxy_facts_via_pdp sends permit.api.role_assignments.list_detailed() here. The PDP passes it through to the control plane's GET /v2/facts/{proj_id}/{env_id}/role_assignments/detailed without listing it in its spec." + }, { "request": "DELETE /facts/tenants/{tenant_id}", "status": "undocumented", From 11b105784873652aead2e625b4a0755ec5d5da92 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:18:14 +0300 Subject: [PATCH 19/32] Name the coverage report's script correctly in the recorder docstring Co-Authored-By: Claude Opus 5.5 --- tests/api_coverage_recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api_coverage_recorder.py b/tests/api_coverage_recorder.py index ace98410..fe825e8d 100644 --- a/tests/api_coverage_recorder.py +++ b/tests/api_coverage_recorder.py @@ -1,6 +1,6 @@ """A pytest plugin that records every HTTP request the SDK sends (PER-16337). -The API coverage report (.github/scripts/api_coverage_report.py) learns which API +The API coverage report (.github/scripts/api_coverage.py) learns which API operation each SDK method calls from the requests the tests actually send: the offline wire tests for the coverage column, the end-to-end tests for the column of operations exercised against a real backend and PDP. From fb0dffce5d99d1886595e5773a8d923ac2ef5dc8 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:18:28 +0300 Subject: [PATCH 20/32] Say when a new method's test makes a coverage allowlist entry stale A new method's wire test covers its operation, so a deferred entry for it goes stale exactly as an untested one does, as the PER-16737 entries will. Its proxy-mode test can also send a PDP route the PDP's spec omits, which needs an sdk_only entry, as the detailed lists did. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0267ef7a..c15ad799 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -339,8 +339,13 @@ entry (its operation is covered now, or is not in the spec, or no request matche request no entry explains. EAP and deprecated operations that are not allowlisted are listed, but do not fail it. It exits 2 when it did not run: a spec it cannot read or that lists too few operations, an invalid allowlist, or a record that is missing, comes from a -session that failed or did not finish, or holds too few requests. So when a test for an -`untested` operation lands, its allowlist entry has to go in the same change. +session that failed or did not finish, or holds too few requests. + +So when an offline test starts sending an allowlisted operation's request (the wire test +of a new method for a `deferred` operation, or a new test for an `untested` one), its +entry has to go in the same change. A method's wire test with `proxy_facts_via_pdp` on may +also send a `/facts/...` request that the PDP forwards to the control plane but does not +list in its spec; that request needs an `undocumented` `sdk_only` entry. CI runs it in two places: From 87eea4ef8221dd3585601465ee8a324ae1198478 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:19:09 +0300 Subject: [PATCH 21/32] List the request recorder's variable among the e2e jobs' settings Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c15ad799..d6ff1c3a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,6 +165,9 @@ The jobs set: - `API_TIER=prod`: sends the SDK's API calls to `https://api.permit.io`. - `ORG_PDP_API_KEY` and `PROJECT_PDP_API_KEY`: the same key, read by `tests/endpoints/test_envs.py`. +- `PERMIT_API_COVERAGE_RECORD`, in the `pytest` jobs only: where the request recorder writes + the requests the tests send. The `API Coverage` job reads the e2e tests' requests from + it (see "API coverage report"). Without `API_TIER=prod` (or an explicit `PDP_CONTROL_PLANE`), `tests/conftest.py` sends API calls to `http://localhost:8000`. To reproduce the required jobs locally with an From 327a0317e10a056ce1322ad92a87d8b32b54e729 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:19:30 +0300 Subject: [PATCH 22/32] Update the request count beside the coverage report's sentinel The merged suite sends about 700 offline requests, not 590. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api_coverage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/api_coverage.py b/.github/scripts/api_coverage.py index c331b767..777db932 100755 --- a/.github/scripts/api_coverage.py +++ b/.github/scripts/api_coverage.py @@ -98,7 +98,7 @@ # The request record format tests/api_coverage_recorder.py writes. RECORD_VERSION = 1 -# Far below what the suite sends today (about 590 requests), so the sentinel only trips +# Far below what the suite sends today (about 700 requests), so the sentinel only trips # when the record is truncated or the recorder stopped seeing requests. DEFAULT_MIN_RECORDS = 400 # Far below today's counts (263 and 34), for the same reason. From 63189d9d8dbd807729ba26239dadc445e0f8eb9d Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:47:19 +0300 Subject: [PATCH 23/32] Say a PDP-proxied role assignment list keeps one value per filter With proxy_facts_via_pdp, the PDP forwards only the last value of a filter given as a list, so list(user_key=["alice", "bob"]) and list_detailed() with the same filter return bob's assignments alone. The SDK sends every value; the docstrings of both methods now say to pass lists only with proxy_facts_via_pdp off. Co-Authored-By: Claude Opus 5.5 --- permit/_sync_types.pyi | 8 ++++++++ permit/api/role_assignments.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index 931ba5a3..3dca89b7 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -2079,6 +2079,10 @@ class SyncRoleAssignmentsApi(BasePermitApi): ) -> list[RoleAssignmentRead]: """Retrieves a list of role assignments based on the specified filters. + With ``proxy_facts_via_pdp``, the request goes through the PDP, which forwards only + the last value of a filter given as a list: ``user_key=["alice", "bob"]`` lists only + bob's assignments. Pass lists only with ``proxy_facts_via_pdp`` off. + Args: user_key: if specified, only role granted to this user will be fetched. role_key: if specified, only assignments of this role will be fetched. @@ -2123,6 +2127,10 @@ class SyncRoleAssignmentsApi(BasePermitApi): Needs an environment-level API key, or a project- or organization-level key with the SDK's API context set to the environment. + With ``proxy_facts_via_pdp``, the request goes through the PDP, which forwards only + the last value of a filter given as a list: ``user_key=["alice", "bob"]`` lists only + bob's assignments. Pass lists only with ``proxy_facts_via_pdp`` off. + Args: user_key: if specified, only roles granted to this user, or to any of these users, will be fetched. diff --git a/permit/api/role_assignments.py b/permit/api/role_assignments.py index 42c06d55..c104d3bf 100644 --- a/permit/api/role_assignments.py +++ b/permit/api/role_assignments.py @@ -87,6 +87,10 @@ async def list( # noqa: PLR0917 - public signature; callers may pass these posi ) -> list[RoleAssignmentRead]: """Retrieves a list of role assignments based on the specified filters. + With ``proxy_facts_via_pdp``, the request goes through the PDP, which forwards only + the last value of a filter given as a list: ``user_key=["alice", "bob"]`` lists only + bob's assignments. Pass lists only with ``proxy_facts_via_pdp`` off. + Args: user_key: if specified, only role granted to this user will be fetched. role_key: if specified, only assignments of this role will be fetched. @@ -149,6 +153,10 @@ async def list_detailed( Needs an environment-level API key, or a project- or organization-level key with the SDK's API context set to the environment. + With ``proxy_facts_via_pdp``, the request goes through the PDP, which forwards only + the last value of a filter given as a list: ``user_key=["alice", "bob"]`` lists only + bob's assignments. Pass lists only with ``proxy_facts_via_pdp`` off. + Args: user_key: if specified, only roles granted to this user, or to any of these users, will be fetched. From 7f19eb983281ccdded0c20656bd6a7b36dc15b92 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:47:34 +0300 Subject: [PATCH 24/32] List every error pdps.refresh() can raise in its docstring A reason over 512 characters raises pydantic.v1.ValidationError before any request is sent, and the API answers 422 for an environment with more PDP configurations than one refresh reaches. The Raises section named only 403 and 404. Co-Authored-By: Claude Opus 5.5 --- permit/_sync_types.pyi | 5 ++++- permit/api/pdps.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index 3dca89b7..d0b47ac3 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -816,8 +816,11 @@ class SyncPdpsApi(BasePermitApi): configurations it was sent to. Raises: + pydantic.v1.ValidationError: If ``reason`` is longer than 512 characters. Nothing + is sent. PermitApiError: If the API returns an error HTTP status code, such as 403 for a - read-only API key or 404 when the environment has no PDP configuration. + read-only API key, 404 when the environment has no PDP configuration, or 422 + when it has more PDP configurations than one refresh can reach. PermitContextError: If the configured ApiContext does not match the required endpoint context. """ diff --git a/permit/api/pdps.py b/permit/api/pdps.py index 34e83db6..2699e928 100644 --- a/permit/api/pdps.py +++ b/permit/api/pdps.py @@ -50,8 +50,11 @@ async def refresh(self, reason: str | None = None) -> PDPDataRefreshResponse: configurations it was sent to. Raises: + pydantic.v1.ValidationError: If ``reason`` is longer than 512 characters. Nothing + is sent. PermitApiError: If the API returns an error HTTP status code, such as 403 for a - read-only API key or 404 when the environment has no PDP configuration. + read-only API key, 404 when the environment has no PDP configuration, or 422 + when it has more PDP configurations than one refresh can reach. PermitContextError: If the configured ApiContext does not match the required endpoint context. """ From 7a51d668202eef2daaf6c9dd8782f9642d5515dd Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:47:47 +0300 Subject: [PATCH 25/32] Name the blocking-client context e2e test for what it checks The policy is RBAC, so the PDP answers the same with or without a context, and the test cannot see whether the context was sent. It is now test_the_blocking_client_accepts_a_context, and its docstring points to the offline test that pins the request bytes. Co-Authored-By: Claude Opus 5.5 --- tests/test_user_permissions_context_e2e.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_user_permissions_context_e2e.py b/tests/test_user_permissions_context_e2e.py index 1c1a660d..9bd78a22 100644 --- a/tests/test_user_permissions_context_e2e.py +++ b/tests/test_user_permissions_context_e2e.py @@ -142,9 +142,11 @@ async def granted(context: dict[str, Any] | None = None) -> dict[str, list[str]] ) -async def test_the_blocking_client_sends_the_context_too( +async def test_the_blocking_client_accepts_a_context( sync_permit: SyncPermit, policy: Policy ) -> None: + """The PDP accepts the blocking client's context; the offline test pins its bytes.""" + async def granted() -> dict[str, list[str]]: answer = sync_permit.get_user_permissions( policy.user, [policy.tenant], context=CONTEXTS[-1] From c73820b0a5a7f685c0b0e0fd6edad3d0142b284b Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:48:32 +0300 Subject: [PATCH 26/32] Fail the script tests when the PDP snapshot is not the pinned image's The coverage report reads .github/api-specs/pdp.json, taken from the PDP image PINNED_PDP_IMAGE names. A change that moved the pin without refreshing the snapshot still passed, so the report measured an older PDP API. A test now reads the pin from test.yml and requires pdp.source.json to name the same image; CONTRIBUTING.md says so. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/test_api_coverage.py | 18 ++++++++++++++++++ CONTRIBUTING.md | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/scripts/test_api_coverage.py b/.github/scripts/test_api_coverage.py index 70276568..0555d58f 100644 --- a/.github/scripts/test_api_coverage.py +++ b/.github/scripts/test_api_coverage.py @@ -986,6 +986,24 @@ def test_the_committed_snapshots_are_their_own_inventory(tmp_path: Path, api: st assert json.loads((tmp_path / f"{api}.source.json").read_text(encoding="utf-8")) == sidecar +TEST_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "test.yml" +PINNED_PDP_IMAGE = re.compile(r"^\s*PINNED_PDP_IMAGE:\s*(?:>-\s*\n\s*)?(\S+)\s*$", re.MULTILINE) +SNAPSHOT_IMAGE = re.compile(r"^GET /openapi\.json on a container of (\S+) ") + + +def test_the_pdp_snapshot_comes_from_the_pinned_pdp_image() -> None: + """Moving PINNED_PDP_IMAGE without refreshing the PDP snapshot fails here.""" + pins = PINNED_PDP_IMAGE.findall(TEST_WORKFLOW.read_text(encoding="utf-8")) + assert len(pins) == 1, f"expected one PINNED_PDP_IMAGE in {TEST_WORKFLOW}, found {pins}" + source = json.loads((SNAPSHOTS / "pdp.source.json").read_text(encoding="utf-8"))["source"] + taken_from = SNAPSHOT_IMAGE.match(source) + assert taken_from is not None, f"pdp.source.json names no PDP image: {source}" + assert taken_from.group(1) == pins[0], ( + f"pdp.json was taken from {taken_from.group(1)}, but test.yml pins {pins[0]}: " + "refresh it (CONTRIBUTING.md, 'API coverage report')" + ) + + # --- the committed snapshots and allowlist, with planted failures ------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6ff1c3a..131582e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -206,7 +206,8 @@ curl -s https://hub.docker.com/v2/repositories/permitio/pdp-v2/tags/ | Docker pulls by the digest; the tag only names it. Then refresh the PDP spec snapshot the API coverage report reads, from a container of the -new image (see "API coverage report" below). +new image (see "API coverage report" below). Until then, the `Audit Script Tests` job fails: +a test there checks that `.github/api-specs/pdp.source.json` names the pinned image. ## Regenerating the sync stubs From 684c1986b86e8236fb9be507620a375fc4f9cb0d Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:49:04 +0300 Subject: [PATCH 27/32] Test the "not run" cell of every coverage report table Without an e2e record, the covered, missing and allowlisted tables must each say "not run" in their end-to-end column. Only the counts table and the JSON were checked, so a "no" in those rows went unnoticed. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/test_api_coverage.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/scripts/test_api_coverage.py b/.github/scripts/test_api_coverage.py index 0555d58f..89cee8ec 100644 --- a/.github/scripts/test_api_coverage.py +++ b/.github/scripts/test_api_coverage.py @@ -422,11 +422,16 @@ def test_an_sdk_only_entry_for_a_route_the_spec_now_lists_goes_stale(tmp_path: P def test_without_an_e2e_record_the_column_says_not_run(tmp_path: Path) -> None: - outcome = report(tmp_path) + outcome = report(tmp_path, operations=[entry("POST /v2/requests", stage=EAP)]) assert "End to end: **not run** (no end-to-end record was given)" in outcome.summary assert outcome.result["e2e"] == "not run" assert {op["e2e"] for op in outcome.result["operations"]} == {"not run"} assert "| Control plane | GA | 4 | 4 | 0 | 0 | 0 | 0 | not run |" in outcome.summary + covered = "| Control plane | `GET /v2/users/{user_id}` | GA | 1 | not run |" + missing = "| `GET /v2/groups/{group_key}` | deprecated | GET /v2/groups/{group_key} | not run |" + allowlisted = "| Control plane | `POST /v2/requests` | EAP | PER-1 | a reason | not run |" + for row in (covered, missing, allowlisted): + assert row in outcome.summary def test_an_e2e_record_without_e2e_requests_also_says_not_run(tmp_path: Path) -> None: From ce9e41756a047e2c38709d6122d5b0eb96ae3cc6 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:49:27 +0300 Subject: [PATCH 28/32] Test both edges of the e2e column's 2xx-or-3xx success range An e2e answer of 399 now counts as exercised and 400 does not, so narrowing the range to 2xx, or widening it past 3xx, fails the test. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/test_api_coverage.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/scripts/test_api_coverage.py b/.github/scripts/test_api_coverage.py index 89cee8ec..b61c87ce 100644 --- a/.github/scripts/test_api_coverage.py +++ b/.github/scripts/test_api_coverage.py @@ -443,17 +443,19 @@ def test_an_e2e_record_without_e2e_requests_also_says_not_run(tmp_path: Path) -> def test_e2e_requests_fill_the_column_only_on_success(tmp_path: Path) -> None: e2e = [ request_line("GET", "/v2/users", status=200, e2e=True), - request_line("GET", "/v2/users/u1", status=404, e2e=True), + request_line("GET", "/v2/templates/", status=399, e2e=True), + request_line("GET", "/v2/users/u1", status=400, e2e=True), request_line("POST", "/allowed", status=None, e2e=True), request_line("GET", "/v2/groups/direct", status=200, e2e=False), ] outcome = report(tmp_path, e2e=[e2e]) exercised = {op["operation"]: op["e2e"] for op in outcome.result["operations"]} assert exercised["GET /v2/users"] is True + assert exercised["GET /v2/templates/"] is True assert exercised["GET /v2/users/{user_id}"] is False assert exercised["POST /allowed"] is False assert exercised["GET /v2/groups/direct"] is False - assert "| Control plane | GA | 4 | 4 | 0 | 0 | 0 | 0 | 1 |" in outcome.summary + assert "| Control plane | GA | 4 | 4 | 0 | 0 | 0 | 0 | 2 |" in outcome.summary def test_e2e_requests_never_make_an_operation_covered(tmp_path: Path) -> None: From c66ecbbd0b945e24885ce66796b346bdf96e71b2 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:49:59 +0300 Subject: [PATCH 29/32] Type the coverage report's parsed JSON as object, not Any read_json, operations_of and inventory took or returned Any, with three noqa: ANN401 comments, two of them unexplained. They now use object, and inventory checks that the document is a JSON object before it reads it, with the message operations_of already gives. The snapshot test now also covers a document that is not an object or has no paths. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/api_coverage.py | 11 +++++++---- .github/scripts/test_api_coverage.py | 8 ++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/scripts/api_coverage.py b/.github/scripts/api_coverage.py index 777db932..2b81d607 100755 --- a/.github/scripts/api_coverage.py +++ b/.github/scripts/api_coverage.py @@ -185,7 +185,7 @@ def match(self, method: str, path: str) -> Operation | None: return max(candidates, key=lambda op: (specificity(op.path), op.path)) -def read_json(path: Path, what: str) -> Any: # noqa: ANN401 - whatever the JSON document holds +def read_json(path: Path, what: str) -> object: """Read a JSON file. Raises: @@ -201,7 +201,7 @@ def read_json(path: Path, what: str) -> Any: # noqa: ANN401 - whatever the JSON raise CoverageError(msg) from exc -def operations_of(document: Any, api: str, label: str) -> list[Operation]: # noqa: ANN401 +def operations_of(document: object, api: str, label: str) -> list[Operation]: """List the operations of an OpenAPI document or of a committed operation inventory. Raises: @@ -1183,12 +1183,15 @@ def _write_report(args: argparse.Namespace, report: Report) -> int: return report.exit_code -def inventory(document: Any, api: str, label: str) -> dict[str, Any]: # noqa: ANN401 +def inventory(document: object, api: str, label: str) -> dict[str, Any]: """The part of a spec the report reads: each operation's id, summary, tags and stage. Raises: - CoverageError: If the document has no operations. + CoverageError: If the document has no `paths` object or no operations. """ + if not isinstance(document, dict): + msg = f"{label} has no `paths` object" + raise CoverageError(msg) paths: dict[str, dict[str, Any]] = {} for operation in operations_of(document, api, label): source = document["paths"][operation.path][operation.method.lower()] diff --git a/.github/scripts/test_api_coverage.py b/.github/scripts/test_api_coverage.py index b61c87ce..5026e45f 100644 --- a/.github/scripts/test_api_coverage.py +++ b/.github/scripts/test_api_coverage.py @@ -961,11 +961,15 @@ def test_a_snapshot_keeps_what_the_report_reads_and_records_its_source(tmp_path: assert "a snapshot of https://example.test/openapi.json taken 2026-01-02" in reduced.source -def test_a_snapshot_of_a_document_without_operations_fails(tmp_path: Path) -> None: +@pytest.mark.parametrize("document", ['{"paths": {}}', "{}", "[]"]) +def test_a_snapshot_of_a_document_without_operations_fails( + tmp_path: Path, capsys: pytest.CaptureFixture[str], document: str +) -> None: source = tmp_path / "empty.json" - source.write_text('{"paths": {}}', encoding="utf-8") + source.write_text(document, encoding="utf-8") code = main(["snapshot", "pdp", str(source), "--source", "x", "--out-dir", str(tmp_path)]) assert code == 2 + assert "could not write the snapshot: the PDP spec at" in capsys.readouterr().err assert not (tmp_path / "pdp.json").exists() From 497a61f9263a0f14f089f82000dc96b88c535f4a Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Thu, 1 Oct 2026 21:56:51 +0300 Subject: [PATCH 30/32] Test the coverage report's default minimums at their edges The CI jobs rely on the defaults: 400 offline requests, 200 control-plane operations and 20 PDP operations. The tests pinned the minimum checks only with explicit values, so lowering a default, which turns its sentinel off, still passed. The report now runs with each default one below and at its value. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/test_api_coverage.py | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/.github/scripts/test_api_coverage.py b/.github/scripts/test_api_coverage.py index 5026e45f..6b66ff27 100644 --- a/.github/scripts/test_api_coverage.py +++ b/.github/scripts/test_api_coverage.py @@ -628,6 +628,55 @@ def test_one_operation_below_the_minimum_exits_2(tmp_path: Path) -> None: assert_did_not_run(outcome, "PDP spec at .* lists 1 operations, fewer than the minimum of 2") +def run_with_defaults(tmp_path: Path, *extra: str) -> Outcome: + """Rerun the report with each minimum not in `extra` at its default, as CI runs it.""" + return run_report( + tmp_path, + "--spec", + f"control-plane={tmp_path / 'cp.json'}", + "--spec", + f"pdp={tmp_path / 'pdp.json'}", + "--allowlist", + str(tmp_path / "allowlist.json"), + "--record", + str(tmp_path / "offline.jsonl"), + *extra, + ) + + +def test_the_default_minimum_of_offline_requests_is_400(tmp_path: Path) -> None: + small_specs = ("--min-operations", "control-plane=1", "--min-operations", "pdp=1") + report(tmp_path, requests=[COVERING[index % len(COVERING)] for index in range(399)]) + assert_did_not_run( + run_with_defaults(tmp_path, *small_specs), + "holds 399 offline requests, fewer than the minimum of 400", + ) + report(tmp_path, requests=[COVERING[index % len(COVERING)] for index in range(400)]) + outcome = run_with_defaults(tmp_path, *small_specs) + assert outcome.code == 0, outcome.summary + + +@pytest.mark.parametrize(("api", "minimum"), [(CONTROL_PLANE, 200), ("pdp", 20)]) +def test_the_default_minimum_of_operations_per_spec(tmp_path: Path, api: str, minimum: int) -> None: + def run_with_operations(count: int) -> Outcome: + ops = [("POST", f"/{api}/{index}", {"tags": ["Users"]}) for index in range(count)] + requests = [(method, path) for method, path, _ in ops] + if api == CONTROL_PLANE: + report(tmp_path, cp_ops=ops, requests=[*requests, ("POST", "/allowed")]) + other = "pdp=1" + else: + report(tmp_path, pdp_ops=ops, requests=[*COVERING[:4], *requests]) + other = "control-plane=1" + return run_with_defaults(tmp_path, "--min-records", "1", "--min-operations", other) + + assert_did_not_run( + run_with_operations(minimum - 1), + f"lists {minimum - 1} operations, fewer than the minimum of {minimum}", + ) + outcome = run_with_operations(minimum) + assert outcome.code == 0, outcome.summary + + def test_e2e_requests_do_not_count_towards_the_offline_minimum(tmp_path: Path) -> None: report(tmp_path) requests = [request_line(m, p) for m, p in COVERING] + [ From d19c2feb2a0fc1de3000c486233ba1c2d991e0db Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 00:16:36 +0300 Subject: [PATCH 31/32] Regenerate ApproveMessage, whose field the API renamed to detail The live spec renamed ApproveMessage's only field from message to detail, so the schema drift check failed on it. No SDK method returns this model. The class is the generator's output, copied unchanged. Co-Authored-By: Claude Opus 5.5 --- permit/api/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/permit/api/models.py b/permit/api/models.py index 59e00d4c..1e12665f 100644 --- a/permit/api/models.py +++ b/permit/api/models.py @@ -295,7 +295,7 @@ class ApproveMessage(BaseModel): class Config: extra = Extra.allow - message: str = Field(..., title='Message') + detail: str = Field(..., title='Detail') class AttributeType(str, Enum): From ef5a79168ff935c7da16d252d654a3edc8538c65 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Fri, 2 Oct 2026 00:16:36 +0300 Subject: [PATCH 32/32] Delete the detailed lists' test tuple without a tenant The relationship tuple delete body takes subject, relation and object, and the API answers 422 to a tenant there, so the teardown failed. Co-Authored-By: Claude Opus 5.5 --- tests/test_detailed_lists_e2e.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_detailed_lists_e2e.py b/tests/test_detailed_lists_e2e.py index 11a1edad..ad89df08 100644 --- a/tests/test_detailed_lists_e2e.py +++ b/tests/test_detailed_lists_e2e.py @@ -140,9 +140,10 @@ def on_teardown(delete: Callable[[], Awaitable[None]], description: str) -> None "object": policy.document, "tenant": policy.tenant, } - on_teardown( - functools.partial(api.relationship_tuples.delete, relationship), str(relationship) - ) + # The delete body names the tuple by subject, relation and object; the API + # rejects a tenant there with a 422. + unrelate = {key: relationship[key] for key in ("subject", "relation", "object")} + on_teardown(functools.partial(api.relationship_tuples.delete, unrelate), str(unrelate)) await api.relationship_tuples.create(relationship) for assignment in ( {"user": policy.user, "role": policy.role, "tenant": policy.tenant},