diff --git a/permit/api/base.py b/permit/api/base.py index faaabc9..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,7 +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] - return json.dict(exclude_unset=True, exclude_none=True) + 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..de396c7 --- /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: # noqa: ARG001 + return model.dict(**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/permit/api/models.py b/permit/api/models.py index 2d59102..414624a 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 @@ -146,7 +146,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 +154,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 +298,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 +353,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 +376,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 +710,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 +776,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 +792,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 +823,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 +852,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 +1345,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 +1369,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 +1379,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 +1416,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 +1829,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 +2185,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 +2255,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 +2269,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 +2571,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 +2753,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 +2862,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 +2883,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 +3068,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 +3180,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 +3199,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 +3679,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 +3712,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 +3940,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): @@ -3551,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', ) @@ -3576,6 +4004,11 @@ class Config: description='The tenant id of the user that is being invited', title='Tenant Id', ) + resource_instance_id: Optional[UUID] = Field( + None, + description='The resource instance id of the user that is being invited', + title='Resource Instance Id', + ) class ElementsUserInviteUpdate(BaseModel): @@ -3607,6 +4040,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,9 +4254,51 @@ 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' + ) + 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', + ) + + +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' ) @@ -3842,6 +4322,11 @@ class Config: 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): @@ -4426,7 +4911,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 +4951,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 +4993,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 +5073,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 +5115,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 +5138,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 +5163,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( @@ -4743,30 +5261,112 @@ class Config: 'Bearer', description='Proxy config auth mechanism will define the authentication mechanism that will be used to authenticate the request.\n\nBearer injects the secret into the Authorization header as a Bearer token,\n\nBasic injects the secret into the Authorization header as a Basic user:password,\n\nHeaders injects plain headers into the request.', ) - - -class ProxyConfigUpdate(BaseModel): - class Config: - extra = Extra.allow - - secret: Optional[Any] = Field( - None, - description='Proxy config secret is set to enable the Permit Proxy to make proxied requests to the backend service.', - title='Secret', + + +class ProxyConfigUpdate(BaseModel): + class Config: + extra = Extra.allow + + secret: Optional[Any] = Field( + None, + description='Proxy config secret is set to enable the Permit Proxy to make proxied requests to the backend service.', + title='Secret', + ) + name: Optional[str] = Field( + None, + description="The name of the proxy config, for example: 'Stripe API'", + title='Name', + ) + mapping_rules: Optional[List[MappingRuleUpdate]] = Field( + [], + description='Proxy config mapping rules, with optional should_delete flag to indicate deletion.', + title='Mapping Rules', + ) + auth_mechanism: Optional[AuthMechanism] = Field( + 'Bearer', + description='Proxy config auth mechanism will define the authentication mechanism that will be used to authenticate the request.\n\nBearer injects the secret into the Authorization header as a Bearer token,\n\nBasic injects the secret into the Authorization header as a Basic user:password,\n\nHeaders injects plain headers into the request.', + ) + + +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', ) - name: Optional[str] = Field( - None, - description="The name of the proxy config, for example: 'Stripe API'", - title='Name', + relation_details: StrippedRelationBlockRead = Field( + ..., + description='The relation details of the relationship tuple', + title='Relation Details', ) - mapping_rules: Optional[List[MappingRule]] = 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.', - title='Mapping Rules', + object_details: ResourceInstanceBlockRead = Field( + ..., + description='The object details of the relationship tuple', + title='Object Details', ) - auth_mechanism: Optional[AuthMechanism] = Field( - 'Bearer', - description='Proxy config auth mechanism will define the authentication mechanism that will be used to authenticate the request.\n\nBearer injects the secret into the Authorization header as a Bearer token,\n\nBasic injects the secret into the Authorization header as a Basic user:password,\n\nHeaders injects plain headers into the request.', + tenant_details: TenantBlockRead = Field( + ..., + description='The tenant details of the relationship tuple', + title='Tenant Details', ) @@ -4899,6 +5499,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 +5672,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 +5769,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 +6140,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 +6369,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 +6659,99 @@ class Config: ) -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 DataGeneratorLibSchemasSchemaOpalDataFullData(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 +6761,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 +6804,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 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..da3dd15 --- /dev/null +++ b/tests/test_user_invites_complete_e2e.py @@ -0,0 +1,357 @@ +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 ( + ActionBlockEditable, + ElementsUserInviteApprove, + ElementsUserInviteCreate, + ResourceCreate, + ResourceInstanceCreate, + ResourceInstanceRead, + ResourceRead, + RoleCreate, + RoleRead, + TenantCreate, + TenantRead, + UserInviteStatus, +) +from permit.exceptions import PermitApiError + + +def print_break(): + print("\n\n ----------- \n\n") # noqa: T201 + + +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: + # ========================================== + # SETUP: Create necessary resources + # ========================================== + logger.info("Setting up test environment") + + # Create test resource with proper actions format + test_resource = ResourceCreate( + key=f"test_resource_invites-{run_id.hex}", + 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=f"test_instance_invites-{run_id.hex}", + 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=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 + ) + 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 + ) + 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 + # ========================================== + 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) + 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(final_invites_list.data)} of our test invites remain in the list") + finally: + # 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!")