From 9b42fc03abaf4179ab88036b3dd8a441612ac90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Goran=20Jovanovi=C4=87?= Date: Sun, 15 Mar 2026 13:44:44 +0100 Subject: [PATCH 1/4] feat(api): update role assignment and user APIs * Change parameter name from `params` to `body` for consistency. * Update API requests to use `json=body` instead of `params=params`. * Enhance `PaginatedResponse` to handle plain lists from API. * Modify resource actions serialization to match backend expectations. * Add integration tests for comprehensive coverage of SDK functionality. --- permissio/api/role_assignments.py | 16 +- permissio/api/users.py | 26 +-- permissio/models/common.py | 8 +- permissio/models/resource.py | 22 ++- permissio/models/role_assignment.py | 6 +- tests/test_integration.py | 274 ++++++++++++++++++++++++++++ 6 files changed, 326 insertions(+), 26 deletions(-) create mode 100644 tests/test_integration.py diff --git a/permissio/api/role_assignments.py b/permissio/api/role_assignments.py index 4e5c79d..e59ec50 100644 --- a/permissio/api/role_assignments.py +++ b/permissio/api/role_assignments.py @@ -186,17 +186,17 @@ def unassign( tenant: The tenant key (optional). resource_instance: The resource instance key (optional). """ - params: Dict[str, Any] = { + body: Dict[str, Any] = { "user": user, "role": role, } if tenant: - params["tenant"] = tenant + body["tenant"] = tenant if resource_instance: - params["resource_instance"] = resource_instance + body["resource_instance"] = resource_instance url = self._build_facts_url("role_assignments") - self.request("DELETE", url, params=params) + self.request("DELETE", url, json=body) async def unassign_async( self, @@ -215,17 +215,17 @@ async def unassign_async( tenant: The tenant key (optional). resource_instance: The resource instance key (optional). """ - params: Dict[str, Any] = { + body: Dict[str, Any] = { "user": user, "role": role, } if tenant: - params["tenant"] = tenant + body["tenant"] = tenant if resource_instance: - params["resource_instance"] = resource_instance + body["resource_instance"] = resource_instance url = self._build_facts_url("role_assignments") - await self.request_async("DELETE", url, params=params) + await self.request_async("DELETE", url, json=body) def bulk_assign(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, Any]]]) -> List[RoleAssignmentRead]: """ diff --git a/permissio/api/users.py b/permissio/api/users.py index ae5be9d..6b44825 100644 --- a/permissio/api/users.py +++ b/permissio/api/users.py @@ -232,9 +232,10 @@ def sync(self, user: Union[UserSync, Dict[str, Any]]) -> UserRead: if isinstance(user, UserSync): user_data = user.to_dict() else: - user_data = user + user_data = dict(user) - url = self._build_facts_url("users") + user_key = user_data.get("key", "") + url = self._build_facts_url(f"users/{user_key}") response = self.put(url, json=user_data) return UserRead.from_dict(response.json()) @@ -251,9 +252,10 @@ async def sync_async(self, user: Union[UserSync, Dict[str, Any]]) -> UserRead: if isinstance(user, UserSync): user_data = user.to_dict() else: - user_data = user + user_data = dict(user) - url = self._build_facts_url("users") + user_key = user_data.get("key", "") + url = self._build_facts_url(f"users/{user_key}") response = await self.put_async(url, json=user_data) return UserRead.from_dict(response.json()) @@ -334,17 +336,17 @@ def unassign_role( tenant: The tenant key (optional). resource_instance: The resource instance key (optional). """ - params: Dict[str, Any] = { + body: Dict[str, Any] = { "user": user_key, "role": role, } if tenant: - params["tenant"] = tenant + body["tenant"] = tenant if resource_instance: - params["resource_instance"] = resource_instance + body["resource_instance"] = resource_instance url = self._build_facts_url("role_assignments") - self.request("DELETE", url, params=params) + self.request("DELETE", url, json=body) async def unassign_role_async( self, @@ -363,17 +365,17 @@ async def unassign_role_async( tenant: The tenant key (optional). resource_instance: The resource instance key (optional). """ - params: Dict[str, Any] = { + body: Dict[str, Any] = { "user": user_key, "role": role, } if tenant: - params["tenant"] = tenant + body["tenant"] = tenant if resource_instance: - params["resource_instance"] = resource_instance + body["resource_instance"] = resource_instance url = self._build_facts_url("role_assignments") - await self.request_async("DELETE", url, params=params) + await self.request_async("DELETE", url, json=body) def get_roles(self, user_key: str, *, tenant: Optional[str] = None) -> List[RoleAssignment]: """ diff --git a/permissio/models/common.py b/permissio/models/common.py index 50c3edf..4bc2149 100644 --- a/permissio/models/common.py +++ b/permissio/models/common.py @@ -56,12 +56,18 @@ def from_dict(cls, data: Dict[str, Any], item_factory: Callable[[Dict[str, Any]] Create a PaginatedResponse from a dictionary. Args: - data: The response dictionary. + data: The response dictionary (or a plain list if the API returns one). item_factory: A callable to create items from dictionaries. Returns: A PaginatedResponse instance. """ + # Some endpoints return a plain list rather than a paginated dict + if isinstance(data, list): + items = [item_factory(item) for item in data] + pagination = Pagination(page=1, per_page=len(items), total=len(items), total_pages=1) + return cls(data=items, pagination=pagination) + items = [item_factory(item) for item in data.get("data", [])] # Handle both nested pagination object and flat response with total_count/page_count diff --git a/permissio/models/resource.py b/permissio/models/resource.py index c431452..c6131c3 100644 --- a/permissio/models/resource.py +++ b/permissio/models/resource.py @@ -163,7 +163,16 @@ def to_dict(self) -> Dict[str, Any]: if self.description is not None: data["description"] = self.description if self.actions: - data["actions"] = [a.to_dict() for a in self.actions] + # Backend expects actions as a map: {"read": {"name": "Read"}} + actions_map: Dict[str, Any] = {} + for a in self.actions: + action_val: Dict[str, Any] = {} + if a.name is not None: + action_val["name"] = a.name + if a.description is not None: + action_val["description"] = a.description + actions_map[a.key] = action_val + data["actions"] = actions_map if self.attributes: data["attributes"] = [a.to_dict() for a in self.attributes] if self.urn is not None: @@ -198,7 +207,16 @@ def to_dict(self) -> Dict[str, Any]: if self.description is not None: data["description"] = self.description if self.actions is not None: - data["actions"] = [a.to_dict() for a in self.actions] + # Backend expects actions as a map: {"read": {"name": "Read"}} + actions_map: Dict[str, Any] = {} + for a in self.actions: + action_val: Dict[str, Any] = {} + if a.name is not None: + action_val["name"] = a.name + if a.description is not None: + action_val["description"] = a.description + actions_map[a.key] = action_val + data["actions"] = actions_map if self.attributes is not None: data["attributes"] = [a.to_dict() for a in self.attributes] if self.urn is not None: diff --git a/permissio/models/role_assignment.py b/permissio/models/role_assignment.py index df895b9..0064baf 100644 --- a/permissio/models/role_assignment.py +++ b/permissio/models/role_assignment.py @@ -118,11 +118,11 @@ def from_dict(cls, data: Dict[str, Any]) -> "RoleAssignmentRead": return cls( id=data.get("id", ""), user_id=data.get("user_id", data.get("userId", "")), - user_key=data.get("user_key", data.get("userKey", "")), + user_key=data.get("user_key", data.get("userKey", data.get("user", ""))), role_id=data.get("role_id", data.get("roleId", "")), - role_key=data.get("role_key", data.get("roleKey", "")), + role_key=data.get("role_key", data.get("roleKey", data.get("role", ""))), tenant_id=data.get("tenant_id", data.get("tenantId")), - tenant_key=data.get("tenant_key", data.get("tenantKey")), + tenant_key=data.get("tenant_key", data.get("tenantKey", data.get("tenant"))), resource_instance=data.get("resource_instance", data.get("resourceInstance")), created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), ) diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..89a4779 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,274 @@ +""" +Integration tests for the Permissio Python SDK. + +These tests run against a live backend at http://localhost:3001. +All test data uses timestamped keys and is cleaned up after each run. + +Usage: + pytest tests/test_integration.py -v + PERMIS_API_KEY= pytest tests/test_integration.py -v +""" + +import os +import time +import pytest + +from permissio import Permissio +from permissio.models.resource import ResourceAction, ResourceCreate + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +API_KEY = os.environ.get( + "PERMIS_API_KEY", + "permis_key_d39064912cd9d1f0052a98430e3eb7d689a350d84f2d0a018843541b5da3e5ef", +) +API_URL = os.environ.get("PERMIS_API_URL", "http://localhost:3001") + +TS = str(int(time.time())) + +USER_KEY = f"test-user-{TS}" +TENANT_KEY = f"test-tenant-{TS}" +RESOURCE_KEY = f"test-resource-{TS}" +ROLE_KEY = f"test-role-{TS}" + + +# --------------------------------------------------------------------------- +# Shared client fixture (module-scoped) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def client(): + """Create and initialise a Permissio client, yield it, then close.""" + c = Permissio(token=API_KEY, api_url=API_URL) + c.init() + yield c + c.close() + + +# --------------------------------------------------------------------------- +# Teardown: clean up all test data after the module finishes +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module", autouse=True) +def cleanup(client): + """Yield first so all tests run, then delete test data in reverse order.""" + yield + # Best-effort cleanup – ignore errors if resources were already deleted + + # 1. Unassign role (in case the unassign test didn't run) + try: + client.api.role_assignments.unassign(USER_KEY, ROLE_KEY, tenant=TENANT_KEY) + except Exception: + pass + + # 2. Delete user + try: + client.api.users.delete(USER_KEY) + except Exception: + pass + + # 3. Delete role + try: + client.api.roles.delete(ROLE_KEY) + except Exception: + pass + + # 4. Delete resource + try: + client.api.resources.delete(RESOURCE_KEY) + except Exception: + pass + + # 5. Delete tenant + try: + client.api.tenants.delete(TENANT_KEY) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestIntegration: + """Full integration test suite for the Python SDK.""" + + # 1. API key scope auto-fetch ----------------------------------------- + + def test_01_scope_auto_fetch(self, client): + """SDK should have auto-fetched project_id and environment_id.""" + assert client.config.project_id, "project_id must be set after init()" + assert client.config.environment_id, "environment_id must be set after init()" + + # 2. Users CRUD ---------------------------------------------------------- + + def test_02_create_user(self, client): + user = client.api.users.create( + {"key": USER_KEY, "first_name": "Integration", "last_name": "Test"} + ) + assert user.key == USER_KEY + + def test_03_list_users(self, client): + resp = client.api.users.list(per_page=50) + keys = [u.key for u in resp.data] + assert USER_KEY in keys + + def test_04_get_user(self, client): + user = client.api.users.get(USER_KEY) + assert user.key == USER_KEY + + def test_05_sync_user_via_api(self, client): + """api.users.sync() should create-or-update.""" + user = client.api.users.sync( + {"key": USER_KEY, "first_name": "Synced", "last_name": "User"} + ) + assert user.key == USER_KEY + + # 3. Tenants CRUD -------------------------------------------------------- + + def test_06_create_tenant(self, client): + tenant = client.api.tenants.create( + {"key": TENANT_KEY, "name": f"Tenant {TS}"} + ) + assert tenant.key == TENANT_KEY + + def test_07_list_tenants(self, client): + resp = client.api.tenants.list(per_page=50) + keys = [t.key for t in resp.data] + assert TENANT_KEY in keys + + def test_08_get_tenant(self, client): + tenant = client.api.tenants.get(TENANT_KEY) + assert tenant.key == TENANT_KEY + + # 4. Resources CRUD ------------------------------------------------------ + + def test_09_create_resource(self, client): + resource = client.api.resources.create( + ResourceCreate( + key=RESOURCE_KEY, + name=f"Resource {TS}", + actions=[ + ResourceAction(key="read", name="Read"), + ResourceAction(key="write", name="Write"), + ], + ) + ) + assert resource.key == RESOURCE_KEY + + def test_10_list_resources(self, client): + resp = client.api.resources.list(per_page=50) + keys = [r.key for r in resp.data] + assert RESOURCE_KEY in keys + + # 5. Roles CRUD ---------------------------------------------------------- + + def test_11_create_role(self, client): + role = client.api.roles.create( + { + "key": ROLE_KEY, + "name": f"Role {TS}", + "permissions": [f"{RESOURCE_KEY}:read"], + } + ) + assert role.key == ROLE_KEY + + def test_12_list_roles(self, client): + resp = client.api.roles.list(per_page=50) + keys = [r.key for r in resp.data] + assert ROLE_KEY in keys + + def test_13_get_role(self, client): + role = client.api.roles.get(ROLE_KEY) + assert role.key == ROLE_KEY + + # 6. Role Assignments ---------------------------------------------------- + + def test_14_assign_role(self, client): + assignment = client.api.role_assignments.assign( + USER_KEY, ROLE_KEY, tenant=TENANT_KEY + ) + assert assignment.user_key == USER_KEY + assert assignment.role_key == ROLE_KEY + + def test_15_list_role_assignments(self, client): + resp = client.api.role_assignments.list(user=USER_KEY, per_page=50) + found = any( + a.user_key == USER_KEY and a.role_key == ROLE_KEY for a in resp.data + ) + assert found, f"Expected assignment for {USER_KEY}/{ROLE_KEY} in list" + + # 7. check() – allowed --------------------------------------------------- + + def test_16_check_allowed(self, client): + allowed = client.check(USER_KEY, "read", RESOURCE_KEY, tenant=TENANT_KEY) + assert allowed is True, "User with role should be allowed to read" + + # 8. check() – denied (action not in role) -------------------------------- + + def test_17_check_denied(self, client): + allowed = client.check(USER_KEY, "write", RESOURCE_KEY, tenant=TENANT_KEY) + assert allowed is False, "User without write permission should be denied" + + # 9. bulk_check() -------------------------------------------------------- + + def test_18_bulk_check(self, client): + """ + bulk_check uses client-side logic (same as check() called 3 times). + The backend has no dedicated /v1/allowed/.../bulk endpoint, + so we verify by calling check() for each case individually. + """ + read_allowed = client.check(USER_KEY, "read", RESOURCE_KEY, tenant=TENANT_KEY) + write_allowed = client.check(USER_KEY, "write", RESOURCE_KEY, tenant=TENANT_KEY) + delete_allowed = client.check(USER_KEY, "delete", RESOURCE_KEY, tenant=TENANT_KEY) + + assert read_allowed is True, "read should be allowed" + assert write_allowed is False, "write should be denied (not in role perms)" + assert delete_allowed is False, "delete should be denied" + + # 10. getPermissions() – roles + permissions via users.get_roles() ------- + + def test_19_get_permissions(self, client): + """ + Verify that the user has the expected role assigned and that the role + grants the expected permission. + """ + assignments = client.api.users.get_roles(USER_KEY, tenant=TENANT_KEY) + role_keys = [a.role for a in assignments] + assert ROLE_KEY in role_keys, f"Expected role {ROLE_KEY} in assignments" + + # Fetch the role and verify the permission + role = client.api.roles.get(ROLE_KEY) + assert f"{RESOURCE_KEY}:read" in (role.permissions or []) + + # 11. sync_user() convenience method ------------------------------------- + + def test_20_sync_user_convenience(self, client): + """client.sync_user() is a convenience wrapper around api.users.sync().""" + user = client.sync_user( + {"key": USER_KEY, "first_name": "Convenience", "last_name": "Sync"} + ) + assert user.key == USER_KEY + + # 12. Role Assignment unassign – verify removed -------------------------- + + def test_21_unassign_role(self, client): + # Unassign + client.api.role_assignments.unassign(USER_KEY, ROLE_KEY, tenant=TENANT_KEY) + + # Verify it's gone + resp = client.api.role_assignments.list(user=USER_KEY, per_page=50) + still_assigned = any( + a.user_key == USER_KEY and a.role_key == ROLE_KEY for a in resp.data + ) + assert not still_assigned, "Role should be removed after unassign" + + def test_22_check_denied_after_unassign(self, client): + """After unassigning, check() must return False.""" + allowed = client.check(USER_KEY, "read", RESOURCE_KEY, tenant=TENANT_KEY) + assert allowed is False, "Unassigned user should be denied" From 6e03079561a0239bfd51829261d689c246c53676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Goran=20Jovanovi=C4=87?= Date: Sun, 15 Mar 2026 22:04:47 +0100 Subject: [PATCH 2/4] docs(changelog, readme): update documentation for SDK features * Revise changelog to reflect new features and APIs * Enhance README with usage examples and context management * Clarify installation and configuration instructions --- CHANGELOG.md | 73 +++++++++------ README.md | 258 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 238 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c39267b..9c90163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,30 +1,43 @@ -# Changelog - -All notable changes to the Permissio.io Python SDK will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added -- Initial SDK implementation -- Permission checking with `check()` method -- Async support with `check_async()` method -- Auto-scope detection from API key -- Full CRUD operations for Users, Roles, Tenants, and Resources -- FastAPI and Flask middleware integration -- Type hints throughout -- Comprehensive examples - -## [0.1.0] - 2024-XX-XX - -### Added -- Initial release -- `Permisio` client class -- `PermisioConfig` for configuration -- `User` and `Resource` builders for building check requests -- API clients for Users, Roles, Tenants, Resources, and Role Assignments -- FastAPI middleware for permission enforcement -- Full type hints and documentation -- Examples for common use cases +# Changelog + +All notable changes to the Permissio.io Python SDK will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +No unreleased changes at this time. + +--- + +## [0.1.0-alpha.1] - 2025-03-15 + +### Added +- **`Permissio` client class**: Main entry point with `token`-based configuration, supporting both sync and async usage patterns +- **`PermissioConfig` dataclass** and **`ConfigBuilder`**: Full configuration support — `token`, `api_url`, `project_id`, `environment_id`, `timeout`, `debug`, `retry_attempts`, `throw_on_error`, `custom_headers`, `http_client` +- **Permission checking (sync)**: + - `check()` — simple boolean permission check + - `check_with_details()` — full `CheckResponse` with reason and debug info (client-side RBAC evaluation) + - `bulk_check()` — batch permission checks +- **Permission checking (async)**: + - `check_async()` — async boolean permission check + - `check_with_details_async()` — async full response + - `bulk_check_async()` — async batch checks +- **Auto-scope detection**: Automatically fetches `project_id` and `environment_id` from the `/v1/api-key/scope` endpoint when not provided +- **Users API** (`api.users`): `list()`, `list_async()`, `get()`, `get_async()`, `create()`, `create_async()`, `update()`, `update_async()`, `delete()`, `delete_async()`, `sync()`, `sync_async()`, `assign_role()`, `assign_role_async()`, `unassign_role()`, `unassign_role_async()`, `get_roles()`, `get_roles_async()` +- **Tenants API** (`api.tenants`): Full sync and async CRUD — `list`, `get`, `create`, `update`, `delete`, plus `sync` +- **Roles API** (`api.roles`): Full sync and async CRUD plus `add_permissions()`, `add_permissions_async()`, `remove_permissions()`, `remove_permissions_async()` +- **Resources API** (`api.resources`): Full sync and async CRUD plus action management (`list_actions`, `create_action`, `delete_action`) and attribute management (`list_attributes`, `create_attribute`, `delete_attribute`) +- **Role Assignments API** (`api.role_assignments`): `list()`, `assign()`, `unassign()`, `bulk_assign()`, `bulk_unassign()`, `list_detailed()` — all with async variants +- **ABAC enforcement builders**: + - `UserBuilder` — fluent builder for `CheckUser` with attributes, first/last name, email + - `ResourceBuilder` — fluent builder for `CheckResource` with key, tenant, and attributes + - `ContextBuilder` — fluent builder for `CheckContext` with arbitrary key/value pairs +- **Convenience methods**: `sync_user()`, `sync_user_async()`, `assign_role()`, `assign_role_async()`, `unassign_role()`, `unassign_role_async()`, `create_tenant()`, `create_tenant_async()` +- **Context manager support**: Both sync (`with Permissio(...) as p:`) and async (`async with Permissio(...) as p:`) +- **`permissio.sync` module**: All classes and types re-exported for sync-first usage patterns +- **Error hierarchy**: `PermissioError` → `PermissioApiError` (with `PermissioRateLimitError`, `PermissioAuthenticationError`, `PermissioPermissionError`, `PermissioNotFoundError`, `PermissioConflictError`), `PermissioNetworkError` → `PermissioTimeoutError`, `PermissioValidationError` +- **Full type hints**: Complete type annotations throughout using Python 3.9+ compatible syntax +- **`httpx` HTTP backend**: Async-first HTTP client with connection pooling and timeout support +- **Examples**: Sync, async, and Flask integration examples diff --git a/README.md b/README.md index c811d06..7a8a05b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Official Python SDK for the [Permissio.io](https://permissio.io) authorization p ## Installation ```bash -pip install permisio +pip install permissio ``` ## Quick Start @@ -13,11 +13,9 @@ pip install permisio ```python from permissio import Permissio -# Initialize the client +# Initialize the client (auto-detects project/environment from token) permissio = Permissio( token="permis_key_your_api_key", - project_id="your-project-id", - environment_id="your-environment-id", ) # Check permission @@ -27,18 +25,24 @@ else: print("Access denied!") ``` -## Synchronous Usage - -For synchronous-first usage (similar to Permit.io SDK): +If you need to target a specific project and environment, pass them explicitly: ```python -from permissio.sync import Permissio - permissio = Permissio( token="permis_key_your_api_key", project_id="your-project-id", environment_id="your-environment-id", ) +``` + +## Synchronous Usage + +For synchronous-first usage, import from `permissio.sync`: + +```python +from permissio.sync import Permissio + +permissio = Permissio(token="permis_key_your_api_key") # Simple permission check allowed = permissio.check("user@example.com", "read", "document") @@ -56,33 +60,31 @@ allowed = permissio.check( ## Async Usage -For async applications: +The default `permissio.Permissio` class exposes async methods alongside sync ones: ```python import asyncio from permissio import Permissio async def main(): - permissio = Permissio( - token="permis_key_your_api_key", - project_id="your-project-id", - environment_id="your-environment-id", - ) + permissio = Permissio(token="permis_key_your_api_key") # Async permission check allowed = await permissio.check_async("user@example.com", "read", "document") - - # Close the client when done + + # Close the async HTTP client when done await permissio.close_async() asyncio.run(main()) ``` +> **Note:** Every API method has an `_async` variant (e.g. `permissio.api.users.list_async()`). The sync wrappers in `permissio.sync` call the async variants internally via a managed event loop. + ## ABAC (Attribute-Based Access Control) ```python from permissio import Permissio -from permissio.enforcement import UserBuilder, ResourceBuilder +from permissio.enforcement import UserBuilder, ResourceBuilder, ContextBuilder permissio = Permissio(token="permis_key_your_api_key") @@ -91,6 +93,8 @@ user = ( UserBuilder("user@example.com") .with_attribute("department", "engineering") .with_attribute("level", 5) + .with_first_name("Jane") + .with_last_name("Doe") .build() ) @@ -103,12 +107,30 @@ resource = ( .build() ) -# Check with ABAC -allowed = permissio.check(user, "read", resource) +# Build optional check context +context = ( + ContextBuilder() + .with_value("ip_address", "192.168.1.1") + .with_value("request_time", "2026-03-15T12:00:00Z") + .build() +) + +# Check with ABAC + context +allowed = permissio.check(user, "read", resource, context=context) ``` +### Enforcement builders + +| Class | Description | +|-------|-------------| +| `UserBuilder(key)` | Fluent builder for `CheckUser`; supports `.with_attribute()`, `.with_attributes()`, `.with_first_name()`, `.with_last_name()`, `.with_email()` | +| `ResourceBuilder(type)` | Fluent builder for `CheckResource`; supports `.with_key()`, `.with_tenant()`, `.with_attribute()`, `.with_attributes()` | +| `ContextBuilder()` | Fluent builder for `CheckContext`; supports `.with_value()`, `.with_values()` | + ## API Usage +All API methods exist in both sync and async forms. The sync form is the bare name; the async form appends `_async` (e.g. `list()` / `list_async()`). + ### Users ```python @@ -135,66 +157,96 @@ updated_user = permissio.api.users.update("user@example.com", UserUpdate( # Delete a user permissio.api.users.delete("user@example.com") + +# Sync user (upsert) and assign roles +permissio.api.users.sync("user@example.com", roles=["editor"], tenant="acme-corp") + +# Assign / unassign a role +permissio.api.users.assign_role("user@example.com", "editor", tenant="acme-corp") +permissio.api.users.unassign_role("user@example.com", "editor", tenant="acme-corp") + +# Get tenants for a user +tenants = permissio.api.users.get_tenants("user@example.com") ``` ### Tenants ```python -from permissio.models import TenantCreate +from permissio.models import TenantCreate, TenantUpdate # List tenants tenants = permissio.api.tenants.list() +# Get a tenant +tenant = permissio.api.tenants.get("acme-corp") + # Create a tenant tenant = permissio.api.tenants.create(TenantCreate( key="acme-corp", name="Acme Corporation", )) -# Get a tenant -tenant = permissio.api.tenants.get("acme-corp") +# Update a tenant +tenant = permissio.api.tenants.update("acme-corp", TenantUpdate(name="ACME Corp")) + +# Delete a tenant +permissio.api.tenants.delete("acme-corp") + +# Sync tenant (upsert) +permissio.api.tenants.sync("acme-corp", name="Acme Corporation") + +# Remove a user from a tenant +permissio.api.tenants.remove_user("acme-corp", "user@example.com") ``` ### Roles ```python -from permissio.models import RoleCreate +from permissio.models import RoleCreate, RoleUpdate # List roles roles = permissio.api.roles.list() +# Get a role +role = permissio.api.roles.get("editor") + # Create a role role = permissio.api.roles.create(RoleCreate( key="editor", name="Editor", permissions=["document:read", "document:write"], )) -``` -### Role Assignments +# Update a role +role = permissio.api.roles.update("editor", RoleUpdate(name="Content Editor")) -```python -# Assign a role to a user -permissio.api.role_assignments.assign( - user="user@example.com", - role="editor", - tenant="acme-corp", -) +# Delete a role +permissio.api.roles.delete("editor") -# Or use convenience method -permissio.assign_role("user@example.com", "editor", tenant="acme-corp") +# Sync role (upsert) +permissio.api.roles.sync("editor", name="Editor", permissions=["document:read"]) -# Unassign a role -permissio.unassign_role("user@example.com", "editor", tenant="acme-corp") +# Permission management +permissio.api.roles.add_permission("editor", "document:delete") +permissio.api.roles.remove_permission("editor", "document:delete") +permissions = permissio.api.roles.get_permissions("editor") -# List role assignments -assignments = permissio.api.role_assignments.list(user="user@example.com") +# Role inheritance (extends) +permissio.api.roles.add_extends("editor", "viewer") +permissio.api.roles.remove_extends("editor", "viewer") +extends = permissio.api.roles.get_extends("editor") ``` ### Resources ```python -from permissio.models import ResourceCreate, ResourceAction +from permissio.models import ResourceCreate, ResourceAction, ResourceAttribute + +# List resources +resources = permissio.api.resources.list() + +# Get a resource +resource = permissio.api.resources.get("document") # Create a resource type resource = permissio.api.resources.create(ResourceCreate( @@ -207,8 +259,66 @@ resource = permissio.api.resources.create(ResourceCreate( ], )) -# List resources -resources = permissio.api.resources.list() +# Update / delete a resource type +permissio.api.resources.update("document", ...) +permissio.api.resources.delete("document") + +# Sync resource type (upsert) +permissio.api.resources.sync("document", name="Document") + +# Action management +actions = permissio.api.resources.list_actions("document") +permissio.api.resources.create_action("document", ResourceAction(key="share", name="Share")) +permissio.api.resources.delete_action("document", "share") + +# Attribute management +attributes = permissio.api.resources.list_attributes("document") +permissio.api.resources.create_attribute("document", ResourceAttribute(key="classification", type="string")) +permissio.api.resources.delete_attribute("document", "classification") +``` + +### Role Assignments + +```python +from permissio.models import RoleAssignmentCreate + +# Assign a role to a user +permissio.api.role_assignments.assign( + user="user@example.com", + role="editor", + tenant="acme-corp", +) + +# Unassign a role (optionally scoped to a resource instance) +permissio.api.role_assignments.unassign( + user="user@example.com", + role="editor", + tenant="acme-corp", + resource_instance="document:doc-123", +) + +# Convenience methods on the top-level client +permissio.assign_role("user@example.com", "editor", tenant="acme-corp") +permissio.unassign_role("user@example.com", "editor", tenant="acme-corp") + +# List role assignments (with filters) +assignments = permissio.api.role_assignments.list( + user="user@example.com", + tenant="acme-corp", +) + +# List with detailed user/role information +detailed = permissio.api.role_assignments.list_detailed(user="user@example.com") + +# Bulk operations +permissio.api.role_assignments.bulk_assign([ + RoleAssignmentCreate(user="alice@example.com", role="editor", tenant="acme-corp"), + RoleAssignmentCreate(user="bob@example.com", role="viewer", tenant="acme-corp"), +]) + +permissio.api.role_assignments.bulk_unassign([ + RoleAssignmentCreate(user="alice@example.com", role="editor", tenant="acme-corp"), +]) ``` ## Configuration @@ -216,7 +326,7 @@ resources = permissio.api.resources.list() ### Using ConfigBuilder ```python -from permissio import ConfigBuilder +from permissio import ConfigBuilder, Permissio config = ( ConfigBuilder("permis_key_your_api_key") @@ -238,8 +348,8 @@ permissio = Permissio(config=config) |--------|------|---------|-------------| | `token` | str | (required) | API key starting with `permis_key_` | | `api_url` | str | `https://api.permissio.io` | Base API URL | -| `project_id` | str | None | Project identifier | -| `environment_id` | str | None | Environment identifier | +| `project_id` | str | None | Project identifier (auto-detected from token if omitted) | +| `environment_id` | str | None | Environment identifier (auto-detected from token if omitted) | | `timeout` | float | 30.0 | Request timeout in seconds | | `debug` | bool | False | Enable debug logging | | `retry_attempts` | int | 3 | Number of retry attempts | @@ -250,54 +360,76 @@ permissio = Permissio(config=config) ```python from permissio.errors import ( - PermisError, - PermisApiError, - PermisNotFoundError, - PermisAuthenticationError, + PermissioError, + PermissioApiError, + PermissioNotFoundError, + PermissioAuthenticationError, + PermissioPermissionError, + PermissioRateLimitError, + PermissioNetworkError, + PermissioTimeoutError, + PermissioConflictError, + PermissioValidationError, ) try: user = permissio.api.users.get("nonexistent@example.com") -except PermisNotFoundError as e: +except PermissioNotFoundError as e: print(f"User not found: {e.message}") -except PermisAuthenticationError as e: +except PermissioAuthenticationError as e: print(f"Authentication failed: {e.message}") -except PermisApiError as e: +except PermissioRateLimitError as e: + print(f"Rate limited. Retry after {e.retry_after}s") +except PermissioApiError as e: print(f"API error: {e.message} (status: {e.status_code})") -except PermisError as e: +except PermissioError as e: print(f"SDK error: {e.message}") ``` +### Error hierarchy + +``` +PermissioError +├── PermissioValidationError +├── PermissioNetworkError +│ └── PermissioTimeoutError +└── PermissioApiError + ├── PermissioAuthenticationError (401) + ├── PermissioPermissionError (403) + ├── PermissioNotFoundError (404) + ├── PermissioConflictError (409) + └── PermissioRateLimitError (429) +``` + +`PermissioApiError` exposes convenience properties: `.is_not_found`, `.is_unauthorized`, `.is_forbidden`, `.is_bad_request`, `.is_conflict`, `.is_server_error`, `.is_retryable`. + ## Context Manager ```python -# Automatically closes the client -with Permis(token="permis_key_your_api_key") as permis: +# Sync context manager — automatically closes the client +with Permissio(token="permis_key_your_api_key") as permissio: allowed = permissio.check("user@example.com", "read", "document") # Async context manager -async with Permis(token="permis_key_your_api_key") as permis: +async with Permissio(token="permis_key_your_api_key") as permissio: allowed = await permissio.check_async("user@example.com", "read", "document") ``` ## Flask Integration ```python -from flask import Flask, g, request +from functools import wraps +from flask import Flask, g, abort from permissio import Permissio app = Flask(__name__) -permissio = Permissio( - token="permis_key_your_api_key", - project_id="your-project-id", - environment_id="your-environment-id", -) +permissio = Permissio(token="permis_key_your_api_key") def require_permission(action: str, resource: str): def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): - user_id = g.user.id # Get from your auth system + user_id = g.user.id # From your auth system if not permissio.check(user_id, action, resource): abort(403) return f(*args, **kwargs) From 93c69f62161f8a63d8641e5750a5464fb281da06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Goran=20Jovanovi=C4=87?= Date: Mon, 16 Mar 2026 01:30:13 +0100 Subject: [PATCH 3/4] style(tests): format code for better readability --- tests/test_integration.py | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 89a4779..864ec20 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -11,6 +11,7 @@ import os import time + import pytest from permissio import Permissio @@ -108,9 +109,7 @@ def test_01_scope_auto_fetch(self, client): # 2. Users CRUD ---------------------------------------------------------- def test_02_create_user(self, client): - user = client.api.users.create( - {"key": USER_KEY, "first_name": "Integration", "last_name": "Test"} - ) + user = client.api.users.create({"key": USER_KEY, "first_name": "Integration", "last_name": "Test"}) assert user.key == USER_KEY def test_03_list_users(self, client): @@ -124,17 +123,13 @@ def test_04_get_user(self, client): def test_05_sync_user_via_api(self, client): """api.users.sync() should create-or-update.""" - user = client.api.users.sync( - {"key": USER_KEY, "first_name": "Synced", "last_name": "User"} - ) + user = client.api.users.sync({"key": USER_KEY, "first_name": "Synced", "last_name": "User"}) assert user.key == USER_KEY # 3. Tenants CRUD -------------------------------------------------------- def test_06_create_tenant(self, client): - tenant = client.api.tenants.create( - {"key": TENANT_KEY, "name": f"Tenant {TS}"} - ) + tenant = client.api.tenants.create({"key": TENANT_KEY, "name": f"Tenant {TS}"}) assert tenant.key == TENANT_KEY def test_07_list_tenants(self, client): @@ -190,17 +185,13 @@ def test_13_get_role(self, client): # 6. Role Assignments ---------------------------------------------------- def test_14_assign_role(self, client): - assignment = client.api.role_assignments.assign( - USER_KEY, ROLE_KEY, tenant=TENANT_KEY - ) + assignment = client.api.role_assignments.assign(USER_KEY, ROLE_KEY, tenant=TENANT_KEY) assert assignment.user_key == USER_KEY assert assignment.role_key == ROLE_KEY def test_15_list_role_assignments(self, client): resp = client.api.role_assignments.list(user=USER_KEY, per_page=50) - found = any( - a.user_key == USER_KEY and a.role_key == ROLE_KEY for a in resp.data - ) + found = any(a.user_key == USER_KEY and a.role_key == ROLE_KEY for a in resp.data) assert found, f"Expected assignment for {USER_KEY}/{ROLE_KEY} in list" # 7. check() – allowed --------------------------------------------------- @@ -223,12 +214,12 @@ def test_18_bulk_check(self, client): The backend has no dedicated /v1/allowed/.../bulk endpoint, so we verify by calling check() for each case individually. """ - read_allowed = client.check(USER_KEY, "read", RESOURCE_KEY, tenant=TENANT_KEY) - write_allowed = client.check(USER_KEY, "write", RESOURCE_KEY, tenant=TENANT_KEY) + read_allowed = client.check(USER_KEY, "read", RESOURCE_KEY, tenant=TENANT_KEY) + write_allowed = client.check(USER_KEY, "write", RESOURCE_KEY, tenant=TENANT_KEY) delete_allowed = client.check(USER_KEY, "delete", RESOURCE_KEY, tenant=TENANT_KEY) - assert read_allowed is True, "read should be allowed" - assert write_allowed is False, "write should be denied (not in role perms)" + assert read_allowed is True, "read should be allowed" + assert write_allowed is False, "write should be denied (not in role perms)" assert delete_allowed is False, "delete should be denied" # 10. getPermissions() – roles + permissions via users.get_roles() ------- @@ -250,9 +241,7 @@ def test_19_get_permissions(self, client): def test_20_sync_user_convenience(self, client): """client.sync_user() is a convenience wrapper around api.users.sync().""" - user = client.sync_user( - {"key": USER_KEY, "first_name": "Convenience", "last_name": "Sync"} - ) + user = client.sync_user({"key": USER_KEY, "first_name": "Convenience", "last_name": "Sync"}) assert user.key == USER_KEY # 12. Role Assignment unassign – verify removed -------------------------- @@ -263,9 +252,7 @@ def test_21_unassign_role(self, client): # Verify it's gone resp = client.api.role_assignments.list(user=USER_KEY, per_page=50) - still_assigned = any( - a.user_key == USER_KEY and a.role_key == ROLE_KEY for a in resp.data - ) + still_assigned = any(a.user_key == USER_KEY and a.role_key == ROLE_KEY for a in resp.data) assert not still_assigned, "Role should be removed after unassign" def test_22_check_denied_after_unassign(self, client): From 6b947b6452255b2ebfef4dcd4a66008aa4c3c1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Goran=20Jovanovi=C4=87?= Date: Mon, 16 Mar 2026 01:34:01 +0100 Subject: [PATCH 4/4] ci: update CI configuration to exclude integration tests * Modify pytest command to run only non-integration tests * Add marker for integration tests in pyproject.toml --- .github/workflows/ci.yml | 2 +- pyproject.toml | 3 +++ tests/test_integration.py | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49d8c3c..74dd886 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: run: mypy permissio - name: Run tests - run: pytest --cov=permissio --cov-report=xml + run: pytest -m "not integration" --cov=permissio --cov-report=xml - name: Upload coverage uses: codecov/codecov-action@v4 diff --git a/pyproject.toml b/pyproject.toml index 2a5c04c..7091c87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,9 @@ permissio = ["py.typed"] asyncio_mode = "auto" testpaths = ["tests"] addopts = "-v --cov=permissio --cov-report=term-missing" +markers = [ + "integration: marks tests as integration tests that require a live backend (deselect with '-m not integration')", +] [tool.mypy] python_version = "3.9" diff --git a/tests/test_integration.py b/tests/test_integration.py index 864ec20..f0c1929 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -17,6 +17,8 @@ from permissio import Permissio from permissio.models.resource import ResourceAction, ResourceCreate +pytestmark = pytest.mark.integration + # --------------------------------------------------------------------------- # Configuration # ---------------------------------------------------------------------------