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.