From 2e05510b070f7cb4b0ea4b63a37e61acb2eb6ec7 Mon Sep 17 00:00:00 2001 From: Shuvy Date: Wed, 17 Sep 2025 15:50:03 +0300 Subject: [PATCH 1/5] Update code-generator --- permit/api/models.py | 1147 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 945 insertions(+), 202 deletions(-) diff --git a/permit/api/models.py b/permit/api/models.py index 2d59102..13f26df 100644 --- a/permit/api/models.py +++ b/permit/api/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: https://api.permit.io/v2/openapi.json -# timestamp: 2024-10-13T11:45:49+00:00 +# timestamp: 2025-09-17T12:48:00+00:00 from __future__ import annotations @@ -9,12 +9,7 @@ from typing import Any, Dict, List, Literal, Optional, Union from uuid import UUID -from ..utils.pydantic_version import PYDANTIC_VERSION - -if PYDANTIC_VERSION < (2, 0): - from pydantic import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr -else: - from pydantic.v1 import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr # type: ignore +from pydantic import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr class APIHistoryEventFullRead(BaseModel): @@ -146,7 +141,7 @@ class Config: ) resource_instance: Optional[str] = Field( None, - description='resource instance id or key that the user is requesting access to', + description='Either the unique id of the resource instance that the user is requesting access to, or the URL-friendly key of the (i.e: file:my_file)', title='Resource Instance', ) role: str = Field( @@ -154,6 +149,11 @@ class Config: description='role id or key that the user is requesting access to', title='Role', ) + element_config_id: Optional[str] = Field( + None, + description='element config id or key that the user is requesting access request from', + title='Element Config Id', + ) class AccessRequestReview(BaseModel): @@ -293,6 +293,50 @@ class AttributeType(str, Enum): object_array = 'object_array' +class AuditLogReplayRequest(BaseModel): + class Config: + extra = Extra.allow + + pdp_url: str = Field( + ..., + description='URL of the PDP to test against', + example='http://mydomain.com:7766', + title='Pdp Url', + ) + start_time: Optional[int] = Field( + None, + description='Start time for the query (in seconds since epoch). Defaults to 24 hours ago.', + example=1616432400, + title='Start Time', + ) + end_time: Optional[int] = Field( + None, + description='End time for the query (in seconds since epoch). Defaults to current time.', + example=1616518800, + title='End Time', + ) + concurrency_limit: Optional[int] = Field( + 10, + description='Concurrency limit for processing documents (max: 5)', + example=10, + title='Concurrency Limit', + ) + graceful_shutdown_s: Optional[int] = Field( + 60, + description='Graceful shutdown time in seconds', + example=60, + title='Graceful Shutdown S', + ) + + +class AuditLogReplayResponse(BaseModel): + class Config: + extra = Extra.allow + + message: str = Field(..., title='Message') + document_count: int = Field(..., title='Document Count') + + class AuditLogSortKey(str, Enum): None_ = 'None' timestamp = 'timestamp' @@ -304,10 +348,13 @@ class AuthMechanism(str, Enum): Headers = 'Headers' -class BillingTier(str, Enum): +class BillingTierType(str, Enum): free = 'free' + startup = 'startup' pro = 'pro' enterprise = 'enterprise' + internal = 'internal' + trial = 'trial' class BulkRoleAssignmentReport(BaseModel): @@ -324,6 +371,22 @@ class Config: assignments_removed: Optional[int] = Field(0, title='Assignments Removed') +class ConditionSet(BaseModel): + class Config: + extra = Extra.allow + + key: str = Field(..., description='The key of the condition set.', title='Key') + attribute: str = Field( + ..., description='The attribute of the condition set.', title='Attribute' + ) + operator: str = Field( + ..., description='The operator of the condition set.', title='Operator' + ) + value: str = Field( + ..., description='The value of the condition set.', title='Value' + ) + + class ConditionSetRuleCreate(BaseModel): class Config: extra = Extra.allow @@ -642,6 +705,7 @@ class ErrorCode(str, Enum): PAGINATION_SIZE_OVERREACHED = 'PAGINATION_SIZE_OVERREACHED' MISMATCH_RELATION_TYPE = 'MISMATCH_RELATION_TYPE' CONCURRENT_OPERATION_DISALLOWED = 'CONCURRENT_OPERATION_DISALLOWED' + UNAUTHORIZED = 'UNAUTHORIZED' class ErrorDetails(BaseModel): @@ -707,7 +771,7 @@ class Config: group_instance_key: str = Field( ..., - description='The key of the resource instance that the group belongs to.', + description='Either the unique id of the resource instance that that the group belongs to, or the URL-friendly key of the (i.e: file:my_file)', title='Group Instance Key', ) @@ -723,7 +787,7 @@ class Config: ) group_instance_key: str = Field( ..., - description='The key of the resource instance that the group belongs to.', + description='Either the unique id of the resource instance that that the group belongs to, or the URL-friendly key of the (i.e: file:my_file)', title='Group Instance Key', ) group_tenant: str = Field( @@ -754,7 +818,28 @@ class Config: ) group_instance_key: str = Field( ..., - description='The key of the resource instance that the group belongs to.', + description='Either the unique id of the resource instance that that the group belongs to, or the URL-friendly key of the (i.e: file:my_file)', + title='Group Instance Key', + ) + group_tenant: str = Field( + ..., + description='The tenant key or id that the group belongs to.', + title='Group Tenant', + ) + + +class GroupReadSchema(BaseModel): + class Config: + extra = Extra.allow + + group_resource_type_key: Optional[str] = Field( + 'group', + description='The key of the resource type that the group belongs to.', + title='Group Resource Type Key', + ) + group_instance_key: str = Field( + ..., + description='Either the unique id of the resource instance that that the group belongs to, or the URL-friendly key of the (i.e: file:my_file)', title='Group Instance Key', ) group_tenant: str = Field( @@ -762,6 +847,7 @@ class Config: description='The tenant key or id that the group belongs to.', title='Group Tenant', ) + id: UUID = Field(..., title='Id') class HttpMethods(Enum): @@ -1254,6 +1340,17 @@ class Config: page_count: Optional[conint(ge=0)] = Field(0, title='Page Count') +class PaginatedResultGroupReadSchema(BaseModel): + class Config: + extra = Extra.allow + + data: List[GroupReadSchema] = Field( + ..., description='List of Group Read Schemas', title='Data' + ) + total_count: conint(ge=0) = Field(..., title='Total Count') + page_count: Optional[conint(ge=0)] = Field(0, title='Page Count') + + class PdpConfigObj(BaseModel): class Config: extra = Extra.allow @@ -1267,6 +1364,7 @@ class Config: extra = Extra.allow BACKEND_SERVICE_URL: str = Field(..., title='Backend Service Url') + OPA_DECISION_LOG_ENABLED: bool = Field(..., title='Opa Decision Log Enabled') OPA_DECISION_LOG_INGRESS_ROUTE: str = Field( ..., title='Opa Decision Log Ingress Route' ) @@ -1276,12 +1374,8 @@ class Config: CONTROL_PLANE_RELAY_JWT_TIER: str = Field(..., title='Control Plane Relay Jwt Tier') CONTROL_PLANE_RELAY_API: str = Field(..., title='Control Plane Relay Api') CONTROL_PLANE_PDP_DELTAS_API: str = Field(..., title='Control Plane Pdp Deltas Api') - ENABLE_EXTERNAL_DATA_MANAGER: bool = Field( - ..., title='Enable External Data Manager' - ) - DATA_MANAGER_REMOTE_BACKUP_URL: str = Field( - ..., title='Data Manager Remote Backup Url' - ) + FACTDB_ENABLED: Optional[bool] = Field(None, title='Factdb Enabled') + FACTDB_BACKUP_SERVER_URL: str = Field(..., title='Factdb Backup Server Url') class Permission(BaseModel): @@ -1317,6 +1411,153 @@ class Config: ) +class PolicyGuardRuleCreate(BaseModel): + class Config: + extra = Extra.allow + + is_allowed: bool = Field( + ..., + description='If True, the permission will be allowed for the role in the policy guard across all projectswithin the policy scope, and the permission will be locked from further editing.', + title='Is Allowed', + ) + resource_key: str = Field( + ..., description='The key of the resource.', title='Resource Key' + ) + role_key: Optional[str] = Field( + None, description='The key of the role.', title='Role Key' + ) + action_key: str = Field( + ..., description='The key of the action.', title='Action Key' + ) + resource_set: Optional[ConditionSet] = Field( + None, + description='The resource set that the permission will be applied to.', + title='Resource Set', + ) + user_set: Optional[ConditionSet] = Field( + None, + description='The user set that the permission will be applied to.', + title='User Set', + ) + + +class PolicyGuardRuleItem(BaseModel): + class Config: + extra = Extra.allow + + resource_key: str = Field( + ..., description='The key of the resource.', title='Resource Key' + ) + role_key: Optional[str] = Field( + None, description='The key of the role.', title='Role Key' + ) + action_key: str = Field( + ..., description='The key of the action.', title='Action Key' + ) + resource_set: Optional[ConditionSet] = Field( + None, + description='The resource set that the permission will be applied to.', + title='Resource Set', + ) + user_set: Optional[ConditionSet] = Field( + None, + description='The user set that the permission will be applied to.', + title='User Set', + ) + + +class PolicyGuardRuleRead(BaseModel): + class Config: + extra = Extra.allow + + is_allowed: bool = Field( + ..., + description='If True, the permission will be allowed for the role in the policy guard across all projectswithin the policy scope, and the permission will be locked from further editing.', + title='Is Allowed', + ) + resource_key: str = Field( + ..., description='The key of the resource.', title='Resource Key' + ) + role_key: Optional[str] = Field( + None, description='The key of the role.', title='Role Key' + ) + action_key: str = Field( + ..., description='The key of the action.', title='Action Key' + ) + resource_set: Optional[ConditionSet] = Field( + None, + description='The resource set that the permission will be applied to.', + title='Resource Set', + ) + user_set: Optional[ConditionSet] = Field( + None, + description='The user set that the permission will be applied to.', + title='User Set', + ) + scope_id: UUID = Field( + ..., description='The unique identifier of the policy guard.', title='Scope Id' + ) + organization_id: UUID = Field( + ..., + description='Unique id of the organization that the ScopeConfig belongs to.', + title='Organization Id', + ) + id: UUID = Field(..., description='Unique id of the ScopeConfig', title='Id') + + +class PolicyGuardScopeAssociate(BaseModel): + class Config: + extra = Extra.allow + + project_id: str = Field( + ..., + description='The unique identifier of the project of this policy scope.', + title='Project Id', + ) + + +class PolicyGuardScopeDetail(BaseModel): + class Config: + extra = Extra.allow + + project_id: UUID = Field( + ..., + description='Unique id of the project that the ScopeConfig belongs to.', + title='Project Id', + ) + + +class PolicyGuardScopeDetailCreate(BaseModel): + class Config: + extra = Extra.allow + + project_id: str = Field( + ..., + description='The unique identifier of the project of this policy scope.', + title='Project Id', + ) + + +class PolicyGuardScopeRead(BaseModel): + class Config: + extra = Extra.allow + + key: str = Field( + ..., description='The unique key of the policy guard scope.', title='Key' + ) + id: UUID = Field(..., description='Unique id of the ScopeConfig', title='Id') + organization_id: UUID = Field( + ..., + description='Unique id of the organization that the ScopeConfig belongs to.', + title='Organization Id', + ) + policy_guard_scope_details: Optional[List[PolicyGuardScopeDetail]] = Field( + [], + description='list of projects that this policy guard is assigned to.', + title='Policy Guard Scope Details', + ) + + class PolicyRepoStatus(str, Enum): invalid = 'invalid' pending = 'pending' @@ -1583,7 +1824,12 @@ class RelationshipTupleDeleteBulkOperation(BaseModel): class Config: extra = Extra.allow - idents: List[RelationshipTupleDelete] = Field(..., max_items=1000, title='Idents') + idents: List[RelationshipTupleDelete] = Field( + ..., + description='List of relationship tuples objects to delete', + max_items=1000, + title='Idents', + ) class RelationshipTupleDeleteBulkOperationResult(BaseModel): @@ -1934,22 +2180,6 @@ class Config: key: str = Field(..., title='Key') -class ResourceInstanceAttributeData(BaseModel): - class Config: - extra = Extra.allow - - tenant: Optional[str] = Field( - None, - description='The tenant key that this resource instance belongs to.', - title='Tenant', - ) - attributes: Optional[Dict[str, Any]] = Field( - {}, - description='Key-Value mapping of the attributes of the resource instance.\nThe key is the attribute key and the value is the attribute value.', - title='Attributes', - ) - - class ResourceInstanceBlockRead(BaseModel): class Config: extra = Extra.allow @@ -2020,7 +2250,11 @@ class ResourceInstanceDeleteBulkOperation(BaseModel): class Config: extra = Extra.allow - idents: List[str] = Field(..., title='Idents') + idents: List[str] = Field( + ..., + description='List of resource instance idents to delete. Either the unique id of the resource instance, or the URL-friendly key of the (i.e: file:my_file)', + title='Idents', + ) class ResourceInstanceDeleteBulkOperationResult(BaseModel): @@ -2030,6 +2264,69 @@ class Config: extra = Extra.allow +class ResourceInstanceDetailedRead(BaseModel): + class Config: + extra = Extra.allow + + key: str = Field( + ..., + description="A unique identifier by which Permit will identify the resource instance for permission checks. You will later pass this identifier to the `permit.check()` API. A key can be anything: for example the resource db id, a url slug, a UUID or anything else as long as it's unique on your end. The resource instance key must be url-friendly.", + title='Key', + ) + tenant: str = Field( + ..., + description='the *key* of the tenant that this resource belongs to, used to enforce tenant boundaries in multi-tenant apps.', + title='Tenant', + ) + resource: str = Field( + ..., + description='the *key* of the resource (type) of this resource instance. For example: if this resource instance is the annual budget document, the key of the resource might be `document`.', + title='Resource', + ) + id: UUID = Field(..., description='Unique id of the resource instance', title='Id') + organization_id: UUID = Field( + ..., + description='Unique id of the organization that the resource instance belongs to.', + title='Organization Id', + ) + project_id: UUID = Field( + ..., + description='Unique id of the project that the resource instance belongs to.', + title='Project Id', + ) + environment_id: UUID = Field( + ..., + description='Unique id of the environment that the resource instance belongs to.', + title='Environment Id', + ) + created_at: datetime = Field( + ..., + description='Date and time when the resource instance was created (ISO_8601 format).', + title='Created At', + ) + updated_at: datetime = Field( + ..., + description='Date and time when the resource instance was created (ISO_8601 format).', + title='Updated At', + ) + resource_id: UUID = Field( + ..., description='Unique id of the resource', title='Resource Id' + ) + tenant_id: UUID = Field( + ..., description='Unique id of the tenant', title='Tenant Id' + ) + attributes: Optional[Dict[str, Any]] = Field( + {}, + description='Arbitrary resource attributes that will be used to enforce attribute-based access control policies.', + title='Attributes', + ) + relationships: List[RelationshipTupleBlockRead] = Field( + ..., + description='The relationships of the resource instance.', + title='Relationships', + ) + + class ResourceInstanceRead(BaseModel): class Config: extra = Extra.allow @@ -2269,31 +2566,15 @@ class Config: updated: List[str] = Field(..., title='Updated') -class RoleData(BaseModel): +class SMTPEmailConfigurationCreate(BaseModel): class Config: extra = Extra.allow - grants: Dict[str, List[str]] = Field( + host: str = Field(..., description='The host of the SMTP provider', title='Host') + from_address: EmailStr = Field( ..., - description='Key-Value mapping of the resources and actions that the role can perform.\nThe key is the resource key and the value is a list of actions that the role can perform on that resource.', - title='Grants', - ) - attributes: Optional[Dict[str, Any]] = Field( - {}, - description='Key-Value mapping of the attributes of the role.\nThe key is the attribute key and the value is the attribute value.', - title='Attributes', - ) - - -class SMTPEmailConfigurationCreate(BaseModel): - class Config: - extra = Extra.allow - - host: str = Field(..., description='The host of the SMTP provider', title='Host') - from_address: EmailStr = Field( - ..., - description='The from address the mails will be sent from', - title='From Address', + description='The from address the mails will be sent from', + title='From Address', ) port: int = Field(..., description='The port of the SMTP provider', title='Port') username: str = Field( @@ -2467,27 +2748,17 @@ class Config: extra = Extra.allow -class TenantData(BaseModel): +class TenantDeleteBulkOperation(BaseModel): class Config: extra = Extra.allow - roleAssignments: Optional[Dict[str, List[str]]] = Field( - None, title='Roleassignments' - ) - attributes: Dict[str, Any] = Field( + idents: List[str] = Field( ..., - description='Key-Value mapping of the attributes of the tenant.\nThe key is the attribute key and the value is the attribute value.', - title='Attributes', + description='List of tenant idents to delete. Either the unique id or the key of the tenants.', + title='Idents', ) -class TenantDeleteBulkOperation(BaseModel): - class Config: - extra = Extra.allow - - idents: List[str] = Field(..., title='Idents') - - class TenantDeleteBulkOperationResult(BaseModel): pass @@ -2586,17 +2857,17 @@ class Config: extra = Extra.allow mau: Optional[int] = Field( - 1000, - description='Monthly active users limit. Default for free-tier is 1000.', + 5000, + description='Monthly active users limit. Default for trial is 5000.', title='Mau', ) tenants: Optional[int] = Field( - 20, - description='Number of tenants limit. Default for free-tier is 20.', + 50, + description='Number of tenants limit. Default for trial is 50.', title='Tenants', ) - billing_tier: Optional[BillingTier] = Field( - 'free', description='Billing tier. Default is free.' + billing_tier: Optional[BillingTierType] = Field( + 'trial', description='Billing tier. Default is trial.' ) @@ -2607,22 +2878,6 @@ class Config: extra = Extra.allow -class UserData(BaseModel): - class Config: - extra = Extra.allow - - roleAssignments: Dict[str, List[str]] = Field( - ..., - description='Key-Value mapping of the roles assigned to the user.\nThe key is the tenant key and the value is a list of role keys assigned to the user in that tenant.', - title='Roleassignments', - ) - attributes: Dict[str, Any] = Field( - ..., - description='Key-Value mapping of the attributes of the user.\nThe key is the attribute key and the value is the attribute value.', - title='Attributes', - ) - - class UserDeleteBulkOperation(BaseModel): class Config: extra = Extra.allow @@ -2808,6 +3063,107 @@ class Config: ) +class DataGeneratorLibSchemasSchemaOpalDataConditionSetData(BaseModel): + class Config: + extra = Extra.allow + + type: ConditionSetType = Field(..., description='The type of the condition set.') + key: str = Field(..., description='The key of the condition set.', title='Key') + + +class DataGeneratorLibSchemasSchemaOpalDataDerivationSettings(BaseModel): + class Config: + extra = Extra.allow + + superseded_by_direct_role: Optional[bool] = Field( + False, + description='If True, the derived role is superseded by a direct role.meaning role derivation is not considered if the user has a direct role.', + title='Superseded By Direct Role', + ) + + +class DataGeneratorLibSchemasSchemaOpalDataDerivedRoleRule(BaseModel): + class Config: + extra = Extra.allow + + relation: str = Field( + ..., + description='The relation between the resource and the related resource.', + title='Relation', + ) + related_resource: str = Field( + ..., description='The related resource type key.', title='Related Resource' + ) + related_role: str = Field( + ..., description='The related role key.', title='Related Role' + ) + settings: DataGeneratorLibSchemasSchemaOpalDataDerivationSettings = Field( + ..., description='Settings for the derived role rule.', title='Settings' + ) + + +class DataGeneratorLibSchemasSchemaOpalDataResourceInstanceAttributeData(BaseModel): + class Config: + extra = Extra.allow + + tenant: Optional[str] = Field( + None, + description='The tenant key that this resource instance belongs to.', + title='Tenant', + ) + attributes: Optional[Dict[str, Any]] = Field( + {}, + description='Key-Value mapping of the attributes of the resource instance.\nThe key is the attribute key and the value is the attribute value.', + title='Attributes', + ) + + +class DataGeneratorLibSchemasSchemaOpalDataRoleData(BaseModel): + class Config: + extra = Extra.allow + + grants: Dict[str, List[str]] = Field( + ..., + description='Key-Value mapping of the resources and actions that the role can perform.\nThe key is the resource key and the value is a list of actions that the role can perform on that resource.', + title='Grants', + ) + attributes: Optional[Dict[str, Any]] = Field( + {}, + description='Key-Value mapping of the attributes of the role.\nThe key is the attribute key and the value is the attribute value.', + title='Attributes', + ) + + +class DataGeneratorLibSchemasSchemaOpalDataTenantData(BaseModel): + class Config: + extra = Extra.allow + + roleAssignments: Optional[Dict[str, List[str]]] = Field( + None, title='Roleassignments' + ) + attributes: Dict[str, Any] = Field( + ..., + description='Key-Value mapping of the attributes of the tenant.\nThe key is the attribute key and the value is the attribute value.', + title='Attributes', + ) + + +class DataGeneratorLibSchemasSchemaOpalDataUserData(BaseModel): + class Config: + extra = Extra.allow + + roleAssignments: Dict[str, List[str]] = Field( + ..., + description='Key-Value mapping of the roles assigned to the user.\nThe key is the tenant key and the value is a list of role keys assigned to the user in that tenant.', + title='Roleassignments', + ) + attributes: Dict[str, Any] = Field( + ..., + description='Key-Value mapping of the attributes of the user.\nThe key is the attribute key and the value is the attribute value.', + title='Attributes', + ) + + class PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings(BaseModel): class Config: extra = Extra.allow @@ -2819,6 +3175,14 @@ class Config: ) +class PermitBackendSchemasSchemaOpalDataConditionSetData(BaseModel): + class Config: + extra = Extra.allow + + type: ConditionSetType = Field(..., description='The type of the condition set.') + key: str = Field(..., description='The key of the condition set.', title='Key') + + class PermitBackendSchemasSchemaOpalDataDerivationSettings(BaseModel): class Config: extra = Extra.allow @@ -2830,6 +3194,88 @@ class Config: ) +class PermitBackendSchemasSchemaOpalDataDerivedRoleRule(BaseModel): + class Config: + extra = Extra.allow + + relation: str = Field( + ..., + description='The relation between the resource and the related resource.', + title='Relation', + ) + related_resource: str = Field( + ..., description='The related resource type key.', title='Related Resource' + ) + related_role: str = Field( + ..., description='The related role key.', title='Related Role' + ) + settings: PermitBackendSchemasSchemaOpalDataDerivationSettings = Field( + ..., description='Settings for the derived role rule.', title='Settings' + ) + + +class PermitBackendSchemasSchemaOpalDataResourceInstanceAttributeData(BaseModel): + class Config: + extra = Extra.allow + + tenant: Optional[str] = Field( + None, + description='The tenant key that this resource instance belongs to.', + title='Tenant', + ) + attributes: Optional[Dict[str, Any]] = Field( + {}, + description='Key-Value mapping of the attributes of the resource instance.\nThe key is the attribute key and the value is the attribute value.', + title='Attributes', + ) + + +class PermitBackendSchemasSchemaOpalDataRoleData(BaseModel): + class Config: + extra = Extra.allow + + grants: Dict[str, List[str]] = Field( + ..., + description='Key-Value mapping of the resources and actions that the role can perform.\nThe key is the resource key and the value is a list of actions that the role can perform on that resource.', + title='Grants', + ) + attributes: Optional[Dict[str, Any]] = Field( + {}, + description='Key-Value mapping of the attributes of the role.\nThe key is the attribute key and the value is the attribute value.', + title='Attributes', + ) + + +class PermitBackendSchemasSchemaOpalDataTenantData(BaseModel): + class Config: + extra = Extra.allow + + roleAssignments: Optional[Dict[str, List[str]]] = Field( + None, title='Roleassignments' + ) + attributes: Dict[str, Any] = Field( + ..., + description='Key-Value mapping of the attributes of the tenant.\nThe key is the attribute key and the value is the attribute value.', + title='Attributes', + ) + + +class PermitBackendSchemasSchemaOpalDataUserData(BaseModel): + class Config: + extra = Extra.allow + + roleAssignments: Dict[str, List[str]] = Field( + ..., + description='Key-Value mapping of the roles assigned to the user.\nThe key is the tenant key and the value is a list of role keys assigned to the user in that tenant.', + title='Roleassignments', + ) + attributes: Dict[str, Any] = Field( + ..., + description='Key-Value mapping of the attributes of the user.\nThe key is the attribute key and the value is the attribute value.', + title='Attributes', + ) + + class APIKeyCreate(BaseModel): class Config: extra = Extra.allow @@ -3228,14 +3674,6 @@ class Config: ) -class ConditionSetData(BaseModel): - class Config: - extra = Extra.allow - - type: ConditionSetType = Field(..., description='The type of the condition set.') - key: str = Field(..., description='The key of the condition set.', title='Key') - - class DataSourceEntryWithPollingInterval(BaseModel): class Config: extra = Extra.allow @@ -3269,26 +3707,6 @@ class Config: ) -class DerivedRoleRule(BaseModel): - class Config: - extra = Extra.allow - - relation: str = Field( - ..., - description='The relation between the resource and the related resource.', - title='Relation', - ) - related_resource: str = Field( - ..., description='The related resource type key.', title='Related Resource' - ) - related_role: str = Field( - ..., description='The related role key.', title='Related Role' - ) - settings: PermitBackendSchemasSchemaOpalDataDerivationSettings = Field( - ..., description='Settings for the derived role rule.', title='Settings' - ) - - class DerivedRoleRuleCreate(BaseModel): class Config: extra = Extra.allow @@ -3517,6 +3935,11 @@ class Config: description='The tenant id of the user that is being invited', title='Tenant Id', ) + resource_instance_id: UUID = Field( + ..., + description='The resource instance id of the user that is being invited', + title='Resource Instance Id', + ) class ElementsUserInviteRead(BaseModel): @@ -3576,6 +3999,11 @@ class Config: description='The tenant id of the user that is being invited', title='Tenant Id', ) + resource_instance_id: UUID = Field( + ..., + description='The resource instance id of the user that is being invited', + title='Resource Instance Id', + ) class ElementsUserInviteUpdate(BaseModel): @@ -3607,6 +4035,11 @@ class Config: description='The tenant id of the user that is being invited', title='Tenant Id', ) + resource_instance_id: UUID = Field( + ..., + description='The resource instance id of the user that is being invited', + title='Resource Instance Id', + ) class EmailConfigurationCreate(BaseModel): @@ -3816,10 +4249,15 @@ class MappingRule(BaseModel): class Config: extra = Extra.allow - url: AnyUrl = Field( + url: str = Field( ..., description='The URL to match against the request URL', title='Url' ) - http_method: Methods = Field( + url_type: Optional[Literal['regex']] = Field( + None, + description="The URL type to match against the request URL can be, 'regex' or none", + title='Url Type', + ) + http_method: Methods = Field( ..., description='The HTTP method to match against the request method' ) resource: constr(regex=r'^[A-Za-z0-9\-_]+$') = Field( @@ -3844,6 +4282,48 @@ class Config: ) +class MappingRuleUpdate(BaseModel): + class Config: + extra = Extra.allow + + url: str = Field( + ..., description='The URL to match against the request URL', title='Url' + ) + url_type: Optional[Literal['regex']] = Field( + None, + description="The URL type to match against the request URL can be, 'regex' or none", + title='Url Type', + ) + http_method: Methods = Field( + ..., description='The HTTP method to match against the request method' + ) + resource: constr(regex=r'^[A-Za-z0-9\-_]+$') = Field( + ..., + description='The resource to match against the request resource', + title='Resource', + ) + headers: Optional[Dict[str, str]] = Field( + {}, + description='The headers to match against the request headers', + title='Headers', + ) + action: Optional[str] = Field( + None, + description='The action to match against the request action', + title='Action', + ) + priority: Optional[int] = Field( + None, + description='The priority of the mapping rule. The higher the priority, the higher the precedence', + title='Priority', + ) + should_delete: Optional[bool] = Field( + False, + description='If true, this mapping rule will be deleted during update.', + title='Should Delete', + ) + + class MultiInviteResult(BaseModel): class Config: extra = Extra.allow @@ -4426,7 +4906,7 @@ class Config: ) usage_limits: Optional[UsageLimits] = Field( default_factory=lambda: UsageLimits.parse_obj( - {'mau': 1000, 'tenants': 20, 'billing_tier': 'free'} + {'mau': 5000, 'tenants': 50, 'billing_tier': 'trial'} ), description='Usage limits for this organization', title='Usage Limits', @@ -4466,7 +4946,7 @@ class Config: ) usage_limits: Optional[UsageLimits] = Field( default_factory=lambda: UsageLimits.parse_obj( - {'mau': 1000, 'tenants': 20, 'billing_tier': 'free'} + {'mau': 5000, 'tenants': 50, 'billing_tier': 'trial'} ), description='Usage limits for this organization', title='Usage Limits', @@ -4508,7 +4988,7 @@ class Config: ) usage_limits: Optional[UsageLimits] = Field( default_factory=lambda: UsageLimits.parse_obj( - {'mau': 1000, 'tenants': 20, 'billing_tier': 'free'} + {'mau': 5000, 'tenants': 50, 'billing_tier': 'trial'} ), description='Usage limits for this organization', title='Usage Limits', @@ -4588,6 +5068,17 @@ class Config: page_count: Optional[conint(ge=0)] = Field(0, title='Page Count') +class PaginatedResultResourceInstanceDetailedRead(BaseModel): + class Config: + extra = Extra.allow + + data: List[ResourceInstanceDetailedRead] = Field( + ..., description='List of Resource Instance Detaileds', title='Data' + ) + total_count: conint(ge=0) = Field(..., title='Total Count') + page_count: Optional[conint(ge=0)] = Field(0, title='Page Count') + + class PaginatedResultResourceInstanceRead(BaseModel): class Config: extra = Extra.allow @@ -4619,6 +5110,20 @@ class Config: page_count: Optional[conint(ge=0)] = Field(0, title='Page Count') +class PolicyGuardScopeCreate(BaseModel): + class Config: + extra = Extra.allow + + policy_guard_scope_details: Optional[List[PolicyGuardScopeDetailCreate]] = Field( + [], + description='list of projects that this policy guard is assigned to.', + title='Policy Guard Scope Details', + ) + key: str = Field( + ..., description='The unique key of the policy guard scope.', title='Key' + ) + + class PolicyRepoCreate(BaseModel): class Config: extra = Extra.allow @@ -4628,7 +5133,11 @@ class Config: description='A URL-friendly name of the policy repo (i.e: slug). You will be able to query later using this key instead of the id (UUID) of the policy repo.', title='Key', ) - url: constr(regex=r'^(.+@)*([\w\d\.]+):(.*)?.git$') = Field(..., title='Url') + url: str = Field( + ..., + description='The SSH URL of the git repository (e.g. git@github.com:username/repository.git)', + title='Url', + ) main_branch_name: Optional[str] = Field('main', title='Main Branch Name') credentials: SSHAuthData activate_when_validated: Optional[bool] = Field( @@ -4649,7 +5158,11 @@ class Config: description='A URL-friendly name of the policy repo (i.e: slug). You will be able to query later using this key instead of the id (UUID) of the policy repo.', title='Key', ) - url: constr(regex=r'^(.+@)*([\w\d\.]+):(.*)?.git$') = Field(..., title='Url') + url: str = Field( + ..., + description='The SSH URL of the git repository (e.g. git@github.com:username/repository.git)', + title='Url', + ) main_branch_name: Optional[str] = Field('main', title='Main Branch Name') credentials: SSHAuthDataRead activate_when_validated: Optional[bool] = Field( @@ -4759,9 +5272,9 @@ class Config: description="The name of the proxy config, for example: 'Stripe API'", title='Name', ) - mapping_rules: Optional[List[MappingRule]] = Field( + mapping_rules: Optional[List[MappingRuleUpdate]] = Field( [], - description='Proxy config mapping rules will include the rules that will be used to map the request to the backend service by a URL and a http method.', + description='Proxy config mapping rules, with optional should_delete flag to indicate deletion.', title='Mapping Rules', ) auth_mechanism: Optional[AuthMechanism] = Field( @@ -4770,6 +5283,88 @@ class Config: ) +class RelationshipTupleDetailedRead(BaseModel): + class Config: + extra = Extra.allow + + subject: str = Field( + ..., + description='resource_key:resource_instance_key of the subject', + title='Subject', + ) + relation: str = Field( + ..., description='key of the assigned relation', title='Relation' + ) + object: str = Field( + ..., + description='resource_key:resource_instance_key of the object', + title='Object', + ) + id: UUID = Field(..., description='Unique id of the relationship tuple', title='Id') + tenant: str = Field( + ..., + description='The tenant the relationship tuple is associated with', + title='Tenant', + ) + subject_id: UUID = Field( + ..., description='Unique id of the subject', title='Subject Id' + ) + relation_id: UUID = Field( + ..., description='Unique id of the relation', title='Relation Id' + ) + object_id: UUID = Field( + ..., description='Unique id of the object', title='Object Id' + ) + tenant_id: UUID = Field( + ..., description='Unique id of the tenant', title='Tenant Id' + ) + organization_id: UUID = Field( + ..., + description='Unique id of the organization that the relationship tuple belongs to.', + title='Organization Id', + ) + project_id: UUID = Field( + ..., + description='Unique id of the project that the relationship tuple belongs to.', + title='Project Id', + ) + environment_id: UUID = Field( + ..., + description='Unique id of the environment that the relationship tuple belongs to.', + title='Environment Id', + ) + created_at: datetime = Field( + ..., + description='Date and time when the relationship tuple was created (ISO_8601 format).', + title='Created At', + ) + updated_at: datetime = Field( + ..., + description='Date and time when the relationship tuple was created (ISO_8601 format).', + title='Updated At', + ) + subject_details: ResourceInstanceBlockRead = Field( + ..., + description='The subject details of the relationship tuple', + title='Subject Details', + ) + relation_details: StrippedRelationBlockRead = Field( + ..., + description='The relation details of the relationship tuple', + title='Relation Details', + ) + object_details: ResourceInstanceBlockRead = Field( + ..., + description='The object details of the relationship tuple', + title='Object Details', + ) + tenant_details: TenantBlockRead = Field( + ..., + description='The tenant details of the relationship tuple', + title='Tenant Details', + ) + + class RelationshipTupleRead(BaseModel): class Config: extra = Extra.allow @@ -4899,6 +5494,22 @@ class Config: ) +class TaskResultPolicyGuardScopeRead(BaseModel): + class Config: + extra = Extra.allow + + task_id: str = Field(..., description='The unique id of the task.', title='Task Id') + status: TaskStatus = Field(..., description='The status of the task.') + result: Optional[PolicyGuardScopeRead] = Field( + None, + description='The result of the task when the task finished.', + title='Result', + ) + error: Optional[ErrorDetails] = Field( + None, description='The error details when the task failed.', title='Error' + ) + + class UserCreate(BaseModel): class Config: extra = Extra.allow @@ -5056,6 +5667,64 @@ class Config: url: str = Field(..., description='The url to POST the webhook to', title='Url') +class DataGeneratorLibSchemasSchemaOpalDataDerivedRole(BaseModel): + class Config: + extra = Extra.allow + + conditions: Optional[str] = Field(None, title='Conditions') + settings: DataGeneratorLibSchemasSchemaOpalDataDerivationSettings = Field( + ..., description='Settings for the derived role.', title='Settings' + ) + rules: List[DataGeneratorLibSchemasSchemaOpalDataDerivedRoleRule] = Field( + ..., description='List of rules for the derived role.', title='Rules' + ) + + +class DataGeneratorLibSchemasSchemaOpalDataResourceTypeData(BaseModel): + class Config: + extra = Extra.allow + + actions: List[str] = Field( + ..., + description='List of actions that can be performed on the resource.', + title='Actions', + ) + derived_roles: Dict[str, DataGeneratorLibSchemasSchemaOpalDataDerivedRole] = Field( + ..., + description='Key-Value mapping of the derived roles for the resource type.\nThe key is the derived role key and the value is the details and conditions for the role derivation.', + title='Derived Roles', + ) + + +class PermitBackendSchemasSchemaOpalDataDerivedRole(BaseModel): + class Config: + extra = Extra.allow + + conditions: Optional[str] = Field(None, title='Conditions') + settings: PermitBackendSchemasSchemaOpalDataDerivationSettings = Field( + ..., description='Settings for the derived role.', title='Settings' + ) + rules: List[PermitBackendSchemasSchemaOpalDataDerivedRoleRule] = Field( + ..., description='List of rules for the derived role.', title='Rules' + ) + + +class PermitBackendSchemasSchemaOpalDataResourceTypeData(BaseModel): + class Config: + extra = Extra.allow + + actions: List[str] = Field( + ..., + description='List of actions that can be performed on the resource.', + title='Actions', + ) + derived_roles: Dict[str, PermitBackendSchemasSchemaOpalDataDerivedRole] = Field( + ..., + description='Key-Value mapping of the derived roles for the resource type.\nThe key is the derived role key and the value is the details and conditions for the role derivation.', + title='Derived Roles', + ) + + class AuditLogModel(BaseModel): class Config: extra = Extra.allow @@ -5095,19 +5764,6 @@ class Config: ) -class DerivedRole(BaseModel): - class Config: - extra = Extra.allow - - conditions: Optional[str] = Field(None, title='Conditions') - settings: PermitBackendSchemasSchemaOpalDataDerivationSettings = Field( - ..., description='Settings for the derived role.', title='Settings' - ) - rules: List[DerivedRoleRule] = Field( - ..., description='List of rules for the derived role.', title='Rules' - ) - - class DerivedRoleBlockEdit(BaseModel): class Config: extra = Extra.allow @@ -5479,6 +6135,17 @@ class Config: page_count: Optional[conint(ge=0)] = Field(0, title='Page Count') +class PaginatedResultRelationshipTupleDetailedRead(BaseModel): + class Config: + extra = Extra.allow + + data: List[RelationshipTupleDetailedRead] = Field( + ..., description='List of Relationship Tuple Detaileds', title='Data' + ) + total_count: conint(ge=0) = Field(..., title='Total Count') + page_count: Optional[conint(ge=0)] = Field(0, title='Page Count') + + class PaginatedResultRelationshipTupleRead(BaseModel): class Config: extra = Extra.allow @@ -5697,22 +6364,6 @@ class Config: ) -class ResourceTypeData(BaseModel): - class Config: - extra = Extra.allow - - actions: List[str] = Field( - ..., - description='List of actions that can be performed on the resource.', - title='Actions', - ) - derived_roles: Dict[str, DerivedRole] = Field( - ..., - description='Key-Value mapping of the derived roles for the resource type.\nThe key is the derived role key and the value is the details and conditions for the role derivation.', - title='Derived Roles', - ) - - class RoleBlockEditable(BaseModel): class Config: extra = Extra.allow @@ -6003,58 +6654,99 @@ class Config: ) -class APIKeyRead(BaseModel): +class DataGeneratorLibSchemasSchemaOpalDataFullData(BaseModel): class Config: extra = Extra.allow - organization_id: UUID = Field(..., title='Organization Id') - project_id: Optional[UUID] = Field(None, title='Project Id') - environment_id: Optional[UUID] = Field(None, title='Environment Id') - object_type: Optional[MemberAccessObj] = 'env' - access_level: Optional[MemberAccessLevel] = 'admin' - owner_type: APIKeyOwnerType - name: Optional[str] = Field(None, title='Name') - id: UUID = Field(..., title='Id') - secret: Optional[str] = Field(None, title='Secret') - created_at: datetime = Field(..., title='Created At') - created_by_member: Optional[OrgMemberRead] = None - last_used_at: Optional[datetime] = Field(None, title='Last Used At') - env: Optional[EnvironmentRead] = None - project: Optional[ProjectRead] = None - - -class EnvironmentCopyTarget(BaseModel): - class Config: - extra = Extra.allow - - existing: Optional[str] = Field( - None, - description='Identifier of an existing environment to copy into', - title='Existing', + use_debugger: Optional[bool] = Field(True, title='Use Debugger') + users: Dict[str, DataGeneratorLibSchemasSchemaOpalDataUserData] = Field( + ..., + description='Key-Value mapping of the users in the system.\nThe key is the user key and the value contains some details about the user.', + title='Users', ) - new: Optional[EnvironmentCreate] = Field( - None, - description='Description of the environment to create. This environment must not already exist.', - title='New', + tenants: Dict[str, DataGeneratorLibSchemasSchemaOpalDataTenantData] = Field( + ..., + description='Key-Value mapping of the tenants in the system.\nThe key is the tenant key and the value contains some details about the tenant.', + title='Tenants', + ) + roles: Dict[str, DataGeneratorLibSchemasSchemaOpalDataRoleData] = Field( + ..., + description='Key-Value mapping of the roles in the system.\nThe key is the role key and the value contains some details about the role.', + title='Roles', + ) + condition_set_rules: Dict[str, Dict[str, Dict[str, List[str]]]] = Field( + ..., + description='Key-Value mapping of the permissions for each condition set.\nThe key is the user-set key and the value is Key-Value mapping of resource-set key to the permissions for that user-set & resource-set.The key is the resource key and the value is list of actions that the user-set can perform on that resource-set', + title='Condition Set Rules', + ) + condition_set_rules_expand: Optional[Dict[str, Dict[str, Dict[str, List[str]]]]] = ( + Field( + {}, + description='Sanitized Key-Value mapping of the permissions for each condition set.\n(Equal to condition_set_rules but user_set_key and resource_set_key are sanitized)The key is the user-set key and the value is Key-Value mapping of resource-set key to the permissions for that user-set & resource-set.The key is the resource key and the value is list of actions that the user-set can perform on that resource-set', + title='Condition Set Rules Expand', + ) + ) + relationships: Dict[str, Dict[str, Dict[str, List[str]]]] = Field( + ..., + description='Key-Value mapping of the relationships between resources.\nThe key is the resource instance key and the value is Key-Value mapping of relation key to a Key-Value mapping of resource ( type ) to list of instances keys.', + title='Relationships', + ) + resource_types: Dict[str, DataGeneratorLibSchemasSchemaOpalDataResourceTypeData] = ( + Field( + ..., + description='Key-Value mapping of the resource types in the system.\nThe key is the resource type key and the value contains some details about the resource type.', + title='Resource Types', + ) + ) + condition_sets: Dict[str, DataGeneratorLibSchemasSchemaOpalDataConditionSetData] = ( + Field( + ..., + description='Key-Value mapping of the condition sets in the system.\nThe key is the formatted condition set key and the value contains some details about the condition set.', + title='Condition Sets', + ) + ) + role_assignments: Dict[str, Dict[str, List[str]]] = Field( + ..., + description='Key-Value mapping of the role assignments for the users.\nThe key is the user key and the value is Key-Value mapping of resource instance key or tenant key to list of role keys assigned to the user in that resource instance.', + title='Role Assignments', + ) + role_permissions: Dict[ + str, Dict[str, DataGeneratorLibSchemasSchemaOpalDataRoleData] + ] = Field( + ..., + description='Key-Value mapping of the permissions for each role.\nThe key is the resource key and the value is Key-Value mapping of role key to details on the role permissions.', + title='Role Permissions', + ) + mapping_rules: Optional[Dict[str, List[Dict[str, Union[str, int]]]]] = Field( + {}, + description="Key-Value mapping of groups of mapping rules in the system.\nThe key is the mapping rule group and the value is a list of mapping rules objects.We currently have only one group named 'all' which contains all the mapping rules.A mapping rule object contains, action, http_method, resource and url - all strings.", + title='Mapping Rules', + ) + resource_instances: Optional[ + Dict[str, DataGeneratorLibSchemasSchemaOpalDataResourceInstanceAttributeData] + ] = Field( + {}, + description='Key-Value mapping of the resource instances in the system.\nThe key is the resource instance key and the value contains some details about the resource instance.', + title='Resource Instances', ) -class FullData(BaseModel): +class PermitBackendSchemasSchemaOpalDataFullData(BaseModel): class Config: extra = Extra.allow use_debugger: Optional[bool] = Field(True, title='Use Debugger') - users: Dict[str, UserData] = Field( + users: Dict[str, PermitBackendSchemasSchemaOpalDataUserData] = Field( ..., description='Key-Value mapping of the users in the system.\nThe key is the user key and the value contains some details about the user.', title='Users', ) - tenants: Dict[str, TenantData] = Field( + tenants: Dict[str, PermitBackendSchemasSchemaOpalDataTenantData] = Field( ..., description='Key-Value mapping of the tenants in the system.\nThe key is the tenant key and the value contains some details about the tenant.', title='Tenants', ) - roles: Dict[str, RoleData] = Field( + roles: Dict[str, PermitBackendSchemasSchemaOpalDataRoleData] = Field( ..., description='Key-Value mapping of the roles in the system.\nThe key is the role key and the value contains some details about the role.', title='Roles', @@ -6064,27 +6756,40 @@ class Config: description='Key-Value mapping of the permissions for each condition set.\nThe key is the user-set key and the value is Key-Value mapping of resource-set key to the permissions for that user-set & resource-set.The key is the resource key and the value is list of actions that the user-set can perform on that resource-set', title='Condition Set Rules', ) + condition_set_rules_expand: Optional[Dict[str, Dict[str, Dict[str, List[str]]]]] = ( + Field( + {}, + description='Sanitized Key-Value mapping of the permissions for each condition set.\n(Equal to condition_set_rules but user_set_key and resource_set_key are sanitized)The key is the user-set key and the value is Key-Value mapping of resource-set key to the permissions for that user-set & resource-set.The key is the resource key and the value is list of actions that the user-set can perform on that resource-set', + title='Condition Set Rules Expand', + ) + ) relationships: Dict[str, Dict[str, Dict[str, List[str]]]] = Field( ..., description='Key-Value mapping of the relationships between resources.\nThe key is the resource instance key and the value is Key-Value mapping of relation key to a Key-Value mapping of resource ( type ) to list of instances keys.', title='Relationships', ) - resource_types: Dict[str, ResourceTypeData] = Field( - ..., - description='Key-Value mapping of the resource types in the system.\nThe key is the resource type key and the value contains some details about the resource type.', - title='Resource Types', + resource_types: Dict[str, PermitBackendSchemasSchemaOpalDataResourceTypeData] = ( + Field( + ..., + description='Key-Value mapping of the resource types in the system.\nThe key is the resource type key and the value contains some details about the resource type.', + title='Resource Types', + ) ) - condition_sets: Dict[str, ConditionSetData] = Field( - ..., - description='Key-Value mapping of the condition sets in the system.\nThe key is the formatted condition set key and the value contains some details about the condition set.', - title='Condition Sets', + condition_sets: Dict[str, PermitBackendSchemasSchemaOpalDataConditionSetData] = ( + Field( + ..., + description='Key-Value mapping of the condition sets in the system.\nThe key is the formatted condition set key and the value contains some details about the condition set.', + title='Condition Sets', + ) ) role_assignments: Dict[str, Dict[str, List[str]]] = Field( ..., description='Key-Value mapping of the role assignments for the users.\nThe key is the user key and the value is Key-Value mapping of resource instance key or tenant key to list of role keys assigned to the user in that resource instance.', title='Role Assignments', ) - role_permissions: Dict[str, Dict[str, RoleData]] = Field( + role_permissions: Dict[ + str, Dict[str, PermitBackendSchemasSchemaOpalDataRoleData] + ] = Field( ..., description='Key-Value mapping of the permissions for each role.\nThe key is the resource key and the value is Key-Value mapping of role key to details on the role permissions.', title='Role Permissions', @@ -6094,13 +6799,51 @@ class Config: description="Key-Value mapping of groups of mapping rules in the system.\nThe key is the mapping rule group and the value is a list of mapping rules objects.We currently have only one group named 'all' which contains all the mapping rules.A mapping rule object contains, action, http_method, resource and url - all strings.", title='Mapping Rules', ) - resource_instances: Optional[Dict[str, ResourceInstanceAttributeData]] = Field( + resource_instances: Optional[ + Dict[str, PermitBackendSchemasSchemaOpalDataResourceInstanceAttributeData] + ] = Field( {}, description='Key-Value mapping of the resource instances in the system.\nThe key is the resource instance key and the value contains some details about the resource instance.', title='Resource Instances', ) +class APIKeyRead(BaseModel): + class Config: + extra = Extra.allow + + organization_id: UUID = Field(..., title='Organization Id') + project_id: Optional[UUID] = Field(None, title='Project Id') + environment_id: Optional[UUID] = Field(None, title='Environment Id') + object_type: Optional[MemberAccessObj] = 'env' + access_level: Optional[MemberAccessLevel] = 'admin' + owner_type: APIKeyOwnerType + name: Optional[str] = Field(None, title='Name') + id: UUID = Field(..., title='Id') + secret: Optional[str] = Field(None, title='Secret') + created_at: datetime = Field(..., title='Created At') + created_by_member: Optional[OrgMemberRead] = None + last_used_at: Optional[datetime] = Field(None, title='Last Used At') + env: Optional[EnvironmentRead] = None + project: Optional[ProjectRead] = None + + +class EnvironmentCopyTarget(BaseModel): + class Config: + extra = Extra.allow + + existing: Optional[str] = Field( + None, + description='Identifier of an existing environment to copy into', + title='Existing', + ) + new: Optional[EnvironmentCreate] = Field( + None, + description='Description of the environment to create. This environment must not already exist.', + title='New', + ) + + class PaginatedResultAPIKeyRead(BaseModel): class Config: extra = Extra.allow From 95b032ea4b5efaad84b356b4bab3323d1d6bb62c Mon Sep 17 00:00:00 2001 From: Shuvy Date: Sun, 21 Sep 2025 12:14:51 +0300 Subject: [PATCH 2/5] add list, create, get, delete api in user+invite --- permit/api/base.py | 6 +- permit/api/models.py | 23 +- permit/api/user_invites.py | 85 ++++++ tests/test_user_invites_complete_e2e.py | 359 ++++++++++++++++++++++++ 4 files changed, 463 insertions(+), 10 deletions(-) create mode 100644 tests/test_user_invites_complete_e2e.py diff --git a/permit/api/base.py b/permit/api/base.py index faaabc9..2c3ca3b 100644 --- a/permit/api/base.py +++ b/permit/api/base.py @@ -62,7 +62,11 @@ def _prepare_json(self, json: Optional[Union[TData, dict, list]] = None) -> Opti if isinstance(json, list): return [self._prepare_json(item) for item in json] - return json.dict(exclude_unset=True, exclude_none=True) + # Use json() method which properly handles UUID serialization + # Then parse it back to dict to avoid double JSON encoding + import json as json_module + json_str = json.json(exclude_unset=True, exclude_none=True) + return json_module.loads(json_str) @handle_client_error async def get(self, url, model: Type[TModel], **kwargs) -> TModel: diff --git a/permit/api/models.py b/permit/api/models.py index 13f26df..414624a 100644 --- a/permit/api/models.py +++ b/permit/api/models.py @@ -9,7 +9,12 @@ from typing import Any, Dict, List, Literal, Optional, Union from uuid import UUID -from pydantic import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr +from ..utils.pydantic_version import PYDANTIC_VERSION + +if PYDANTIC_VERSION < (2, 0): + from pydantic import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr +else: + from pydantic.v1 import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr # type: ignore class APIHistoryEventFullRead(BaseModel): @@ -3974,20 +3979,20 @@ class Config: description='Date and time when the elements_user_invite was last updated/modified (ISO_8601 format).', title='Updated At', ) - key: constr(regex=r'^[A-Za-z0-9|@+\-\._]+$') = Field( - ..., description='The key of the user that is being invited', title='Key' + key: Optional[constr(regex=r'^[A-Za-z0-9|@+\-\._]+$')] = Field( + None, description='The key of the user that is being invited', title='Key' ) status: UserInviteStatus = Field(..., description='The status of the user invite') email: EmailStr = Field( ..., description='The email of the user that being invited', title='Email' ) - first_name: str = Field( - ..., + first_name: Optional[str] = Field( + None, description='The first name of the user that is being invited', title='First Name', ) - last_name: str = Field( - ..., + last_name: Optional[str] = Field( + None, description='The last name of the user that is being invited', title='Last Name', ) @@ -3999,8 +4004,8 @@ class Config: description='The tenant id of the user that is being invited', title='Tenant Id', ) - resource_instance_id: UUID = Field( - ..., + resource_instance_id: Optional[UUID] = Field( + None, description='The resource instance id of the user that is being invited', title='Resource Instance Id', ) diff --git a/permit/api/user_invites.py b/permit/api/user_invites.py index 57b696d..4a08fa6 100644 --- a/permit/api/user_invites.py +++ b/permit/api/user_invites.py @@ -8,10 +8,14 @@ from .base import ( BasePermitApi, SimpleHttpClient, + pagination_params, ) from .context import ApiContextLevel, ApiKeyAccessLevel from .models import ( ElementsUserInviteApprove, + ElementsUserInviteCreate, + ElementsUserInviteRead, + PaginatedResultElementsUserInviteRead, UserRead, ) @@ -23,6 +27,87 @@ def __user_invites(self) -> SimpleHttpClient: f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/user_invites" ) + @validate_arguments # type: ignore[operator] + async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultElementsUserInviteRead: + """ + Retrieves a list of user invites. + + Args: + page: The page number to retrieve (default: 1). + per_page: The number of invites per page (default: 100). + + Returns: + A paginated list of user invites. + + 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) + return await self.__user_invites.get( + "", + model=PaginatedResultElementsUserInviteRead, + params=pagination_params(page, per_page), + ) + + @validate_arguments # type: ignore[operator] + async def get(self, user_invite_id: str) -> ElementsUserInviteRead: + """ + Retrieves a single user invite by ID. + + Args: + user_invite_id: The ID of the user invite to retrieve. + + Returns: + The user invite details. + + 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) + return await self.__user_invites.get(f"/{user_invite_id}", model=ElementsUserInviteRead) + + @validate_arguments # type: ignore[operator] + async def create(self, user_invite_data: ElementsUserInviteCreate) -> ElementsUserInviteRead: + """ + Creates a new user invite. + + Args: + user_invite_data: The user invite data to create. + + Returns: + The created user invite. + + 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) + return await self.__user_invites.post("", model=ElementsUserInviteRead, json=user_invite_data) + + @validate_arguments # type: ignore[operator] + async def delete(self, user_invite_id: str) -> None: + """ + Deletes a user invite. + + Args: + user_invite_id: The ID of the user invite to delete. + + Returns: + None + + 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) + await self.__user_invites.delete(f"/{user_invite_id}") + @validate_arguments # type: ignore[operator] async def approve(self, user_invite_id: str, approve_data: ElementsUserInviteApprove) -> UserRead: """ diff --git a/tests/test_user_invites_complete_e2e.py b/tests/test_user_invites_complete_e2e.py new file mode 100644 index 0000000..c02fb80 --- /dev/null +++ b/tests/test_user_invites_complete_e2e.py @@ -0,0 +1,359 @@ +import asyncio +from uuid import uuid4 + +import pytest +from loguru import logger + +from permit import Permit +from permit.api.models import ( + ElementsUserInviteApprove, + ElementsUserInviteCreate, + TenantCreate, + RoleCreate, + UserInviteStatus, + ResourceCreate, + ResourceInstanceCreate, + ActionBlockEditable, +) +from permit.exceptions import PermitApiError, PermitConnectionError + +from .utils import handle_api_error + + +def print_break(): + print("\n\n ----------- \n\n") # noqa: T201 + + +# Test data +TEST_TENANT = TenantCreate(key="test_tenant_invites", name="Test Tenant for Invites") + +# Test user invites data (will be populated with actual IDs in the test) +TEST_INVITE_DATA_1 = { + "key": "testuser1_complete@example.com", + "status": UserInviteStatus.pending, + "email": "testuser1_complete@example.com", + "first_name": "Test", + "last_name": "User1", +} + +TEST_INVITE_DATA_2 = { + "key": "testuser2_complete@example.com", + "status": UserInviteStatus.pending, + "email": "testuser2_complete@example.com", + "first_name": "Test", + "last_name": "User2", +} + +# Store created objects for cleanup +created_invites = [] +created_tenant = None +created_role = None +created_resource = None +created_resource_instance = None + + +@pytest.mark.asyncio +async def test_user_invites_complete_e2e(permit: Permit): + """ + Complete end-to-end test for User Invites API functionality. + + Tests the complete lifecycle: + 1. Setup (create resource, tenant, resource instance, role) + 2. Create user invites + 3. List user invites + 4. Get single user invite + 5. Approve user invite + 6. Delete user invite + 7. Cleanup + """ + global created_invites, created_tenant, created_role, created_resource, created_resource_instance + + logger.info("Starting User Invites Complete E2E test") + + try: + # ========================================== + # SETUP: Create necessary resources + # ========================================== + logger.info("Setting up test environment") + + # Create test resource with proper actions format + test_resource = ResourceCreate( + key="test_resource_invites", + name="Test Resource for Invites", + description="Resource for testing user invites", + actions={ + "read": ActionBlockEditable( + name="Read Access", + description="Read access to the resource" + ), + "write": ActionBlockEditable( + name="Write Access", + description="Write access to the resource" + ) + } + ) + created_resource = await permit.api.resources.create(test_resource) + assert created_resource is not None + assert created_resource.key == test_resource.key + logger.info(f"Created test resource: {created_resource.key}") + + # Create test tenant + created_tenant = await permit.api.tenants.create(TEST_TENANT) + assert created_tenant is not None + assert created_tenant.key == TEST_TENANT.key + assert created_tenant.name == TEST_TENANT.name + logger.info(f"Created test tenant: {created_tenant.key}") + + # Create test resource instance + test_resource_instance = ResourceInstanceCreate( + key="test_instance_invites", + resource=created_resource.key, + tenant=created_tenant.key, + attributes={"test": "invites"} + ) + created_resource_instance = await permit.api.resource_instances.create(test_resource_instance) + assert created_resource_instance is not None + assert created_resource_instance.key == test_resource_instance.key + logger.info(f"Created test resource instance: {created_resource_instance.key}") + + # Create test role with permissions that match our resource actions + test_role = RoleCreate( + key="test_role_invites", + name="Test Role for Invites", + permissions=[f"{created_resource.key}:read", f"{created_resource.key}:write"] # Use our resource actions + ) + created_role = await permit.api.roles.create(test_role) + assert created_role is not None + assert created_role.key == test_role.key + assert created_role.name == test_role.name + logger.info(f"Created test role: {created_role.key}") + + # Create invite objects with actual IDs - CONVERT UUIDs TO STRINGS + TEST_INVITE_1 = ElementsUserInviteCreate( + **TEST_INVITE_DATA_1, + role_id=str(created_role.id), # Convert UUID to string + tenant_id=str(created_tenant.id), # Convert UUID to string + resource_instance_id=str(created_resource_instance.id), # Convert UUID to string + ) + + TEST_INVITE_2 = ElementsUserInviteCreate( + **TEST_INVITE_DATA_2, + role_id=str(created_role.id), # Convert UUID to string + tenant_id=str(created_tenant.id), # Convert UUID to string + resource_instance_id=str(created_resource_instance.id), # Convert UUID to string + ) + + print_break() + + # ========================================== + # TEST 1: Create User Invites + # ========================================== + logger.info("Testing user invite creation") + + # Create first invite + invite_1 = await permit.api.user_invites.create(TEST_INVITE_1) + created_invites.append(invite_1) + + # Verify create output + assert invite_1 is not None + assert invite_1.id is not None + assert invite_1.key == TEST_INVITE_1.key + assert invite_1.email == TEST_INVITE_1.email + assert invite_1.first_name == TEST_INVITE_1.first_name + assert invite_1.last_name == TEST_INVITE_1.last_name + assert invite_1.status == UserInviteStatus.pending + assert invite_1.role_id == created_role.id + assert invite_1.tenant_id == created_tenant.id + logger.info(f"✅ Created invite 1: {invite_1.email} (ID: {invite_1.id})") + + # Create second invite + invite_2 = await permit.api.user_invites.create(TEST_INVITE_2) + created_invites.append(invite_2) + + # Verify second invite + assert invite_2 is not None + assert invite_2.id is not None + assert invite_2.key == TEST_INVITE_2.key + assert invite_2.email == TEST_INVITE_2.email + assert invite_2.status == UserInviteStatus.pending + logger.info(f"✅ Created invite 2: {invite_2.email} (ID: {invite_2.id})") + + print_break() + + # ========================================== + # TEST 2: List User Invites + # ========================================== + logger.info("Testing user invite listing") + + invites_list = await permit.api.user_invites.list(page=1, per_page=50) + assert invites_list is not None + assert invites_list.data is not None + assert invites_list.total_count >= 2 # At least our 2 invites + + # Find our created invites in the list + our_invites = [ + invite for invite in invites_list.data + if invite.id in [invite_1.id, invite_2.id] + ] + assert len(our_invites) == 2 + logger.info(f"✅ Listed invites: found {invites_list.total_count} total, including our 2 test invites") + + print_break() + + # ========================================== + # TEST 3: Get Single User Invite + # ========================================== + logger.info("Testing single user invite retrieval") + + retrieved_invite = await permit.api.user_invites.get(str(invite_1.id)) + assert retrieved_invite is not None + assert retrieved_invite.id == invite_1.id + assert retrieved_invite.email == invite_1.email + assert retrieved_invite.key == invite_1.key + assert retrieved_invite.status == UserInviteStatus.pending + logger.info(f"✅ Retrieved invite: {retrieved_invite.email} (Status: {retrieved_invite.status})") + + print_break() + + # ========================================== + # TEST 4: Approve User Invite + # ========================================== + logger.info("Testing user invite approval") + + approve_data = ElementsUserInviteApprove( + email=invite_1.email, + key=invite_1.key, + attributes={"department": "Engineering", "role": "Developer", "test": "complete_e2e_test"} + ) + + approved_user = await permit.api.user_invites.approve( + user_invite_id=str(invite_1.id), + approve_data=approve_data + ) + + # Verify approval + assert approved_user is not None + assert approved_user.email == invite_1.email + assert approved_user.id is not None + assert approved_user.attributes is not None + assert approved_user.attributes.get("department") == "Engineering" + assert approved_user.attributes.get("role") == "Developer" + assert approved_user.attributes.get("test") == "complete_e2e_test" + logger.info(f"✅ Approved invite: {approved_user.email} (User ID: {approved_user.id})") + + print_break() + + # ========================================== + # TEST 5: Delete User Invite + # ========================================== + logger.info("Testing user invite deletion") + + # Delete the second invite + await permit.api.user_invites.delete(str(invite_2.id)) + logger.info(f"✅ Deleted invite: {invite_2.email}") + + # Verify deletion - trying to get the deleted invite should fail + try: + await permit.api.user_invites.get(str(invite_2.id)) + pytest.fail("Expected invite to be deleted, but it still exists") + except PermitApiError as e: + # Expected - invite should not be found + assert e.status_code in [404, 403] # Not found or forbidden + logger.info("✅ Confirmed: Invite successfully deleted (not found)") + + # Remove from our tracking list since it's deleted + created_invites = [inv for inv in created_invites if inv.id != invite_2.id] + + print_break() + + # ========================================== + # TEST 6: Verify Final State + # ========================================== + logger.info("Verifying final state") + + # List invites again to verify our changes + final_invites_list = await permit.api.user_invites.list(page=1, per_page=50) + our_remaining_invites = [ + invite for invite in final_invites_list.data + if invite.id == invite_1.id # Only invite_1 should remain + ] + + # Should have 1 invite remaining (invite_1 which was approved) + # Note: approved invites might still be in the list or might be removed depending on API behavior + logger.info(f"✅ Final verification: {len(our_remaining_invites)} of our test invites remain in the list") + + logger.info("🎉 All User Invites Complete E2E tests passed successfully!") + + except PermitApiError as error: + handle_api_error(error, "Got API Error during User Invites Complete E2E test") + except PermitConnectionError: + raise + except Exception as error: # noqa: BLE001 + logger.error(f"Got error during User Invites Complete E2E test: {error}") + pytest.fail(f"Got error during User Invites Complete E2E test: {error}") + finally: + # ========================================== + # CLEANUP + # ========================================== + logger.info("Starting cleanup") + try: + # Delete remaining user invites + for invite in created_invites: + try: + await permit.api.user_invites.delete(str(invite.id)) + logger.info(f"Cleaned up invite: {invite.email}") + except PermitApiError as e: + if e.status_code not in [404, 403]: # Ignore if already deleted + logger.warning(f"Failed to delete invite {invite.email}: {e}") + + # Delete test role + if created_role: + try: + await permit.api.roles.delete(created_role.key) + logger.info(f"Cleaned up role: {created_role.key}") + except PermitApiError as e: + if e.status_code != 404: # Ignore if already deleted + logger.warning(f"Failed to delete role {created_role.key}: {e}") + + # Delete test tenant + if created_tenant: + try: + await permit.api.tenants.delete(created_tenant.key) + logger.info(f"Cleaned up tenant: {created_tenant.key}") + except PermitApiError as e: + if e.status_code != 404: # Ignore if already deleted + logger.warning(f"Failed to delete tenant {created_tenant.key}: {e}") + + # Delete test resource instance + if created_resource_instance: + try: + await permit.api.resource_instances.delete(created_resource_instance.key) + logger.info(f"Cleaned up resource instance: {created_resource_instance.key}") + except PermitApiError as e: + if e.status_code != 404: # Ignore if already deleted + logger.warning(f"Failed to delete resource instance {created_resource_instance.key}: {e}") + + # Delete test resource + if created_resource: + try: + await permit.api.resources.delete(created_resource.key) + logger.info(f"Cleaned up resource: {created_resource.key}") + except PermitApiError as e: + if e.status_code != 404: # Ignore if already deleted + logger.warning(f"Failed to delete resource {created_resource.key}: {e}") + + logger.info("✅ Cleanup completed") + + except PermitApiError as error: + handle_api_error(error, "Got API Error during cleanup") + except PermitConnectionError: + raise + except Exception as error: # noqa: BLE001 + logger.error(f"Got error during cleanup: {error}") + # Don't fail the test due to cleanup errors, just log them + logger.warning("Cleanup failed, but test results are still valid") + + +if __name__ == "__main__": + # For running the test directly + pytest.main([__file__, "-v", "-s"]) From 0d54d8bd8c1abe8ff884c2c29f1e18695e4ab798 Mon Sep 17 00:00:00 2001 From: Shuvy Date: Sun, 21 Sep 2025 12:17:26 +0300 Subject: [PATCH 3/5] Fix pre commit --- permit/api/base.py | 1 + tests/test_user_invites_complete_e2e.py | 60 ++++++++++--------------- 2 files changed, 25 insertions(+), 36 deletions(-) diff --git a/permit/api/base.py b/permit/api/base.py index 2c3ca3b..e6a5abb 100644 --- a/permit/api/base.py +++ b/permit/api/base.py @@ -65,6 +65,7 @@ def _prepare_json(self, json: Optional[Union[TData, dict, list]] = None) -> Opti # Use json() method which properly handles UUID serialization # Then parse it back to dict to avoid double JSON encoding import json as json_module + json_str = json.json(exclude_unset=True, exclude_none=True) return json_module.loads(json_str) diff --git a/tests/test_user_invites_complete_e2e.py b/tests/test_user_invites_complete_e2e.py index c02fb80..32f0479 100644 --- a/tests/test_user_invites_complete_e2e.py +++ b/tests/test_user_invites_complete_e2e.py @@ -1,19 +1,16 @@ -import asyncio -from uuid import uuid4 - import pytest from loguru import logger from permit import Permit from permit.api.models import ( + ActionBlockEditable, ElementsUserInviteApprove, ElementsUserInviteCreate, - TenantCreate, - RoleCreate, - UserInviteStatus, ResourceCreate, ResourceInstanceCreate, - ActionBlockEditable, + RoleCreate, + TenantCreate, + UserInviteStatus, ) from permit.exceptions import PermitApiError, PermitConnectionError @@ -82,15 +79,9 @@ async def test_user_invites_complete_e2e(permit: Permit): name="Test Resource for Invites", description="Resource for testing user invites", actions={ - "read": ActionBlockEditable( - name="Read Access", - description="Read access to the resource" - ), - "write": ActionBlockEditable( - name="Write Access", - description="Write access to the resource" - ) - } + "read": ActionBlockEditable(name="Read Access", description="Read access to the resource"), + "write": ActionBlockEditable(name="Write Access", description="Write access to the resource"), + }, ) created_resource = await permit.api.resources.create(test_resource) assert created_resource is not None @@ -109,7 +100,7 @@ async def test_user_invites_complete_e2e(permit: Permit): key="test_instance_invites", resource=created_resource.key, tenant=created_tenant.key, - attributes={"test": "invites"} + attributes={"test": "invites"}, ) created_resource_instance = await permit.api.resource_instances.create(test_resource_instance) assert created_resource_instance is not None @@ -120,7 +111,7 @@ async def test_user_invites_complete_e2e(permit: Permit): test_role = RoleCreate( key="test_role_invites", name="Test Role for Invites", - permissions=[f"{created_resource.key}:read", f"{created_resource.key}:write"] # Use our resource actions + permissions=[f"{created_resource.key}:read", f"{created_resource.key}:write"], # Use our resource actions ) created_role = await permit.api.roles.create(test_role) assert created_role is not None @@ -129,14 +120,14 @@ async def test_user_invites_complete_e2e(permit: Permit): logger.info(f"Created test role: {created_role.key}") # Create invite objects with actual IDs - CONVERT UUIDs TO STRINGS - TEST_INVITE_1 = ElementsUserInviteCreate( + test_invite_1 = ElementsUserInviteCreate( **TEST_INVITE_DATA_1, role_id=str(created_role.id), # Convert UUID to string tenant_id=str(created_tenant.id), # Convert UUID to string resource_instance_id=str(created_resource_instance.id), # Convert UUID to string ) - TEST_INVITE_2 = ElementsUserInviteCreate( + test_invite_2 = ElementsUserInviteCreate( **TEST_INVITE_DATA_2, role_id=str(created_role.id), # Convert UUID to string tenant_id=str(created_tenant.id), # Convert UUID to string @@ -151,30 +142,30 @@ async def test_user_invites_complete_e2e(permit: Permit): logger.info("Testing user invite creation") # Create first invite - invite_1 = await permit.api.user_invites.create(TEST_INVITE_1) + invite_1 = await permit.api.user_invites.create(test_invite_1) created_invites.append(invite_1) # Verify create output assert invite_1 is not None assert invite_1.id is not None - assert invite_1.key == TEST_INVITE_1.key - assert invite_1.email == TEST_INVITE_1.email - assert invite_1.first_name == TEST_INVITE_1.first_name - assert invite_1.last_name == TEST_INVITE_1.last_name + assert invite_1.key == test_invite_1.key + assert invite_1.email == test_invite_1.email + assert invite_1.first_name == test_invite_1.first_name + assert invite_1.last_name == test_invite_1.last_name assert invite_1.status == UserInviteStatus.pending assert invite_1.role_id == created_role.id assert invite_1.tenant_id == created_tenant.id logger.info(f"✅ Created invite 1: {invite_1.email} (ID: {invite_1.id})") # Create second invite - invite_2 = await permit.api.user_invites.create(TEST_INVITE_2) + invite_2 = await permit.api.user_invites.create(test_invite_2) created_invites.append(invite_2) # Verify second invite assert invite_2 is not None assert invite_2.id is not None - assert invite_2.key == TEST_INVITE_2.key - assert invite_2.email == TEST_INVITE_2.email + assert invite_2.key == test_invite_2.key + assert invite_2.email == test_invite_2.email assert invite_2.status == UserInviteStatus.pending logger.info(f"✅ Created invite 2: {invite_2.email} (ID: {invite_2.id})") @@ -191,10 +182,7 @@ async def test_user_invites_complete_e2e(permit: Permit): assert invites_list.total_count >= 2 # At least our 2 invites # Find our created invites in the list - our_invites = [ - invite for invite in invites_list.data - if invite.id in [invite_1.id, invite_2.id] - ] + our_invites = [invite for invite in invites_list.data if invite.id in [invite_1.id, invite_2.id]] assert len(our_invites) == 2 logger.info(f"✅ Listed invites: found {invites_list.total_count} total, including our 2 test invites") @@ -223,12 +211,11 @@ async def test_user_invites_complete_e2e(permit: Permit): approve_data = ElementsUserInviteApprove( email=invite_1.email, key=invite_1.key, - attributes={"department": "Engineering", "role": "Developer", "test": "complete_e2e_test"} + attributes={"department": "Engineering", "role": "Developer", "test": "complete_e2e_test"}, ) approved_user = await permit.api.user_invites.approve( - user_invite_id=str(invite_1.id), - approve_data=approve_data + user_invite_id=str(invite_1.id), approve_data=approve_data ) # Verify approval @@ -274,7 +261,8 @@ async def test_user_invites_complete_e2e(permit: Permit): # List invites again to verify our changes final_invites_list = await permit.api.user_invites.list(page=1, per_page=50) our_remaining_invites = [ - invite for invite in final_invites_list.data + invite + for invite in final_invites_list.data if invite.id == invite_1.id # Only invite_1 should remain ] From 8fb047eadd7f1c5c3f7d698201f46f79b91503e9 Mon Sep 17 00:00:00 2001 From: Shuvy Date: Sun, 21 Sep 2025 14:29:04 +0300 Subject: [PATCH 4/5] Fix test and add setup for test --- permit/api/base.py | 8 +- permit/api/encoders.py | 263 ++++++++++++++++++++++ tests/test_user_invites_complete_e2e.py | 284 ++++++++++++------------ 3 files changed, 412 insertions(+), 143 deletions(-) create mode 100644 permit/api/encoders.py diff --git a/permit/api/base.py b/permit/api/base.py index e6a5abb..25b257e 100644 --- a/permit/api/base.py +++ b/permit/api/base.py @@ -5,6 +5,7 @@ from loguru import logger from ..utils.pydantic_version import PYDANTIC_VERSION +from .encoders import jsonable_encoder if PYDANTIC_VERSION < (2, 0): from pydantic import BaseModel, Extra, Field, parse_obj_as @@ -62,12 +63,7 @@ def _prepare_json(self, json: Optional[Union[TData, dict, list]] = None) -> Opti if isinstance(json, list): return [self._prepare_json(item) for item in json] - # Use json() method which properly handles UUID serialization - # Then parse it back to dict to avoid double JSON encoding - import json as json_module - - json_str = json.json(exclude_unset=True, exclude_none=True) - return json_module.loads(json_str) + return jsonable_encoder(json, exclude_unset=True, exclude_none=True) @handle_client_error async def get(self, url, model: Type[TModel], **kwargs) -> TModel: diff --git a/permit/api/encoders.py b/permit/api/encoders.py new file mode 100644 index 0000000..9278646 --- /dev/null +++ b/permit/api/encoders.py @@ -0,0 +1,263 @@ +# copied from fastapi.encoders +import dataclasses +import datetime +from collections import defaultdict, deque +from decimal import Decimal +from enum import Enum +from ipaddress import ( + IPv4Address, + IPv4Interface, + IPv4Network, + IPv6Address, + IPv6Interface, + IPv6Network, +) +from pathlib import Path, PurePath +from re import Pattern +from types import GeneratorType +from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union +from uuid import UUID + +from permit import PYDANTIC_VERSION + +if PYDANTIC_VERSION < (2, 0): + from pydantic import BaseModel + from pydantic.color import Color + from pydantic.networks import AnyUrl, NameEmail + from pydantic.types import SecretBytes, SecretStr + + def _model_dump(model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any) -> Any: + return model.model_dump(mode=mode, **kwargs) +else: + from pydantic.v1 import BaseModel # type: ignore[assignment] + from pydantic.v1.color import Color # type: ignore[assignment] + from pydantic.v1.networks import AnyUrl, NameEmail # type: ignore[assignment] + from pydantic.v1.types import SecretBytes, SecretStr # type: ignore[assignment] + + def _model_dump(model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any) -> Any: # noqa: ARG001 + return model.dict(**kwargs) + + +def isoformat(o: Union[datetime.date, datetime.time]) -> str: + return o.isoformat() + + +def decimal_encoder(dec_value: Decimal) -> Union[int, float]: + """ + Encodes a Decimal as int of there's no exponent, otherwise float + + This is useful when we use ConstrainedDecimal to represent Numeric(x,0) + where a integer (but not int typed) is used. Encoding this as a float + results in failed round-tripping between encode and parse. + Our Id type is a prime example of this. + + >>> decimal_encoder(Decimal("1.0")) + 1.0 + + >>> decimal_encoder(Decimal("1")) + 1 + """ + if dec_value.as_tuple().exponent >= 0: # type: ignore[operator] + return int(dec_value) + else: + return float(dec_value) + + +IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any]] +ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = { + bytes: lambda o: o.decode(), + Color: str, + datetime.date: isoformat, + datetime.datetime: isoformat, + datetime.time: isoformat, + datetime.timedelta: lambda td: td.total_seconds(), + Decimal: decimal_encoder, + Enum: lambda o: o.value, + frozenset: list, + deque: list, + GeneratorType: list, + IPv4Address: str, + IPv4Interface: str, + IPv4Network: str, + IPv6Address: str, + IPv6Interface: str, + IPv6Network: str, + NameEmail: str, + Path: str, + Pattern: lambda o: o.pattern, + SecretBytes: str, + SecretStr: str, + set: list, + UUID: str, + AnyUrl: str, +} + + +def generate_encoders_by_class_tuples( + type_encoder_map: Dict[Any, Callable[[Any], Any]], +) -> Dict[Callable[[Any], Any], Tuple[Any, ...]]: + encoders_by_class_tuples: Dict[Callable[[Any], Any], Tuple[Any, ...]] = defaultdict(tuple) + for type_, encoder in type_encoder_map.items(): + encoders_by_class_tuples[encoder] += (type_,) + return encoders_by_class_tuples + + +encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) + + +def jsonable_encoder( + obj: Any, + *, + include: Optional[IncEx] = None, + exclude: Optional[IncEx] = None, + by_alias: bool = True, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None, + sqlalchemy_safe: bool = True, +) -> Any: + """ + Convert any object to something that can be encoded in JSON. + + This is used internally by FastAPI to make sure anything you return can be + encoded as JSON before it is sent to the client. + + You can also use it yourself, for example to convert objects before saving them + in a database that supports only JSON. + + Read more about it in the + [FastAPI docs for JSON Compatible Encoder](https://fastapi.tiangolo.com/tutorial/encoder/). + """ + custom_encoder = custom_encoder or {} + if custom_encoder: + if type(obj) in custom_encoder: + return custom_encoder[type(obj)](obj) + else: + for encoder_type, encoder_instance in custom_encoder.items(): + if isinstance(obj, encoder_type): + return encoder_instance(obj) + if include is not None and not isinstance(include, (set, dict)): + include = set(include) # type: ignore[unreachable] + if exclude is not None and not isinstance(exclude, (set, dict)): + exclude = set(exclude) # type: ignore[unreachable] + if isinstance(obj, BaseModel): + encoders = getattr(obj.__config__, "json_encoders", {}) # type: ignore[attr-defined] + if custom_encoder: + encoders.update(custom_encoder) + + obj_dict = _model_dump( + obj, + mode="json", + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + exclude_defaults=exclude_defaults, + ) + if "__root__" in obj_dict: + obj_dict = obj_dict["__root__"] + return jsonable_encoder( + obj_dict, + exclude_none=exclude_none, + exclude_defaults=exclude_defaults, + # TODO: remove when deprecating Pydantic v1 + custom_encoder=encoders, + sqlalchemy_safe=sqlalchemy_safe, + ) + if dataclasses.is_dataclass(obj): + obj_dict = dataclasses.asdict(obj) # type: ignore[call-overload] + return jsonable_encoder( + obj_dict, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + if isinstance(obj, Enum): + return obj.value + if isinstance(obj, PurePath): + return str(obj) + if isinstance(obj, (str, int, float, type(None))): + return obj + if isinstance(obj, dict): + encoded_dict = {} + allowed_keys = set(obj.keys()) + if include is not None: + allowed_keys &= set(include) + if exclude is not None: + allowed_keys -= set(exclude) + for key, value in obj.items(): + if ( + (not sqlalchemy_safe or (not isinstance(key, str)) or (not key.startswith("_sa"))) + and (value is not None or not exclude_none) + and key in allowed_keys + ): + encoded_key = jsonable_encoder( + key, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + encoded_value = jsonable_encoder( + value, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + encoded_dict[encoded_key] = encoded_value + return encoded_dict + if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): + encoded_list = [] + for item in obj: + encoded_list.append( + jsonable_encoder( + item, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + ) + return encoded_list + + if type(obj) in ENCODERS_BY_TYPE: + return ENCODERS_BY_TYPE[type(obj)](obj) + for encoder, classes_tuple in encoders_by_class_tuples.items(): + if isinstance(obj, classes_tuple): + return encoder(obj) + + try: + data = dict(obj) + except Exception as e: # noqa: BLE001 + errors: List[Exception] = [] + errors.append(e) + try: + data = vars(obj) + except Exception as e: + errors.append(e) + raise ValueError(errors) from e + return jsonable_encoder( + data, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) diff --git a/tests/test_user_invites_complete_e2e.py b/tests/test_user_invites_complete_e2e.py index 32f0479..da3dd15 100644 --- a/tests/test_user_invites_complete_e2e.py +++ b/tests/test_user_invites_complete_e2e.py @@ -1,5 +1,9 @@ +import uuid +from typing import List, Optional, cast + import pytest from loguru import logger +from typing_extensions import NamedTuple from permit import Permit from permit.api.models import ( @@ -8,64 +12,56 @@ ElementsUserInviteCreate, ResourceCreate, ResourceInstanceCreate, + ResourceInstanceRead, + ResourceRead, RoleCreate, + RoleRead, TenantCreate, + TenantRead, UserInviteStatus, ) -from permit.exceptions import PermitApiError, PermitConnectionError - -from .utils import handle_api_error +from permit.exceptions import PermitApiError def print_break(): print("\n\n ----------- \n\n") # noqa: T201 -# Test data -TEST_TENANT = TenantCreate(key="test_tenant_invites", name="Test Tenant for Invites") - -# Test user invites data (will be populated with actual IDs in the test) -TEST_INVITE_DATA_1 = { - "key": "testuser1_complete@example.com", - "status": UserInviteStatus.pending, - "email": "testuser1_complete@example.com", - "first_name": "Test", - "last_name": "User1", -} - -TEST_INVITE_DATA_2 = { - "key": "testuser2_complete@example.com", - "status": UserInviteStatus.pending, - "email": "testuser2_complete@example.com", - "first_name": "Test", - "last_name": "User2", -} - -# Store created objects for cleanup -created_invites = [] -created_tenant = None -created_role = None -created_resource = None -created_resource_instance = None - - -@pytest.mark.asyncio -async def test_user_invites_complete_e2e(permit: Permit): - """ - Complete end-to-end test for User Invites API functionality. - - Tests the complete lifecycle: - 1. Setup (create resource, tenant, resource instance, role) - 2. Create user invites - 3. List user invites - 4. Get single user invite - 5. Approve user invite - 6. Delete user invite - 7. Cleanup - """ - global created_invites, created_tenant, created_role, created_resource, created_resource_instance - - logger.info("Starting User Invites Complete E2E test") +class SetupUserInvites(NamedTuple): + created_resource: ResourceRead + created_resource_instance: ResourceInstanceRead + created_role: RoleRead + created_tenant: TenantRead + to_create_invites: List[ElementsUserInviteCreate] + + +@pytest.fixture(scope="function") +async def setup_user_invites(permit: Permit): + run_id = uuid.uuid4() + # Test data + test_tenant = TenantCreate(key=f"test_tenant_invites_{run_id.hex}", name="Test Tenant for Invites") + + # Test user invites data (will be populated with actual IDs in the test) + test_invite_data_1 = { + "key": f"testuser1_complete_{run_id.hex}@example.com", + "status": UserInviteStatus.pending, + "email": f"testuser1_complete_{run_id.hex}@example.com", + "first_name": "Test", + "last_name": "User1", + } + + test_invite_data_2 = { + "key": f"testuser2_complete_{run_id.hex}@example.com", + "status": UserInviteStatus.pending, + "email": f"testuser2_complete_{run_id.hex}@example.com", + "first_name": "Test", + "last_name": "User2", + } + created_role: Optional[RoleRead] = None + created_tenant: Optional[TenantRead] = None + created_resource: Optional[ResourceRead] = None + created_resource_instance: Optional[ResourceInstanceRead] = None + to_create_invites: List[ElementsUserInviteCreate] = [] try: # ========================================== @@ -75,7 +71,7 @@ async def test_user_invites_complete_e2e(permit: Permit): # Create test resource with proper actions format test_resource = ResourceCreate( - key="test_resource_invites", + key=f"test_resource_invites-{run_id.hex}", name="Test Resource for Invites", description="Resource for testing user invites", actions={ @@ -89,15 +85,15 @@ async def test_user_invites_complete_e2e(permit: Permit): logger.info(f"Created test resource: {created_resource.key}") # Create test tenant - created_tenant = await permit.api.tenants.create(TEST_TENANT) + created_tenant = await permit.api.tenants.create(test_tenant) assert created_tenant is not None - assert created_tenant.key == TEST_TENANT.key - assert created_tenant.name == TEST_TENANT.name + assert created_tenant.key == test_tenant.key + assert created_tenant.name == test_tenant.name logger.info(f"Created test tenant: {created_tenant.key}") # Create test resource instance test_resource_instance = ResourceInstanceCreate( - key="test_instance_invites", + key=f"test_instance_invites-{run_id.hex}", resource=created_resource.key, tenant=created_tenant.key, attributes={"test": "invites"}, @@ -109,7 +105,7 @@ async def test_user_invites_complete_e2e(permit: Permit): # Create test role with permissions that match our resource actions test_role = RoleCreate( - key="test_role_invites", + key=f"test_role_invites-{run_id.hex}", name="Test Role for Invites", permissions=[f"{created_resource.key}:read", f"{created_resource.key}:write"], # Use our resource actions ) @@ -121,21 +117,103 @@ async def test_user_invites_complete_e2e(permit: Permit): # Create invite objects with actual IDs - CONVERT UUIDs TO STRINGS test_invite_1 = ElementsUserInviteCreate( - **TEST_INVITE_DATA_1, + **test_invite_data_1, role_id=str(created_role.id), # Convert UUID to string tenant_id=str(created_tenant.id), # Convert UUID to string resource_instance_id=str(created_resource_instance.id), # Convert UUID to string ) test_invite_2 = ElementsUserInviteCreate( - **TEST_INVITE_DATA_2, + **test_invite_data_2, role_id=str(created_role.id), # Convert UUID to string tenant_id=str(created_tenant.id), # Convert UUID to string resource_instance_id=str(created_resource_instance.id), # Convert UUID to string ) + to_create_invites = [test_invite_1, test_invite_2] print_break() + yield SetupUserInvites( + created_resource=cast(ResourceRead, created_resource), + created_resource_instance=cast(ResourceInstanceRead, created_resource_instance), + created_role=cast(RoleRead, created_role), + created_tenant=cast(TenantRead, created_tenant), + to_create_invites=to_create_invites, + ) + finally: + # ========================================== + # CLEANUP + # ========================================== + logger.info("Starting cleanup") + try: + # Delete test role + if created_role: + try: + await permit.api.roles.delete(created_role.key) + logger.info(f"Cleaned up role: {created_role.key}") + except PermitApiError as e: + if e.status_code != 404: # Ignore if already deleted + logger.warning(f"Failed to delete role {created_role.key}: {e}") + + # Delete test tenant + if created_tenant: + try: + await permit.api.tenants.delete(created_tenant.key) + logger.info(f"Cleaned up tenant: {created_tenant.key}") + except PermitApiError as e: + if e.status_code != 404: # Ignore if already deleted + logger.warning(f"Failed to delete tenant {created_tenant.key}: {e}") + + # Delete test resource instance + if created_resource_instance: + try: + await permit.api.resource_instances.delete(created_resource_instance.key) + logger.info(f"Cleaned up resource instance: {created_resource_instance.key}") + except PermitApiError as e: + if e.status_code != 404: # Ignore if already deleted + logger.warning(f"Failed to delete resource instance {created_resource_instance.key}: {e}") + + # Delete test resource + if created_resource: + try: + await permit.api.resources.delete(created_resource.key) + logger.info(f"Cleaned up resource: {created_resource.key}") + except PermitApiError as e: + if e.status_code != 404: # Ignore if already deleted + logger.warning(f"Failed to delete resource {created_resource.key}: {e}") + + logger.info("✅ Cleanup completed") + except Exception as e: + logger.error(f"Got error during cleanup: {e}") + logger.warning("Cleanup failed, but test results are still valid") + raise + +@pytest.mark.asyncio +async def test_user_invites_complete_e2e( + permit: Permit, + setup_user_invites: SetupUserInvites, +): + """ + Complete end-to-end test for User Invites API functionality. + + Tests the complete lifecycle: + 1. Setup (create resource, tenant, resource instance, role) + 2. Create user invites + 3. List user invites + 4. Get single user invite + 5. Approve user invite + 6. Delete user invite + 7. Cleanup + """ + + logger.info("Starting User Invites Complete E2E test") + + created_role = setup_user_invites.created_role + created_tenant = setup_user_invites.created_tenant + test_invite_1 = setup_user_invites.to_create_invites[0] + test_invite_2 = setup_user_invites.to_create_invites[1] + created_invites = [] + try: # ========================================== # TEST 1: Create User Invites # ========================================== @@ -260,88 +338,20 @@ async def test_user_invites_complete_e2e(permit: Permit): # List invites again to verify our changes final_invites_list = await permit.api.user_invites.list(page=1, per_page=50) - our_remaining_invites = [ - invite - for invite in final_invites_list.data - if invite.id == invite_1.id # Only invite_1 should remain - ] + assert len(final_invites_list.data) == 1 + assert final_invites_list.data[0].id == invite_1.id # Should have 1 invite remaining (invite_1 which was approved) # Note: approved invites might still be in the list or might be removed depending on API behavior - logger.info(f"✅ Final verification: {len(our_remaining_invites)} of our test invites remain in the list") - - logger.info("🎉 All User Invites Complete E2E tests passed successfully!") - - except PermitApiError as error: - handle_api_error(error, "Got API Error during User Invites Complete E2E test") - except PermitConnectionError: - raise - except Exception as error: # noqa: BLE001 - logger.error(f"Got error during User Invites Complete E2E test: {error}") - pytest.fail(f"Got error during User Invites Complete E2E test: {error}") + logger.info(f"✅ Final verification: {len(final_invites_list.data)} of our test invites remain in the list") finally: - # ========================================== - # CLEANUP - # ========================================== - logger.info("Starting cleanup") - try: - # Delete remaining user invites - for invite in created_invites: - try: - await permit.api.user_invites.delete(str(invite.id)) - logger.info(f"Cleaned up invite: {invite.email}") - except PermitApiError as e: - if e.status_code not in [404, 403]: # Ignore if already deleted - logger.warning(f"Failed to delete invite {invite.email}: {e}") - - # Delete test role - if created_role: - try: - await permit.api.roles.delete(created_role.key) - logger.info(f"Cleaned up role: {created_role.key}") - except PermitApiError as e: - if e.status_code != 404: # Ignore if already deleted - logger.warning(f"Failed to delete role {created_role.key}: {e}") - - # Delete test tenant - if created_tenant: - try: - await permit.api.tenants.delete(created_tenant.key) - logger.info(f"Cleaned up tenant: {created_tenant.key}") - except PermitApiError as e: - if e.status_code != 404: # Ignore if already deleted - logger.warning(f"Failed to delete tenant {created_tenant.key}: {e}") - - # Delete test resource instance - if created_resource_instance: - try: - await permit.api.resource_instances.delete(created_resource_instance.key) - logger.info(f"Cleaned up resource instance: {created_resource_instance.key}") - except PermitApiError as e: - if e.status_code != 404: # Ignore if already deleted - logger.warning(f"Failed to delete resource instance {created_resource_instance.key}: {e}") - - # Delete test resource - if created_resource: - try: - await permit.api.resources.delete(created_resource.key) - logger.info(f"Cleaned up resource: {created_resource.key}") - except PermitApiError as e: - if e.status_code != 404: # Ignore if already deleted - logger.warning(f"Failed to delete resource {created_resource.key}: {e}") - - logger.info("✅ Cleanup completed") - - except PermitApiError as error: - handle_api_error(error, "Got API Error during cleanup") - except PermitConnectionError: - raise - except Exception as error: # noqa: BLE001 - logger.error(f"Got error during cleanup: {error}") - # Don't fail the test due to cleanup errors, just log them - logger.warning("Cleanup failed, but test results are still valid") - - -if __name__ == "__main__": - # For running the test directly - pytest.main([__file__, "-v", "-s"]) + # Delete remaining user invites + for invite in created_invites: + try: + await permit.api.user_invites.delete(str(invite.id)) + logger.info(f"Cleaned up invite: {invite.email}") + except PermitApiError as e: + if e.status_code not in [404, 403]: # Ignore if already deleted + logger.warning(f"Failed to delete invite {invite.email}: {e}") + + logger.info("🎉 All User Invites Complete E2E tests passed successfully!") From 2982a7761835a5834aebe5732b2f61b6083a2340 Mon Sep 17 00:00:00 2001 From: Shuvy Date: Sun, 21 Sep 2025 14:34:33 +0300 Subject: [PATCH 5/5] Fix model_dump --- permit/api/encoders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/permit/api/encoders.py b/permit/api/encoders.py index 9278646..de396c7 100644 --- a/permit/api/encoders.py +++ b/permit/api/encoders.py @@ -26,8 +26,8 @@ from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr - def _model_dump(model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any) -> Any: - return model.model_dump(mode=mode, **kwargs) + def _model_dump(model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any) -> Any: # noqa: ARG001 + return model.dict(**kwargs) else: from pydantic.v1 import BaseModel # type: ignore[assignment] from pydantic.v1.color import Color # type: ignore[assignment]