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..2b81d607
--- /dev/null
+++ b/.github/scripts/api_coverage.py
@@ -0,0 +1,1324 @@
+#!/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 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.
+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) -> object:
+ """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: object, api: str, label: str) -> list[Operation]:
+ """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, 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.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 ----------------------------------------------------------
+
+
+@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", "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
+ ),
+ ],
+ )
+ 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", "End to end"),
+ _row(*["---"] * 6),
+ *(
+ _row(
+ API_TITLES[r.operation.api],
+ _code(r.operation.name),
+ r.operation.stage,
+ _cell(entry.ticket),
+ _cell(entry.reason),
+ _e2e_cell(r),
+ )
+ 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: 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 `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()]
+ 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..9b32c0ee
--- /dev/null
+++ b/.github/scripts/api_coverage_allowlist.json
@@ -0,0 +1,1894 @@
+{
+ "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": "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",
+ "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/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/.github/scripts/test_api_coverage.py b/.github/scripts/test_api_coverage.py
new file mode 100644
index 00000000..6b66ff27
--- /dev/null
+++ b/.github/scripts/test_api_coverage.py
@@ -0,0 +1,1203 @@
+"""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, 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:
+ 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/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 | 2 |" 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}"]
+ 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:
+ 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 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] + [
+ 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)
+
+
+@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(
+ 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
+
+
+@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(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()
+
+
+@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
+
+
+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 -------------
+
+
+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/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) }}
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
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 13b575fb..35d01565 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
@@ -194,6 +196,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
@@ -201,8 +206,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. Drop the GET /health requests and every
# "Health check failed: horizon" line but the first, which says why the PDP
@@ -443,6 +460,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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d46f8db3..131582e6 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
@@ -164,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
@@ -201,6 +205,10 @@ 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). 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
The blocking client, `permit.sync.Permit`, wraps the async classes at runtime, which type
@@ -294,6 +302,86 @@ 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 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:
+
+- 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
diff --git a/README.md b/README.md
index 2f9c9c70..a0d6ae33 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,55 @@ 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
+`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.
+
+## 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
@@ -136,6 +185,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 242426ed..d0b47ac3 100644
--- a/permit/_sync_types.pyi
+++ b/permit/_sync_types.pyi
@@ -36,7 +36,11 @@ from permit.api.models import (
PaginatedResultElementsUserInviteRead,
PaginatedResultGroupReadSchema,
PaginatedResultRelationRead,
+ PaginatedResultRelationshipTupleDetailedRead,
+ PaginatedResultResourceInstanceDetailedRead,
+ PaginatedResultRoleAssignmentDetailedRead,
PaginatedResultUserRead,
+ PDPDataRefreshResponse,
PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings,
ProjectCreate,
ProjectRead,
@@ -785,6 +789,42 @@ 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:
+ 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, 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.
+ """
+
class SyncProjectsApi(BasePermitApi):
"""Manage the projects of an organization."""
def __init__(self, config: PermitConfig) -> None: ...
@@ -917,6 +957,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
@@ -1371,12 +1452,49 @@ 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:
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
@@ -1964,6 +2082,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.
@@ -1981,6 +2103,56 @@ 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.
+
+ 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.
+ 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
@@ -2812,6 +2984,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.
@@ -2820,6 +2993,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/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/models.py b/permit/api/models.py
index fdd4147d..1e12665f 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
diff --git a/permit/api/pdps.py b/permit/api/pdps.py
new file mode 100644
index 00000000..2699e928
--- /dev/null
+++ b/permit/api/pdps.py
@@ -0,0 +1,66 @@
+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:
+ 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, 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.
+ """
+ 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/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..82c7559f 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,
@@ -23,8 +24,37 @@
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(
+ *,
+ 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."""
@@ -62,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:
@@ -73,18 +105,18 @@ 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 = 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 +124,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..c104d3bf 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."""
@@ -50,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.
@@ -74,32 +115,89 @@ 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.
+
+ 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.
+ 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/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/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/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.
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/api_coverage_recorder.py b/tests/api_coverage_recorder.py
new file mode 100644
index 00000000..fe825e8d
--- /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.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"
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_detailed_lists_e2e.py b/tests/test_detailed_lists_e2e.py
new file mode 100644
index 00000000..ad89df08
--- /dev/null
+++ b/tests/test_detailed_lists_e2e.py
@@ -0,0 +1,323 @@
+"""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,
+ }
+ # 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},
+ {
+ "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]
diff --git a/tests/test_detailed_lists_offline.py b/tests/test_detailed_lists_offline.py
new file mode 100644
index 00000000..c88ac56d
--- /dev/null
+++ b/tests/test_detailed_lists_offline.py
@@ -0,0 +1,535 @@
+"""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
+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.
+
+``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
+
+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 == []
+
+
+# --- 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_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_offline_regressions.py b/tests/test_offline_regressions.py
index a942b6fc..799cdbcb 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"
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
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/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] = {}
diff --git a/tests/test_user_permissions_context_e2e.py b/tests/test_user_permissions_context_e2e.py
new file mode 100644
index 00000000..9bd78a22
--- /dev/null
+++ b/tests/test_user_permissions_context_e2e.py
@@ -0,0 +1,156 @@
+"""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_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]
+ )
+ return permissions_by_tenant(answer)
+
+ assert await settled(granted, expected=policy.expected) == policy.expected
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 1d63fd87..dbfcf800 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
@@ -28,7 +29,11 @@
GroupRead,
GroupReadSchema,
PaginatedResultGroupReadSchema,
+ PaginatedResultRelationshipTupleDetailedRead,
+ PaginatedResultResourceInstanceDetailedRead,
+ PaginatedResultRoleAssignmentDetailedRead,
PaginatedResultUserRead,
+ PDPDataRefreshResponse,
RoleAssignmentCreate,
RoleAssignmentRead,
RoleCreate,
@@ -78,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)
@@ -135,6 +144,20 @@ 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,
+ )
+ 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")]
@@ -179,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)
@@ -195,6 +219,20 @@ 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.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
@@ -218,6 +256,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.