From da7d8bbd5a5d65dd8b63416e2cbe1f61c58a7347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Goran=20Jovanovi=C4=87?= Date: Sat, 29 Nov 2025 20:22:19 +0100 Subject: [PATCH] feat(models): add role assignment, tenant, and user models * Introduced RoleAssignment, RoleAssignmentCreate, RoleAssignmentRead, and BulkRoleAssignment models for managing role assignments. * Added Tenant, TenantCreate, TenantUpdate, and TenantRead models for tenant management. * Created User, UserCreate, UserUpdate, UserRead, UserRole, and UserSync models for user management. * Implemented serialization methods (from_dict and to_dict) for all models. * Added a synchronous wrapper for the Permissio.io SDK in sync.py. * Updated project metadata in pyproject.toml and added type hints with py.typed. * Refactored tests to align with the new model structure and naming conventions. --- CHANGELOG.md | 2 +- CONTRIBUTING.md | 4 +- LICENSE | 2 +- README.md | 648 ++++---- examples/README.md | 192 +-- examples/async_example.py | 492 +++--- examples/basic.py | 552 +++---- examples/flask_example.py | 804 ++++----- examples/test_local.py | 351 ++-- permisio/__init__.py | 19 - permisio/api/__init__.py | 19 - permisio/models/__init__.py | 44 - permissio/__init__.py | 19 + permissio/api/__init__.py | 19 + {permisio => permissio}/api/base.py | 940 +++++------ {permisio => permissio}/api/resources.py | 828 +++++----- .../api/role_assignments.py | 779 ++++----- {permisio => permissio}/api/roles.py | 540 +++--- {permisio => permissio}/api/tenants.py | 418 ++--- {permisio => permissio}/api/users.py | 844 +++++----- {permisio => permissio}/client.py | 1452 ++++++++++------- {permisio => permissio}/config.py | 494 +++--- .../enforcement/__init__.py | 42 +- {permisio => permissio}/enforcement/models.py | 642 ++++---- {permisio => permissio}/errors.py | 404 ++--- permissio/models/__init__.py | 44 + {permisio => permissio}/models/check.py | 279 ++-- {permisio => permissio}/models/common.py | 283 ++-- {permisio => permissio}/models/resource.py | 497 +++--- {permisio => permissio}/models/role.py | 340 ++-- .../models/role_assignment.py | 292 ++-- {permisio => permissio}/models/tenant.py | 284 ++-- {permisio => permissio}/models/user.py | 454 +++--- {permisio => permissio}/py.typed | 0 {permisio => permissio}/sync.py | 208 +-- pyproject.toml | 19 +- tests/__init__.py | 2 +- tests/test_sdk.py | 598 +++---- 38 files changed, 7076 insertions(+), 6774 deletions(-) delete mode 100644 permisio/__init__.py delete mode 100644 permisio/api/__init__.py delete mode 100644 permisio/models/__init__.py create mode 100644 permissio/__init__.py create mode 100644 permissio/api/__init__.py rename {permisio => permissio}/api/base.py (86%) rename {permisio => permissio}/api/resources.py (94%) rename {permisio => permissio}/api/role_assignments.py (93%) rename {permisio => permissio}/api/roles.py (91%) rename {permisio => permissio}/api/tenants.py (89%) rename {permisio => permissio}/api/users.py (92%) rename {permisio => permissio}/client.py (57%) rename {permisio => permissio}/config.py (87%) rename {permisio => permissio}/enforcement/__init__.py (69%) rename {permisio => permissio}/enforcement/models.py (96%) rename {permisio => permissio}/errors.py (86%) create mode 100644 permissio/models/__init__.py rename {permisio => permissio}/models/check.py (93%) rename {permisio => permissio}/models/common.py (86%) rename {permisio => permissio}/models/resource.py (88%) rename {permisio => permissio}/models/role.py (94%) rename {permisio => permissio}/models/role_assignment.py (78%) rename {permisio => permissio}/models/tenant.py (93%) rename {permisio => permissio}/models/user.py (94%) rename {permisio => permissio}/py.typed (100%) rename {permisio => permissio}/sync.py (51%) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcbe80f..c39267b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -All notable changes to the Permis.io Python SDK will be documented in this file. +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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9dba26..9ff55be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to Permis.io Python SDK +# Contributing to Permissio.io Python SDK -Thank you for your interest in contributing to the Permis.io Python SDK! This document provides guidelines and steps for contributing. +Thank you for your interest in contributing to the Permissio.io Python SDK! This document provides guidelines and steps for contributing. ## Code of Conduct diff --git a/LICENSE b/LICENSE index 14610a3..e57a74f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Permis.io Contributors +Copyright (c) 2025 Permissio.io Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 97a1f6b..c811d06 100644 --- a/README.md +++ b/README.md @@ -1,324 +1,324 @@ -# Permis.io Python SDK - -Official Python SDK for the [Permis.io](https://permis.io) authorization platform. - -## Installation - -```bash -pip install permisio -``` - -## Quick Start - -```python -from permisio import Permis - -# Initialize the client -permis = Permis( - token="permis_key_your_api_key", - project_id="your-project-id", - environment_id="your-environment-id", -) - -# Check permission -if permis.check("user@example.com", "read", "document"): - print("Access granted!") -else: - print("Access denied!") -``` - -## Synchronous Usage - -For synchronous-first usage (similar to Permit.io SDK): - -```python -from permisio.sync import Permis - -permis = Permis( - token="permis_key_your_api_key", - project_id="your-project-id", - environment_id="your-environment-id", -) - -# Simple permission check -allowed = permis.check("user@example.com", "read", "document") - -# Check with tenant -allowed = permis.check("user@example.com", "read", "document", tenant="acme-corp") - -# Check with resource instance -allowed = permis.check( - "user@example.com", - "read", - {"type": "document", "key": "doc-123"} -) -``` - -## Async Usage - -For async applications: - -```python -import asyncio -from permisio import Permis - -async def main(): - permis = Permis( - token="permis_key_your_api_key", - project_id="your-project-id", - environment_id="your-environment-id", - ) - - # Async permission check - allowed = await permis.check_async("user@example.com", "read", "document") - - # Close the client when done - await permis.close_async() - -asyncio.run(main()) -``` - -## ABAC (Attribute-Based Access Control) - -```python -from permisio import Permis -from permisio.enforcement import UserBuilder, ResourceBuilder - -permis = Permis(token="permis_key_your_api_key") - -# Build user with attributes -user = ( - UserBuilder("user@example.com") - .with_attribute("department", "engineering") - .with_attribute("level", 5) - .build() -) - -# Build resource with attributes -resource = ( - ResourceBuilder("document") - .with_key("doc-123") - .with_tenant("acme-corp") - .with_attribute("classification", "confidential") - .build() -) - -# Check with ABAC -allowed = permis.check(user, "read", resource) -``` - -## API Usage - -### Users - -```python -from permisio.models import UserCreate, UserUpdate - -# List users -users = permis.api.users.list(page=1, per_page=10) - -# Get a user -user = permis.api.users.get("user@example.com") - -# Create a user -new_user = permis.api.users.create(UserCreate( - key="new.user@example.com", - email="new.user@example.com", - first_name="New", - last_name="User", -)) - -# Update a user -updated_user = permis.api.users.update("user@example.com", UserUpdate( - first_name="Updated" -)) - -# Delete a user -permis.api.users.delete("user@example.com") -``` - -### Tenants - -```python -from permisio.models import TenantCreate - -# List tenants -tenants = permis.api.tenants.list() - -# Create a tenant -tenant = permis.api.tenants.create(TenantCreate( - key="acme-corp", - name="Acme Corporation", -)) - -# Get a tenant -tenant = permis.api.tenants.get("acme-corp") -``` - -### Roles - -```python -from permisio.models import RoleCreate - -# List roles -roles = permis.api.roles.list() - -# Create a role -role = permis.api.roles.create(RoleCreate( - key="editor", - name="Editor", - permissions=["document:read", "document:write"], -)) -``` - -### Role Assignments - -```python -# Assign a role to a user -permis.api.role_assignments.assign( - user="user@example.com", - role="editor", - tenant="acme-corp", -) - -# Or use convenience method -permis.assign_role("user@example.com", "editor", tenant="acme-corp") - -# Unassign a role -permis.unassign_role("user@example.com", "editor", tenant="acme-corp") - -# List role assignments -assignments = permis.api.role_assignments.list(user="user@example.com") -``` - -### Resources - -```python -from permisio.models import ResourceCreate, ResourceAction - -# Create a resource type -resource = permis.api.resources.create(ResourceCreate( - key="document", - name="Document", - actions=[ - ResourceAction(key="read", name="Read"), - ResourceAction(key="write", name="Write"), - ResourceAction(key="delete", name="Delete"), - ], -)) - -# List resources -resources = permis.api.resources.list() -``` - -## Configuration - -### Using ConfigBuilder - -```python -from permisio import ConfigBuilder - -config = ( - ConfigBuilder("permis_key_your_api_key") - .with_project_id("your-project-id") - .with_environment_id("your-environment-id") - .with_api_url("https://api.permis.io") - .with_timeout(30.0) - .with_retry_attempts(3) - .with_debug(True) - .build() -) - -permis = Permis(config=config) -``` - -### Configuration Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `token` | str | (required) | API key starting with `permis_key_` | -| `api_url` | str | `https://api.permis.io` | Base API URL | -| `project_id` | str | None | Project identifier | -| `environment_id` | str | None | Environment identifier | -| `timeout` | float | 30.0 | Request timeout in seconds | -| `debug` | bool | False | Enable debug logging | -| `retry_attempts` | int | 3 | Number of retry attempts | -| `throw_on_error` | bool | True | Raise exceptions on errors | -| `custom_headers` | dict | {} | Additional HTTP headers | - -## Error Handling - -```python -from permisio.errors import ( - PermisError, - PermisApiError, - PermisNotFoundError, - PermisAuthenticationError, -) - -try: - user = permis.api.users.get("nonexistent@example.com") -except PermisNotFoundError as e: - print(f"User not found: {e.message}") -except PermisAuthenticationError as e: - print(f"Authentication failed: {e.message}") -except PermisApiError as e: - print(f"API error: {e.message} (status: {e.status_code})") -except PermisError as e: - print(f"SDK error: {e.message}") -``` - -## Context Manager - -```python -# Automatically closes the client -with Permis(token="permis_key_your_api_key") as permis: - allowed = permis.check("user@example.com", "read", "document") - -# Async context manager -async with Permis(token="permis_key_your_api_key") as permis: - allowed = await permis.check_async("user@example.com", "read", "document") -``` - -## Flask Integration - -```python -from flask import Flask, g, request -from permisio import Permis - -app = Flask(__name__) -permis = Permis( - token="permis_key_your_api_key", - project_id="your-project-id", - environment_id="your-environment-id", -) - -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 - if not permis.check(user_id, action, resource): - abort(403) - return f(*args, **kwargs) - return decorated_function - return decorator - -@app.route("/documents/") -@require_permission("read", "document") -def get_document(doc_id): - return {"id": doc_id, "content": "..."} -``` - -## Requirements - -- Python 3.9+ -- httpx >= 0.24.0 - -## License - -MIT License - see [LICENSE](LICENSE) for details. - -## Documentation - -Full documentation is available at [docs.permis.io/sdk/python](https://docs.permis.io/sdk/python). +# Permissio.io Python SDK + +Official Python SDK for the [Permissio.io](https://permissio.io) authorization platform. + +## Installation + +```bash +pip install permisio +``` + +## Quick Start + +```python +from permissio import Permissio + +# Initialize the client +permissio = Permissio( + token="permis_key_your_api_key", + project_id="your-project-id", + environment_id="your-environment-id", +) + +# Check permission +if permissio.check("user@example.com", "read", "document"): + print("Access granted!") +else: + print("Access denied!") +``` + +## Synchronous Usage + +For synchronous-first usage (similar to Permit.io SDK): + +```python +from permissio.sync import Permissio + +permissio = Permissio( + token="permis_key_your_api_key", + project_id="your-project-id", + environment_id="your-environment-id", +) + +# Simple permission check +allowed = permissio.check("user@example.com", "read", "document") + +# Check with tenant +allowed = permissio.check("user@example.com", "read", "document", tenant="acme-corp") + +# Check with resource instance +allowed = permissio.check( + "user@example.com", + "read", + {"type": "document", "key": "doc-123"} +) +``` + +## Async Usage + +For async applications: + +```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", + ) + + # Async permission check + allowed = await permissio.check_async("user@example.com", "read", "document") + + # Close the client when done + await permissio.close_async() + +asyncio.run(main()) +``` + +## ABAC (Attribute-Based Access Control) + +```python +from permissio import Permissio +from permissio.enforcement import UserBuilder, ResourceBuilder + +permissio = Permissio(token="permis_key_your_api_key") + +# Build user with attributes +user = ( + UserBuilder("user@example.com") + .with_attribute("department", "engineering") + .with_attribute("level", 5) + .build() +) + +# Build resource with attributes +resource = ( + ResourceBuilder("document") + .with_key("doc-123") + .with_tenant("acme-corp") + .with_attribute("classification", "confidential") + .build() +) + +# Check with ABAC +allowed = permissio.check(user, "read", resource) +``` + +## API Usage + +### Users + +```python +from permissio.models import UserCreate, UserUpdate + +# List users +users = permissio.api.users.list(page=1, per_page=10) + +# Get a user +user = permissio.api.users.get("user@example.com") + +# Create a user +new_user = permissio.api.users.create(UserCreate( + key="new.user@example.com", + email="new.user@example.com", + first_name="New", + last_name="User", +)) + +# Update a user +updated_user = permissio.api.users.update("user@example.com", UserUpdate( + first_name="Updated" +)) + +# Delete a user +permissio.api.users.delete("user@example.com") +``` + +### Tenants + +```python +from permissio.models import TenantCreate + +# List tenants +tenants = permissio.api.tenants.list() + +# 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") +``` + +### Roles + +```python +from permissio.models import RoleCreate + +# List roles +roles = permissio.api.roles.list() + +# Create a role +role = permissio.api.roles.create(RoleCreate( + key="editor", + name="Editor", + permissions=["document:read", "document:write"], +)) +``` + +### Role Assignments + +```python +# Assign a role to a user +permissio.api.role_assignments.assign( + user="user@example.com", + role="editor", + tenant="acme-corp", +) + +# Or use convenience method +permissio.assign_role("user@example.com", "editor", tenant="acme-corp") + +# Unassign a role +permissio.unassign_role("user@example.com", "editor", tenant="acme-corp") + +# List role assignments +assignments = permissio.api.role_assignments.list(user="user@example.com") +``` + +### Resources + +```python +from permissio.models import ResourceCreate, ResourceAction + +# Create a resource type +resource = permissio.api.resources.create(ResourceCreate( + key="document", + name="Document", + actions=[ + ResourceAction(key="read", name="Read"), + ResourceAction(key="write", name="Write"), + ResourceAction(key="delete", name="Delete"), + ], +)) + +# List resources +resources = permissio.api.resources.list() +``` + +## Configuration + +### Using ConfigBuilder + +```python +from permissio import ConfigBuilder + +config = ( + ConfigBuilder("permis_key_your_api_key") + .with_project_id("your-project-id") + .with_environment_id("your-environment-id") + .with_api_url("https://api.permissio.io") + .with_timeout(30.0) + .with_retry_attempts(3) + .with_debug(True) + .build() +) + +permissio = Permissio(config=config) +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `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 | +| `timeout` | float | 30.0 | Request timeout in seconds | +| `debug` | bool | False | Enable debug logging | +| `retry_attempts` | int | 3 | Number of retry attempts | +| `throw_on_error` | bool | True | Raise exceptions on errors | +| `custom_headers` | dict | {} | Additional HTTP headers | + +## Error Handling + +```python +from permissio.errors import ( + PermisError, + PermisApiError, + PermisNotFoundError, + PermisAuthenticationError, +) + +try: + user = permissio.api.users.get("nonexistent@example.com") +except PermisNotFoundError as e: + print(f"User not found: {e.message}") +except PermisAuthenticationError as e: + print(f"Authentication failed: {e.message}") +except PermisApiError as e: + print(f"API error: {e.message} (status: {e.status_code})") +except PermisError as e: + print(f"SDK error: {e.message}") +``` + +## Context Manager + +```python +# Automatically closes the client +with Permis(token="permis_key_your_api_key") as permis: + allowed = permissio.check("user@example.com", "read", "document") + +# Async context manager +async with Permis(token="permis_key_your_api_key") as permis: + allowed = await permissio.check_async("user@example.com", "read", "document") +``` + +## Flask Integration + +```python +from flask import Flask, g, request +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", +) + +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 + if not permissio.check(user_id, action, resource): + abort(403) + return f(*args, **kwargs) + return decorated_function + return decorator + +@app.route("/documents/") +@require_permission("read", "document") +def get_document(doc_id): + return {"id": doc_id, "content": "..."} +``` + +## Requirements + +- Python 3.9+ +- httpx >= 0.24.0 + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +## Documentation + +Full documentation is available at [docs.permissio.io/sdk/python](https://docs.permissio.io/sdk/python). diff --git a/examples/README.md b/examples/README.md index 1cc396d..0d7999b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,96 +1,96 @@ -# Examples - -This directory contains example code demonstrating how to use the Permis.io Python SDK. - -## Examples - -### [basic.py](./basic.py) - -Basic usage examples including: -- Client initialization (simple and ConfigBuilder) -- Simple permission checks -- ABAC permission checks with user/resource builders -- Bulk permission checks -- User, Tenant, and Role management -- Role assignment operations -- User sync functionality - -```bash -python examples/basic.py -``` - -### [async_example.py](./async_example.py) - -Async/await examples including: -- Async permission checks -- Concurrent permission checks with `asyncio.gather()` -- Async API operations -- Parallel API operations -- Context manager usage -- Batch operations - -```bash -python examples/async_example.py -``` - -### [flask_example.py](./flask_example.py) - -Flask web framework integration including: -- Permission decorator (`@require_permission`) -- Multi-permission decorators (`@require_any_permission`, `@require_all_permissions`) -- Authentication middleware integration -- Helper functions for templates (`can_user()`) -- User sync endpoint -- Error handlers - -```bash -# Install Flask first -pip install flask - -# Run the example -python examples/flask_example.py - -# Test with curl -curl http://localhost:5000/ -curl -H "Authorization: Bearer user123" http://localhost:5000/documents -curl -H "Authorization: Bearer user123" http://localhost:5000/documents/doc-1 -``` - -## Running the Examples - -1. Install the SDK: - ```bash - pip install permisio - # or for development - pip install -e . - ``` - -2. Set your API key: - ```bash - export PERMIS_API_KEY="permis_key_your_api_key_here" - ``` - -3. Run an example: - ```bash - python examples/basic.py - ``` - -## Configuration - -All examples use placeholder values for: -- `token`: Replace with your actual API key -- `project_id`: Replace with your project ID -- `environment_id`: Replace with your environment ID - -For production use, load these from environment variables: - -```python -import os -from permisio import Permis - -permis = Permis( - token=os.environ["PERMIS_API_KEY"], - project_id=os.environ["PERMIS_PROJECT_ID"], - environment_id=os.environ["PERMIS_ENVIRONMENT_ID"], -) -``` +# Examples + +This directory contains example code demonstrating how to use the Permissio.io Python SDK. + +## Examples + +### [basic.py](./basic.py) + +Basic usage examples including: +- Client initialization (simple and ConfigBuilder) +- Simple permission checks +- ABAC permission checks with user/resource builders +- Bulk permission checks +- User, Tenant, and Role management +- Role assignment operations +- User sync functionality + +```bash +python examples/basic.py +``` + +### [async_example.py](./async_example.py) + +Async/await examples including: +- Async permission checks +- Concurrent permission checks with `asyncio.gather()` +- Async API operations +- Parallel API operations +- Context manager usage +- Batch operations + +```bash +python examples/async_example.py +``` + +### [flask_example.py](./flask_example.py) + +Flask web framework integration including: +- Permission decorator (`@require_permission`) +- Multi-permission decorators (`@require_any_permission`, `@require_all_permissions`) +- Authentication middleware integration +- Helper functions for templates (`can_user()`) +- User sync endpoint +- Error handlers + +```bash +# Install Flask first +pip install flask + +# Run the example +python examples/flask_example.py + +# Test with curl +curl http://localhost:5000/ +curl -H "Authorization: Bearer user123" http://localhost:5000/documents +curl -H "Authorization: Bearer user123" http://localhost:5000/documents/doc-1 +``` + +## Running the Examples + +1. Install the SDK: + ```bash + pip install permisio + # or for development + pip install -e . + ``` + +2. Set your API key: + ```bash + export PERMIS_API_KEY="permis_key_your_api_key_here" + ``` + +3. Run an example: + ```bash + python examples/basic.py + ``` + +## Configuration + +All examples use placeholder values for: +- `token`: Replace with your actual API key +- `project_id`: Replace with your project ID +- `environment_id`: Replace with your environment ID + +For production use, load these from environment variables: + +```python +import os +from permisio import Permis + +permis = Permis( + token=os.environ["PERMIS_API_KEY"], + project_id=os.environ["PERMIS_PROJECT_ID"], + environment_id=os.environ["PERMIS_ENVIRONMENT_ID"], +) +``` diff --git a/examples/async_example.py b/examples/async_example.py index cfb26dc..b69c2c9 100644 --- a/examples/async_example.py +++ b/examples/async_example.py @@ -1,246 +1,246 @@ -""" -Async example for the Permis.io Python SDK. - -This example demonstrates: -- Async client usage -- Async permission checks -- Async API operations -- Integration with asyncio -""" - -import asyncio - -from permisio import Permis -from permisio.enforcement import UserBuilder, ResourceBuilder -from permisio.models import UserCreate, TenantCreate -from permisio.errors import PermisApiError - - -async def main(): - # ========================================================================= - # Async Client Initialization - # ========================================================================= - - print("=== Async Client Example ===\n") - - # Initialize the client (works for both sync and async) - permis = Permis( - token="permis_key_your_api_key_here", - project_id="my-project", - environment_id="production", - ) - - try: - # ===================================================================== - # Async Permission Checks - # ===================================================================== - - print("--- Permission Checks ---") - - # Simple async check - allowed = await permis.check_async("user@example.com", "read", "document") - print(f"Can user read document? {allowed}") - - # Check with ABAC - user = ( - UserBuilder("user@example.com") - .with_attribute("department", "engineering") - .build() - ) - - resource = ( - ResourceBuilder("document") - .with_key("doc-123") - .with_tenant("acme-corp") - .build() - ) - - allowed = await permis.check_async(user, "read", resource) - print(f"Can engineering user read doc-123? {allowed}") - - # Get detailed response - response = await permis.check_with_details_async( - "user@example.com", "write", "document" - ) - print(f"Write allowed: {response.allowed}") - - # ===================================================================== - # Concurrent Permission Checks - # ===================================================================== - - print("\n--- Concurrent Permission Checks ---") - - # Check multiple permissions concurrently - checks = [ - permis.check_async("user1@example.com", "read", "document"), - permis.check_async("user2@example.com", "read", "document"), - permis.check_async("user1@example.com", "write", "document"), - permis.check_async("user2@example.com", "delete", "document"), - ] - - results = await asyncio.gather(*checks) - - check_descriptions = [ - "user1 read document", - "user2 read document", - "user1 write document", - "user2 delete document", - ] - - for desc, result in zip(check_descriptions, results): - print(f" {desc}: {result}") - - # ===================================================================== - # Async API Operations - # ===================================================================== - - print("\n--- Async API Operations ---") - - try: - # List users async - users = await permis.api.users.list_async(page=1, per_page=5) - print(f"Total users: {users.pagination.total}") - - # Create user async - new_user = await permis.api.users.create_async(UserCreate( - key="async.user@example.com", - email="async.user@example.com", - first_name="Async", - last_name="User", - )) - print(f"Created user: {new_user.key}") - - # Get user async - user = await permis.api.users.get_async("async.user@example.com") - print(f"Got user: {user.key}") - - # Delete user async - await permis.api.users.delete_async("async.user@example.com") - print("Deleted user") - - except PermisApiError as e: - print(f"API error: {e.message}") - - # ===================================================================== - # Async Role Assignment - # ===================================================================== - - print("\n--- Async Role Assignment ---") - - try: - # Assign role async - assignment = await permis.api.role_assignments.assign_async( - user="user@example.com", - role="viewer", - tenant="acme-corp", - ) - print(f"Assigned role: {assignment.role_key}") - - # List assignments async - assignments = await permis.api.role_assignments.list_async( - user="user@example.com" - ) - print(f"User has {len(assignments.data)} role assignments") - - # Unassign role async - await permis.api.role_assignments.unassign_async( - user="user@example.com", - role="viewer", - tenant="acme-corp", - ) - print("Unassigned role") - - except PermisApiError as e: - print(f"API error: {e.message}") - - # ===================================================================== - # Parallel API Operations - # ===================================================================== - - print("\n--- Parallel API Operations ---") - - # Fetch multiple resources concurrently - try: - users_task = permis.api.users.list_async() - tenants_task = permis.api.tenants.list_async() - roles_task = permis.api.roles.list_async() - resources_task = permis.api.resources.list_async() - - users, tenants, roles, resources = await asyncio.gather( - users_task, tenants_task, roles_task, resources_task - ) - - print(f" Users: {users.pagination.total}") - print(f" Tenants: {tenants.pagination.total}") - print(f" Roles: {roles.pagination.total}") - print(f" Resources: {resources.pagination.total}") - - except PermisApiError as e: - print(f"API error: {e.message}") - - finally: - # ===================================================================== - # Cleanup - # ===================================================================== - - print("\n--- Cleanup ---") - await permis.close_async() - print("Client closed") - - -async def context_manager_example(): - """Example using async context manager.""" - - print("\n=== Context Manager Example ===") - - async with Permis(token="permis_key_your_api_key") as permis: - allowed = await permis.check_async("user@example.com", "read", "document") - print(f"Permission check result: {allowed}") - - print("Client automatically closed") - - -async def batch_operations_example(): - """Example of batch operations.""" - - print("\n=== Batch Operations Example ===") - - permis = Permis(token="permis_key_your_api_key") - - try: - # Create multiple users concurrently - user_creates = [ - permis.api.users.create_async(UserCreate( - key=f"batch.user{i}@example.com", - email=f"batch.user{i}@example.com", - )) - for i in range(5) - ] - - created_users = await asyncio.gather(*user_creates, return_exceptions=True) - - successful = [u for u in created_users if not isinstance(u, Exception)] - failed = [u for u in created_users if isinstance(u, Exception)] - - print(f"Created {len(successful)} users, {len(failed)} failed") - - # Clean up - delete created users - delete_tasks = [ - permis.api.users.delete_async(f"batch.user{i}@example.com") - for i in range(5) - ] - - await asyncio.gather(*delete_tasks, return_exceptions=True) - print("Cleaned up batch users") - - finally: - await permis.close_async() - - -if __name__ == "__main__": - # Run main example - asyncio.run(main()) - - # Run additional examples - asyncio.run(context_manager_example()) - asyncio.run(batch_operations_example()) +""" +Async example for the Permissio.io Python SDK. + +This example demonstrates: +- Async client usage +- Async permission checks +- Async API operations +- Integration with asyncio +""" + +import asyncio + +from permissio import Permissio +from permissio.enforcement import UserBuilder, ResourceBuilder +from permissio.models import UserCreate, TenantCreate +from permissio.errors import PermissioApiError + + +async def main(): + # ========================================================================= + # Async Client Initialization + # ========================================================================= + + print("=== Async Client Example ===\n") + + # Initialize the client (works for both sync and async) + permissio = Permissio( + token="permis_key_your_api_key_here", + project_id="my-project", + environment_id="production", + ) + + try: + # ===================================================================== + # Async Permission Checks + # ===================================================================== + + print("--- Permission Checks ---") + + # Simple async check + allowed = await permissio.check_async("user@example.com", "read", "document") + print(f"Can user read document? {allowed}") + + # Check with ABAC + user = ( + UserBuilder("user@example.com") + .with_attribute("department", "engineering") + .build() + ) + + resource = ( + ResourceBuilder("document") + .with_key("doc-123") + .with_tenant("acme-corp") + .build() + ) + + allowed = await permissio.check_async(user, "read", resource) + print(f"Can engineering user read doc-123? {allowed}") + + # Get detailed response + response = await permissio.check_with_details_async( + "user@example.com", "write", "document" + ) + print(f"Write allowed: {response.allowed}") + + # ===================================================================== + # Concurrent Permission Checks + # ===================================================================== + + print("\n--- Concurrent Permission Checks ---") + + # Check multiple permissions concurrently + checks = [ + permissio.check_async("user1@example.com", "read", "document"), + permissio.check_async("user2@example.com", "read", "document"), + permissio.check_async("user1@example.com", "write", "document"), + permissio.check_async("user2@example.com", "delete", "document"), + ] + + results = await asyncio.gather(*checks) + + check_descriptions = [ + "user1 read document", + "user2 read document", + "user1 write document", + "user2 delete document", + ] + + for desc, result in zip(check_descriptions, results): + print(f" {desc}: {result}") + + # ===================================================================== + # Async API Operations + # ===================================================================== + + print("\n--- Async API Operations ---") + + try: + # List users async + users = await permissio.api.users.list_async(page=1, per_page=5) + print(f"Total users: {users.pagination.total}") + + # Create user async + new_user = await permissio.api.users.create_async(UserCreate( + key="async.user@example.com", + email="async.user@example.com", + first_name="Async", + last_name="User", + )) + print(f"Created user: {new_user.key}") + + # Get user async + user = await permissio.api.users.get_async("async.user@example.com") + print(f"Got user: {user.key}") + + # Delete user async + await permissio.api.users.delete_async("async.user@example.com") + print("Deleted user") + + except PermissioApiError as e: + print(f"API error: {e.message}") + + # ===================================================================== + # Async Role Assignment + # ===================================================================== + + print("\n--- Async Role Assignment ---") + + try: + # Assign role async + assignment = await permissio.api.role_assignments.assign_async( + user="user@example.com", + role="viewer", + tenant="acme-corp", + ) + print(f"Assigned role: {assignment.role_key}") + + # List assignments async + assignments = await permissio.api.role_assignments.list_async( + user="user@example.com" + ) + print(f"User has {len(assignments.data)} role assignments") + + # Unassign role async + await permissio.api.role_assignments.unassign_async( + user="user@example.com", + role="viewer", + tenant="acme-corp", + ) + print("Unassigned role") + + except PermissioApiError as e: + print(f"API error: {e.message}") + + # ===================================================================== + # Parallel API Operations + # ===================================================================== + + print("\n--- Parallel API Operations ---") + + # Fetch multiple resources concurrently + try: + users_task = permissio.api.users.list_async() + tenants_task = permissio.api.tenants.list_async() + roles_task = permissio.api.roles.list_async() + resources_task = permissio.api.resources.list_async() + + users, tenants, roles, resources = await asyncio.gather( + users_task, tenants_task, roles_task, resources_task + ) + + print(f" Users: {users.pagination.total}") + print(f" Tenants: {tenants.pagination.total}") + print(f" Roles: {roles.pagination.total}") + print(f" Resources: {resources.pagination.total}") + + except PermissioApiError as e: + print(f"API error: {e.message}") + + finally: + # ===================================================================== + # Cleanup + # ===================================================================== + + print("\n--- Cleanup ---") + await permissio.close_async() + print("Client closed") + + +async def context_manager_example(): + """Example using async context manager.""" + + print("\n=== Context Manager Example ===") + + async with Permissio(token="permis_key_your_api_key") as permissio: + allowed = await permissio.check_async("user@example.com", "read", "document") + print(f"Permission check result: {allowed}") + + print("Client automatically closed") + + +async def batch_operations_example(): + """Example of batch operations.""" + + print("\n=== Batch Operations Example ===") + + permissio = Permissio(token="permis_key_your_api_key") + + try: + # Create multiple users concurrently + user_creates = [ + permissio.api.users.create_async(UserCreate( + key=f"batch.user{i}@example.com", + email=f"batch.user{i}@example.com", + )) + for i in range(5) + ] + + created_users = await asyncio.gather(*user_creates, return_exceptions=True) + + successful = [u for u in created_users if not isinstance(u, Exception)] + failed = [u for u in created_users if isinstance(u, Exception)] + + print(f"Created {len(successful)} users, {len(failed)} failed") + + # Clean up - delete created users + delete_tasks = [ + permissio.api.users.delete_async(f"batch.user{i}@example.com") + for i in range(5) + ] + + await asyncio.gather(*delete_tasks, return_exceptions=True) + print("Cleaned up batch users") + + finally: + await permissio.close_async() + + +if __name__ == "__main__": + # Run main example + asyncio.run(main()) + + # Run additional examples + asyncio.run(context_manager_example()) + asyncio.run(batch_operations_example()) diff --git a/examples/basic.py b/examples/basic.py index a53c98c..ee7a363 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -1,276 +1,276 @@ -""" -Basic usage example for the Permis.io Python SDK. - -This example demonstrates: -- Client initialization -- Permission checks -- Using enforcement builders -- API operations for users, tenants, and roles -""" - -from permisio import Permis, ConfigBuilder -from permisio.enforcement import UserBuilder, ResourceBuilder -from permisio.models import UserCreate, TenantCreate, RoleCreate -from permisio.errors import PermisApiError, PermisNotFoundError - - -def main(): - # ========================================================================= - # Client Initialization - # ========================================================================= - - # Option 1: Simple initialization - permis = Permis( - token="permis_key_your_api_key_here", - project_id="my-project", - environment_id="production", - ) - - # Option 2: Using ConfigBuilder - config = ( - ConfigBuilder("permis_key_your_api_key_here") - .with_project_id("my-project") - .with_environment_id("production") - .with_debug(True) - .with_timeout(30.0) - .build() - ) - permis = Permis(config=config) - - # ========================================================================= - # Simple Permission Check - # ========================================================================= - - print("=== Simple Permission Check ===") - - # Check if user can read a document - allowed = permis.check("user@example.com", "read", "document") - print(f"Can user read document? {allowed}") - - # Check with tenant context - allowed = permis.check( - "user@example.com", - "write", - "document", - tenant="acme-corp" - ) - print(f"Can user write document in acme-corp? {allowed}") - - # Check with resource instance - allowed = permis.check( - "user@example.com", - "delete", - {"type": "document", "key": "doc-123"} - ) - print(f"Can user delete doc-123? {allowed}") - - # ========================================================================= - # ABAC Permission Check - # ========================================================================= - - print("\n=== ABAC Permission Check ===") - - # Build user with attributes - user = ( - UserBuilder("user@example.com") - .with_attribute("department", "engineering") - .with_attribute("level", 5) - .with_attribute("location", "US") - .build() - ) - - # Build resource with attributes - resource = ( - ResourceBuilder("document") - .with_key("doc-confidential-123") - .with_tenant("acme-corp") - .with_attribute("classification", "confidential") - .with_attribute("owner_department", "engineering") - .build() - ) - - # Check permission with ABAC - allowed = permis.check(user, "read", resource) - print(f"Can engineering user (level 5) read confidential doc? {allowed}") - - # Get detailed response - response = permis.check_with_details(user, "read", resource) - print(f"Allowed: {response.allowed}") - if response.reason: - print(f"Reason: {response.reason}") - - # ========================================================================= - # Bulk Permission Check - # ========================================================================= - - print("\n=== Bulk Permission Check ===") - - checks = [ - {"user": "user1@example.com", "action": "read", "resource": "document"}, - {"user": "user1@example.com", "action": "write", "resource": "document"}, - {"user": "user2@example.com", "action": "read", "resource": "document"}, - {"user": "user2@example.com", "action": "delete", "resource": "document"}, - ] - - results = permis.bulk_check(checks) - for i, result in enumerate(results.results): - check = checks[i] - print(f"{check['user']} can {check['action']} {check['resource']}: {result.allowed}") - - print(f"All allowed: {results.all_allowed()}") - print(f"Any allowed: {results.any_allowed()}") - - # ========================================================================= - # User Management - # ========================================================================= - - print("\n=== User Management ===") - - try: - # Create a user - new_user = permis.api.users.create(UserCreate( - key="new.user@example.com", - email="new.user@example.com", - first_name="New", - last_name="User", - attributes={"department": "sales"}, - )) - print(f"Created user: {new_user.key}") - - # List users - users = permis.api.users.list(page=1, per_page=10) - print(f"Total users: {users.pagination.total}") - for user in users.data: - print(f" - {user.key}") - - # Get a specific user - user = permis.api.users.get("new.user@example.com") - print(f"Got user: {user.key} ({user.email})") - - # Delete the user - permis.api.users.delete("new.user@example.com") - print("Deleted user") - - except PermisNotFoundError as e: - print(f"User not found: {e.message}") - except PermisApiError as e: - print(f"API error: {e.message}") - - # ========================================================================= - # Tenant Management - # ========================================================================= - - print("\n=== Tenant Management ===") - - try: - # Create a tenant - tenant = permis.api.tenants.create(TenantCreate( - key="demo-tenant", - name="Demo Tenant", - description="A demo tenant for testing", - )) - print(f"Created tenant: {tenant.key}") - - # Or use convenience method - tenant2 = permis.create_tenant({ - "key": "demo-tenant-2", - "name": "Demo Tenant 2", - }) - print(f"Created tenant: {tenant2.key}") - - # List tenants - tenants = permis.api.tenants.list() - print(f"Total tenants: {tenants.pagination.total}") - - except PermisApiError as e: - print(f"API error: {e.message}") - - # ========================================================================= - # Role Management - # ========================================================================= - - print("\n=== Role Management ===") - - try: - # Create a role - role = permis.api.roles.create(RoleCreate( - key="editor", - name="Editor", - description="Can read and write documents", - permissions=["document:read", "document:write"], - )) - print(f"Created role: {role.key}") - - # List roles - roles = permis.api.roles.list() - print(f"Total roles: {roles.pagination.total}") - for role in roles.data: - print(f" - {role.key}: {role.permissions}") - - except PermisApiError as e: - print(f"API error: {e.message}") - - # ========================================================================= - # Role Assignment - # ========================================================================= - - print("\n=== Role Assignment ===") - - try: - # Assign a role to a user - assignment = permis.assign_role( - user="user@example.com", - role="editor", - tenant="acme-corp", - ) - print(f"Assigned role: {assignment.role_key} to {assignment.user_key}") - - # Get user's roles - roles = permis.api.users.get_roles("user@example.com") - print(f"User roles: {[r.role_key for r in roles]}") - - # Unassign the role - permis.unassign_role( - user="user@example.com", - role="editor", - tenant="acme-corp", - ) - print("Unassigned role") - - except PermisApiError as e: - print(f"API error: {e.message}") - - # ========================================================================= - # Sync User (Create or Update with Roles) - # ========================================================================= - - print("\n=== Sync User ===") - - try: - # Sync a user with roles - synced_user = permis.sync_user({ - "key": "synced.user@example.com", - "email": "synced.user@example.com", - "first_name": "Synced", - "last_name": "User", - "roles": [ - {"role": "viewer", "tenant": "acme-corp"}, - {"role": "editor", "tenant": "demo-tenant"}, - ], - }) - print(f"Synced user: {synced_user.key}") - - except PermisApiError as e: - print(f"API error: {e.message}") - - # ========================================================================= - # Cleanup - # ========================================================================= - - print("\n=== Cleanup ===") - permis.close() - print("Client closed") - - -if __name__ == "__main__": - main() +""" +Basic usage example for the Permissio.io Python SDK. + +This example demonstrates: +- Client initialization +- Permission checks +- Using enforcement builders +- API operations for users, tenants, and roles +""" + +from permissio import Permissio, ConfigBuilder +from permissio.enforcement import UserBuilder, ResourceBuilder +from permissio.models import UserCreate, TenantCreate, RoleCreate +from permissio.errors import PermissioApiError, PermissioNotFoundError + + +def main(): + # ========================================================================= + # Client Initialization + # ========================================================================= + + # Option 1: Simple initialization + permissio = Permissio( + token="permis_key_your_api_key_here", + project_id="my-project", + environment_id="production", + ) + + # Option 2: Using ConfigBuilder + config = ( + ConfigBuilder("permis_key_your_api_key_here") + .with_project_id("my-project") + .with_environment_id("production") + .with_debug(True) + .with_timeout(30.0) + .build() + ) + permissio = Permissio(config=config) + + # ========================================================================= + # Simple Permission Check + # ========================================================================= + + print("=== Simple Permission Check ===") + + # Check if user can read a document + allowed = permissio.check("user@example.com", "read", "document") + print(f"Can user read document? {allowed}") + + # Check with tenant context + allowed = permissio.check( + "user@example.com", + "write", + "document", + tenant="acme-corp" + ) + print(f"Can user write document in acme-corp? {allowed}") + + # Check with resource instance + allowed = permissio.check( + "user@example.com", + "delete", + {"type": "document", "key": "doc-123"} + ) + print(f"Can user delete doc-123? {allowed}") + + # ========================================================================= + # ABAC Permission Check + # ========================================================================= + + print("\n=== ABAC Permission Check ===") + + # Build user with attributes + user = ( + UserBuilder("user@example.com") + .with_attribute("department", "engineering") + .with_attribute("level", 5) + .with_attribute("location", "US") + .build() + ) + + # Build resource with attributes + resource = ( + ResourceBuilder("document") + .with_key("doc-confidential-123") + .with_tenant("acme-corp") + .with_attribute("classification", "confidential") + .with_attribute("owner_department", "engineering") + .build() + ) + + # Check permission with ABAC + allowed = permissio.check(user, "read", resource) + print(f"Can engineering user (level 5) read confidential doc? {allowed}") + + # Get detailed response + response = permissio.check_with_details(user, "read", resource) + print(f"Allowed: {response.allowed}") + if response.reason: + print(f"Reason: {response.reason}") + + # ========================================================================= + # Bulk Permission Check + # ========================================================================= + + print("\n=== Bulk Permission Check ===") + + checks = [ + {"user": "user1@example.com", "action": "read", "resource": "document"}, + {"user": "user1@example.com", "action": "write", "resource": "document"}, + {"user": "user2@example.com", "action": "read", "resource": "document"}, + {"user": "user2@example.com", "action": "delete", "resource": "document"}, + ] + + results = permissio.bulk_check(checks) + for i, result in enumerate(results.results): + check = checks[i] + print(f"{check['user']} can {check['action']} {check['resource']}: {result.allowed}") + + print(f"All allowed: {results.all_allowed()}") + print(f"Any allowed: {results.any_allowed()}") + + # ========================================================================= + # User Management + # ========================================================================= + + print("\n=== User Management ===") + + try: + # Create a user + new_user = permissio.api.users.create(UserCreate( + key="new.user@example.com", + email="new.user@example.com", + first_name="New", + last_name="User", + attributes={"department": "sales"}, + )) + print(f"Created user: {new_user.key}") + + # List users + users = permissio.api.users.list(page=1, per_page=10) + print(f"Total users: {users.pagination.total}") + for user in users.data: + print(f" - {user.key}") + + # Get a specific user + user = permissio.api.users.get("new.user@example.com") + print(f"Got user: {user.key} ({user.email})") + + # Delete the user + permissio.api.users.delete("new.user@example.com") + print("Deleted user") + + except PermissioNotFoundError as e: + print(f"User not found: {e.message}") + except PermissioApiError as e: + print(f"API error: {e.message}") + + # ========================================================================= + # Tenant Management + # ========================================================================= + + print("\n=== Tenant Management ===") + + try: + # Create a tenant + tenant = permissio.api.tenants.create(TenantCreate( + key="demo-tenant", + name="Demo Tenant", + description="A demo tenant for testing", + )) + print(f"Created tenant: {tenant.key}") + + # Or use convenience method + tenant2 = permissio.create_tenant({ + "key": "demo-tenant-2", + "name": "Demo Tenant 2", + }) + print(f"Created tenant: {tenant2.key}") + + # List tenants + tenants = permissio.api.tenants.list() + print(f"Total tenants: {tenants.pagination.total}") + + except PermissioApiError as e: + print(f"API error: {e.message}") + + # ========================================================================= + # Role Management + # ========================================================================= + + print("\n=== Role Management ===") + + try: + # Create a role + role = permissio.api.roles.create(RoleCreate( + key="editor", + name="Editor", + description="Can read and write documents", + permissions=["document:read", "document:write"], + )) + print(f"Created role: {role.key}") + + # List roles + roles = permissio.api.roles.list() + print(f"Total roles: {roles.pagination.total}") + for role in roles.data: + print(f" - {role.key}: {role.permissions}") + + except PermissioApiError as e: + print(f"API error: {e.message}") + + # ========================================================================= + # Role Assignment + # ========================================================================= + + print("\n=== Role Assignment ===") + + try: + # Assign a role to a user + assignment = permissio.assign_role( + user="user@example.com", + role="editor", + tenant="acme-corp", + ) + print(f"Assigned role: {assignment.role_key} to {assignment.user_key}") + + # Get user's roles + roles = permissio.api.users.get_roles("user@example.com") + print(f"User roles: {[r.role_key for r in roles]}") + + # Unassign the role + permissio.unassign_role( + user="user@example.com", + role="editor", + tenant="acme-corp", + ) + print("Unassigned role") + + except PermissioApiError as e: + print(f"API error: {e.message}") + + # ========================================================================= + # Sync User (Create or Update with Roles) + # ========================================================================= + + print("\n=== Sync User ===") + + try: + # Sync a user with roles + synced_user = permissio.sync_user({ + "key": "synced.user@example.com", + "email": "synced.user@example.com", + "first_name": "Synced", + "last_name": "User", + "roles": [ + {"role": "viewer", "tenant": "acme-corp"}, + {"role": "editor", "tenant": "demo-tenant"}, + ], + }) + print(f"Synced user: {synced_user.key}") + + except PermissioApiError as e: + print(f"API error: {e.message}") + + # ========================================================================= + # Cleanup + # ========================================================================= + + print("\n=== Cleanup ===") + permissio.close() + print("Client closed") + + +if __name__ == "__main__": + main() diff --git a/examples/flask_example.py b/examples/flask_example.py index 694d146..2908cac 100644 --- a/examples/flask_example.py +++ b/examples/flask_example.py @@ -1,402 +1,402 @@ -""" -Flask integration example for the Permis.io Python SDK. - -This example demonstrates: -- Integrating Permis.io with Flask -- Creating permission decorators -- Middleware for permission checking -- Async support with Flask-Async -""" - -from functools import wraps -from typing import Optional, Callable, Any - -from flask import Flask, g, request, jsonify, abort - -from permisio import Permis -from permisio.errors import PermisApiError, PermisNotFoundError - - -# ============================================================================ -# Flask Application Setup -# ============================================================================ - -app = Flask(__name__) - -# Initialize Permis.io client -# In production, load these from environment variables -permis = Permis( - token="permis_key_your_api_key_here", - project_id="my-project", - environment_id="production", -) - - -# ============================================================================ -# Authentication Middleware (Example) -# ============================================================================ - -@app.before_request -def authenticate(): - """ - Example authentication middleware. - Replace with your actual authentication logic. - """ - # Get auth token from header - auth_header = request.headers.get("Authorization", "") - - if auth_header.startswith("Bearer "): - token = auth_header[7:] - # In a real app, validate the token and get user info - # For this example, we'll use a simple mock - g.user_id = token # Use token as user ID for demo - g.user = { - "id": token, - "email": f"{token}@example.com", - "tenant": request.headers.get("X-Tenant-ID", "default"), - } - else: - g.user_id = None - g.user = None - - -# ============================================================================ -# Permission Decorators -# ============================================================================ - -def require_permission(action: str, resource: str, get_resource_key: Optional[Callable] = None): - """ - Decorator to require a specific permission for a route. - - Args: - action: The action to check (e.g., "read", "write", "delete") - resource: The resource type (e.g., "document", "project") - get_resource_key: Optional callable to extract resource key from request - - Example: - @app.route("/documents/") - @require_permission("read", "document", lambda: request.view_args.get("doc_id")) - def get_document(doc_id): - return {"id": doc_id} - """ - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not g.user_id: - abort(401, description="Authentication required") - - # Build resource for check - resource_data = {"type": resource} - - # Add resource key if provided - if get_resource_key: - resource_key = get_resource_key() - if resource_key: - resource_data["key"] = resource_key - - # Add tenant if available - if g.user and g.user.get("tenant"): - resource_data["tenant"] = g.user["tenant"] - - # Check permission - allowed = permis.check(g.user_id, action, resource_data) - - if not allowed: - abort(403, description=f"Permission denied: {action} on {resource}") - - return f(*args, **kwargs) - return decorated_function - return decorator - - -def require_any_permission(*permissions): - """ - Decorator to require any of the specified permissions. - - Args: - permissions: Tuples of (action, resource) - - Example: - @app.route("/documents") - @require_any_permission(("read", "document"), ("admin", "document")) - def list_documents(): - return {"documents": [...]} - """ - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not g.user_id: - abort(401, description="Authentication required") - - # Check each permission - for action, resource in permissions: - resource_data = {"type": resource} - if g.user and g.user.get("tenant"): - resource_data["tenant"] = g.user["tenant"] - - if permis.check(g.user_id, action, resource_data): - return f(*args, **kwargs) - - abort(403, description="Permission denied") - return decorated_function - return decorator - - -def require_all_permissions(*permissions): - """ - Decorator to require all of the specified permissions. - - Args: - permissions: Tuples of (action, resource) - """ - def decorator(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not g.user_id: - abort(401, description="Authentication required") - - # Build bulk check - checks = [] - for action, resource in permissions: - resource_data = {"type": resource} - if g.user and g.user.get("tenant"): - resource_data["tenant"] = g.user["tenant"] - - checks.append({ - "user": g.user_id, - "action": action, - "resource": resource_data, - }) - - # Bulk check - results = permis.bulk_check(checks) - - if not results.all_allowed(): - abort(403, description="Permission denied") - - return f(*args, **kwargs) - return decorated_function - return decorator - - -# ============================================================================ -# Permission Helper Functions -# ============================================================================ - -def can_user(action: str, resource: str, resource_key: Optional[str] = None) -> bool: - """ - Check if the current user can perform an action. - Use this for conditional logic in templates or responses. - """ - if not g.user_id: - return False - - resource_data = {"type": resource} - if resource_key: - resource_data["key"] = resource_key - if g.user and g.user.get("tenant"): - resource_data["tenant"] = g.user["tenant"] - - return permis.check(g.user_id, action, resource_data) - - -# ============================================================================ -# API Routes -# ============================================================================ - -@app.route("/") -def index(): - """Public route - no permission required.""" - return jsonify({ - "message": "Welcome to the Permis.io Flask example", - "endpoints": [ - "GET /documents", - "GET /documents/", - "POST /documents", - "PUT /documents/", - "DELETE /documents/", - "GET /admin/users", - ] - }) - - -@app.route("/documents") -@require_permission("read", "document") -def list_documents(): - """List documents - requires read permission.""" - # In a real app, fetch from database - documents = [ - {"id": "doc-1", "title": "Document 1", "can_edit": can_user("write", "document", "doc-1")}, - {"id": "doc-2", "title": "Document 2", "can_edit": can_user("write", "document", "doc-2")}, - {"id": "doc-3", "title": "Document 3", "can_edit": can_user("write", "document", "doc-3")}, - ] - return jsonify({"documents": documents}) - - -@app.route("/documents/") -@require_permission("read", "document", lambda: request.view_args.get("doc_id")) -def get_document(doc_id): - """Get a specific document - requires read permission on the document.""" - # In a real app, fetch from database - document = { - "id": doc_id, - "title": f"Document {doc_id}", - "content": "This is the document content...", - "permissions": { - "can_read": True, # Already verified by decorator - "can_write": can_user("write", "document", doc_id), - "can_delete": can_user("delete", "document", doc_id), - } - } - return jsonify(document) - - -@app.route("/documents", methods=["POST"]) -@require_permission("create", "document") -def create_document(): - """Create a document - requires create permission.""" - data = request.get_json() - - # In a real app, save to database - new_doc = { - "id": "doc-new", - "title": data.get("title", "Untitled"), - "content": data.get("content", ""), - } - - return jsonify(new_doc), 201 - - -@app.route("/documents/", methods=["PUT"]) -@require_permission("write", "document", lambda: request.view_args.get("doc_id")) -def update_document(doc_id): - """Update a document - requires write permission on the document.""" - data = request.get_json() - - # In a real app, update in database - updated_doc = { - "id": doc_id, - "title": data.get("title", f"Document {doc_id}"), - "content": data.get("content", ""), - } - - return jsonify(updated_doc) - - -@app.route("/documents/", methods=["DELETE"]) -@require_permission("delete", "document", lambda: request.view_args.get("doc_id")) -def delete_document(doc_id): - """Delete a document - requires delete permission on the document.""" - # In a real app, delete from database - return jsonify({"message": f"Document {doc_id} deleted"}), 200 - - -@app.route("/admin/users") -@require_all_permissions(("read", "user"), ("admin", "system")) -def admin_list_users(): - """Admin route - requires both read:user and admin:system permissions.""" - # In a real app, fetch from database - users = [ - {"id": "user-1", "email": "user1@example.com"}, - {"id": "user-2", "email": "user2@example.com"}, - ] - return jsonify({"users": users}) - - -@app.route("/settings") -@require_any_permission(("read", "settings"), ("admin", "system")) -def get_settings(): - """Settings route - requires either read:settings or admin:system.""" - settings = { - "theme": "dark", - "language": "en", - "notifications": True, - } - return jsonify({"settings": settings}) - - -# ============================================================================ -# User Sync Route -# ============================================================================ - -@app.route("/auth/sync", methods=["POST"]) -def sync_user(): - """ - Sync user data with Permis.io after authentication. - Call this after your user logs in to ensure they exist in Permis.io. - """ - if not g.user_id: - abort(401) - - data = request.get_json() or {} - - try: - synced_user = permis.sync_user({ - "key": g.user_id, - "email": data.get("email", g.user.get("email")), - "first_name": data.get("first_name"), - "last_name": data.get("last_name"), - "attributes": data.get("attributes", {}), - }) - - return jsonify({ - "message": "User synced successfully", - "user_key": synced_user.key, - }) - - except PermisApiError as e: - return jsonify({"error": e.message}), e.status_code - - -# ============================================================================ -# Error Handlers -# ============================================================================ - -@app.errorhandler(401) -def unauthorized(error): - return jsonify({ - "error": "Unauthorized", - "message": str(error.description), - }), 401 - - -@app.errorhandler(403) -def forbidden(error): - return jsonify({ - "error": "Forbidden", - "message": str(error.description), - }), 403 - - -@app.errorhandler(404) -def not_found(error): - return jsonify({ - "error": "Not Found", - "message": str(error.description), - }), 404 - - -# ============================================================================ -# Cleanup -# ============================================================================ - -@app.teardown_appcontext -def cleanup(exception=None): - """Cleanup on app context teardown.""" - # Note: In production, you might want to keep the client alive - # and only close it when the app shuts down - pass - - -# ============================================================================ -# Main -# ============================================================================ - -if __name__ == "__main__": - print("Starting Flask app with Permis.io integration...") - print("Try these commands:") - print(" curl http://localhost:5000/") - print(' curl -H "Authorization: Bearer user123" http://localhost:5000/documents') - print(' curl -H "Authorization: Bearer user123" http://localhost:5000/documents/doc-1') - - app.run(debug=True, port=5000) +""" +Flask integration example for the Permissio.io Python SDK. + +This example demonstrates: +- Integrating Permissio.io with Flask +- Creating permission decorators +- Middleware for permission checking +- Async support with Flask-Async +""" + +from functools import wraps +from typing import Optional, Callable, Any + +from flask import Flask, g, request, jsonify, abort + +from permissio import Permissio +from permissio.errors import PermissioApiError, PermissioNotFoundError + + +# ============================================================================ +# Flask Application Setup +# ============================================================================ + +app = Flask(__name__) + +# Initialize Permissio.io client +# In production, load these from environment variables +permissio = Permissio( + token="permis_key_your_api_key_here", + project_id="my-project", + environment_id="production", +) + + +# ============================================================================ +# Authentication Middleware (Example) +# ============================================================================ + +@app.before_request +def authenticate(): + """ + Example authentication middleware. + Replace with your actual authentication logic. + """ + # Get auth token from header + auth_header = request.headers.get("Authorization", "") + + if auth_header.startswith("Bearer "): + token = auth_header[7:] + # In a real app, validate the token and get user info + # For this example, we'll use a simple mock + g.user_id = token # Use token as user ID for demo + g.user = { + "id": token, + "email": f"{token}@example.com", + "tenant": request.headers.get("X-Tenant-ID", "default"), + } + else: + g.user_id = None + g.user = None + + +# ============================================================================ +# Permission Decorators +# ============================================================================ + +def require_permission(action: str, resource: str, get_resource_key: Optional[Callable] = None): + """ + Decorator to require a specific permission for a route. + + Args: + action: The action to check (e.g., "read", "write", "delete") + resource: The resource type (e.g., "document", "project") + get_resource_key: Optional callable to extract resource key from request + + Example: + @app.route("/documents/") + @require_permission("read", "document", lambda: request.view_args.get("doc_id")) + def get_document(doc_id): + return {"id": doc_id} + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not g.user_id: + abort(401, description="Authentication required") + + # Build resource for check + resource_data = {"type": resource} + + # Add resource key if provided + if get_resource_key: + resource_key = get_resource_key() + if resource_key: + resource_data["key"] = resource_key + + # Add tenant if available + if g.user and g.user.get("tenant"): + resource_data["tenant"] = g.user["tenant"] + + # Check permission + allowed = permissio.check(g.user_id, action, resource_data) + + if not allowed: + abort(403, description=f"Permission denied: {action} on {resource}") + + return f(*args, **kwargs) + return decorated_function + return decorator + + +def require_any_permission(*permissions): + """ + Decorator to require any of the specified permissions. + + Args: + permissions: Tuples of (action, resource) + + Example: + @app.route("/documents") + @require_any_permission(("read", "document"), ("admin", "document")) + def list_documents(): + return {"documents": [...]} + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not g.user_id: + abort(401, description="Authentication required") + + # Check each permission + for action, resource in permissions: + resource_data = {"type": resource} + if g.user and g.user.get("tenant"): + resource_data["tenant"] = g.user["tenant"] + + if permissio.check(g.user_id, action, resource_data): + return f(*args, **kwargs) + + abort(403, description="Permission denied") + return decorated_function + return decorator + + +def require_all_permissions(*permissions): + """ + Decorator to require all of the specified permissions. + + Args: + permissions: Tuples of (action, resource) + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not g.user_id: + abort(401, description="Authentication required") + + # Build bulk check + checks = [] + for action, resource in permissions: + resource_data = {"type": resource} + if g.user and g.user.get("tenant"): + resource_data["tenant"] = g.user["tenant"] + + checks.append({ + "user": g.user_id, + "action": action, + "resource": resource_data, + }) + + # Bulk check + results = permissio.bulk_check(checks) + + if not results.all_allowed(): + abort(403, description="Permission denied") + + return f(*args, **kwargs) + return decorated_function + return decorator + + +# ============================================================================ +# Permission Helper Functions +# ============================================================================ + +def can_user(action: str, resource: str, resource_key: Optional[str] = None) -> bool: + """ + Check if the current user can perform an action. + Use this for conditional logic in templates or responses. + """ + if not g.user_id: + return False + + resource_data = {"type": resource} + if resource_key: + resource_data["key"] = resource_key + if g.user and g.user.get("tenant"): + resource_data["tenant"] = g.user["tenant"] + + return permissio.check(g.user_id, action, resource_data) + + +# ============================================================================ +# API Routes +# ============================================================================ + +@app.route("/") +def index(): + """Public route - no permission required.""" + return jsonify({ + "message": "Welcome to the Permissio.io Flask example", + "endpoints": [ + "GET /documents", + "GET /documents/", + "POST /documents", + "PUT /documents/", + "DELETE /documents/", + "GET /admin/users", + ] + }) + + +@app.route("/documents") +@require_permission("read", "document") +def list_documents(): + """List documents - requires read permission.""" + # In a real app, fetch from database + documents = [ + {"id": "doc-1", "title": "Document 1", "can_edit": can_user("write", "document", "doc-1")}, + {"id": "doc-2", "title": "Document 2", "can_edit": can_user("write", "document", "doc-2")}, + {"id": "doc-3", "title": "Document 3", "can_edit": can_user("write", "document", "doc-3")}, + ] + return jsonify({"documents": documents}) + + +@app.route("/documents/") +@require_permission("read", "document", lambda: request.view_args.get("doc_id")) +def get_document(doc_id): + """Get a specific document - requires read permission on the document.""" + # In a real app, fetch from database + document = { + "id": doc_id, + "title": f"Document {doc_id}", + "content": "This is the document content...", + "permissions": { + "can_read": True, # Already verified by decorator + "can_write": can_user("write", "document", doc_id), + "can_delete": can_user("delete", "document", doc_id), + } + } + return jsonify(document) + + +@app.route("/documents", methods=["POST"]) +@require_permission("create", "document") +def create_document(): + """Create a document - requires create permission.""" + data = request.get_json() + + # In a real app, save to database + new_doc = { + "id": "doc-new", + "title": data.get("title", "Untitled"), + "content": data.get("content", ""), + } + + return jsonify(new_doc), 201 + + +@app.route("/documents/", methods=["PUT"]) +@require_permission("write", "document", lambda: request.view_args.get("doc_id")) +def update_document(doc_id): + """Update a document - requires write permission on the document.""" + data = request.get_json() + + # In a real app, update in database + updated_doc = { + "id": doc_id, + "title": data.get("title", f"Document {doc_id}"), + "content": data.get("content", ""), + } + + return jsonify(updated_doc) + + +@app.route("/documents/", methods=["DELETE"]) +@require_permission("delete", "document", lambda: request.view_args.get("doc_id")) +def delete_document(doc_id): + """Delete a document - requires delete permission on the document.""" + # In a real app, delete from database + return jsonify({"message": f"Document {doc_id} deleted"}), 200 + + +@app.route("/admin/users") +@require_all_permissions(("read", "user"), ("admin", "system")) +def admin_list_users(): + """Admin route - requires both read:user and admin:system permissions.""" + # In a real app, fetch from database + users = [ + {"id": "user-1", "email": "user1@example.com"}, + {"id": "user-2", "email": "user2@example.com"}, + ] + return jsonify({"users": users}) + + +@app.route("/settings") +@require_any_permission(("read", "settings"), ("admin", "system")) +def get_settings(): + """Settings route - requires either read:settings or admin:system.""" + settings = { + "theme": "dark", + "language": "en", + "notifications": True, + } + return jsonify({"settings": settings}) + + +# ============================================================================ +# User Sync Route +# ============================================================================ + +@app.route("/auth/sync", methods=["POST"]) +def sync_user(): + """ + Sync user data with Permissio.io after authentication. + Call this after your user logs in to ensure they exist in Permissio.io. + """ + if not g.user_id: + abort(401) + + data = request.get_json() or {} + + try: + synced_user = permissio.sync_user({ + "key": g.user_id, + "email": data.get("email", g.user.get("email")), + "first_name": data.get("first_name"), + "last_name": data.get("last_name"), + "attributes": data.get("attributes", {}), + }) + + return jsonify({ + "message": "User synced successfully", + "user_key": synced_user.key, + }) + + except PermissioApiError as e: + return jsonify({"error": e.message}), e.status_code + + +# ============================================================================ +# Error Handlers +# ============================================================================ + +@app.errorhandler(401) +def unauthorized(error): + return jsonify({ + "error": "Unauthorized", + "message": str(error.description), + }), 401 + + +@app.errorhandler(403) +def forbidden(error): + return jsonify({ + "error": "Forbidden", + "message": str(error.description), + }), 403 + + +@app.errorhandler(404) +def not_found(error): + return jsonify({ + "error": "Not Found", + "message": str(error.description), + }), 404 + + +# ============================================================================ +# Cleanup +# ============================================================================ + +@app.teardown_appcontext +def cleanup(exception=None): + """Cleanup on app context teardown.""" + # Note: In production, you might want to keep the client alive + # and only close it when the app shuts down + pass + + +# ============================================================================ +# Main +# ============================================================================ + +if __name__ == "__main__": + print("Starting Flask app with Permissio.io integration...") + print("Try these commands:") + print(" curl http://localhost:5000/") + print(' curl -H "Authorization: Bearer user123" http://localhost:5000/documents') + print(' curl -H "Authorization: Bearer user123" http://localhost:5000/documents/doc-1') + + app.run(debug=True, port=5000) diff --git a/examples/test_local.py b/examples/test_local.py index 716ce05..0bed5e2 100644 --- a/examples/test_local.py +++ b/examples/test_local.py @@ -1,172 +1,179 @@ -""" -Test script to verify the Python SDK works with the local Permis.io backend. - -Before running: -1. Start the backend: cd backend && make docker-dev -2. Create an API key in the UI or use one you have -3. Update the configuration below with your values - -Usage: - python examples/test_local.py -""" - -import os -import sys - -# Add parent directory to path for development -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from permisio import Permis, ConfigBuilder -from permisio.models import UserCreate, TenantCreate, RoleCreate -from permisio.errors import PermisApiError, PermisNotFoundError - - -# ============================================================================ -# Configuration - UPDATE THESE VALUES -# ============================================================================ - -# Your API key (get from UI or database) -API_KEY = os.environ.get("PERMIS_API_KEY", "permis_key_your_key_here") - -# Your project and environment IDs -PROJECT_ID = os.environ.get("PERMIS_PROJECT_ID", "default") -ENVIRONMENT_ID = os.environ.get("PERMIS_ENVIRONMENT_ID", "development") - -# Local backend URL -API_URL = os.environ.get("PERMIS_API_URL", "http://localhost:8080") - - -def main(): - print("=" * 60) - print("Permis.io Python SDK - Local Backend Test") - print("=" * 60) - print(f"\nConfiguration:") - print(f" API URL: {API_URL}") - print(f" Project: {PROJECT_ID}") - print(f" Environment: {ENVIRONMENT_ID}") - print(f" API Key: {API_KEY[:20]}..." if len(API_KEY) > 20 else f" API Key: {API_KEY}") - print() - - # Initialize client - config = ( - ConfigBuilder(API_KEY) - .with_api_url(API_URL) - .with_project_id(PROJECT_ID) - .with_environment_id(ENVIRONMENT_ID) - .with_debug(True) - .build() - ) - - permis = Permis(config=config) - - try: - # ===================================================================== - # Test 1: List Users - # ===================================================================== - print("\n--- Test 1: List Users ---") - try: - users = permis.api.users.list(page=1, per_page=5) - print(f"✓ Found {users.pagination.total} users") - for user in users.data[:3]: - print(f" - {user.key}") - except PermisApiError as e: - print(f"✗ Error: {e.message} (status: {e.status_code})") - - # ===================================================================== - # Test 2: List Tenants - # ===================================================================== - print("\n--- Test 2: List Tenants ---") - try: - tenants = permis.api.tenants.list(page=1, per_page=5) - print(f"✓ Found {tenants.pagination.total} tenants") - for tenant in tenants.data[:3]: - print(f" - {tenant.key}: {tenant.name}") - except PermisApiError as e: - print(f"✗ Error: {e.message} (status: {e.status_code})") - - # ===================================================================== - # Test 3: List Roles - # ===================================================================== - print("\n--- Test 3: List Roles ---") - try: - roles = permis.api.roles.list(page=1, per_page=5) - print(f"✓ Found {roles.pagination.total} roles") - for role in roles.data[:3]: - print(f" - {role.key}: {role.name}") - except PermisApiError as e: - print(f"✗ Error: {e.message} (status: {e.status_code})") - - # ===================================================================== - # Test 4: List Resources - # ===================================================================== - print("\n--- Test 4: List Resources ---") - try: - resources = permis.api.resources.list(page=1, per_page=5) - print(f"✓ Found {resources.pagination.total} resources") - for resource in resources.data[:3]: - print(f" - {resource.key}: {resource.name}") - except PermisApiError as e: - print(f"✗ Error: {e.message} (status: {e.status_code})") - - # ===================================================================== - # Test 5: Create and Delete User - # ===================================================================== - print("\n--- Test 5: Create and Delete User ---") - test_user_key = "sdk-test-user@example.com" - try: - # Create - new_user = permis.api.users.create(UserCreate( - key=test_user_key, - email=test_user_key, - first_name="SDK", - last_name="Test", - )) - print(f"✓ Created user: {new_user.key}") - - # Get - fetched = permis.api.users.get(test_user_key) - print(f"✓ Fetched user: {fetched.key}") - - # Delete - permis.api.users.delete(test_user_key) - print(f"✓ Deleted user: {test_user_key}") - - except PermisApiError as e: - print(f"✗ Error: {e.message} (status: {e.status_code})") - # Try to clean up - try: - permis.api.users.delete(test_user_key) - except: - pass - - # ===================================================================== - # Test 6: Permission Check - # ===================================================================== - print("\n--- Test 6: Permission Check ---") - try: - # Get a user and resource to test with - users = permis.api.users.list(per_page=1) - resources = permis.api.resources.list(per_page=1) - - if users.data and resources.data: - user_key = users.data[0].key - resource_key = resources.data[0].key - - allowed = permis.check(user_key, "read", resource_key) - print(f"✓ Check result: {user_key} can read {resource_key}: {allowed}") - else: - print("⚠ No users or resources found for permission check test") - - except PermisApiError as e: - print(f"✗ Error: {e.message} (status: {e.status_code})") - - print("\n" + "=" * 60) - print("Tests completed!") - print("=" * 60) - - finally: - permis.close() - - -if __name__ == "__main__": - main() +""" +Test script to verify the Python SDK works with the local Permissio.io backend. + +Before running: +1. Start the backend: cd backend && make docker-dev +2. Create an API key in the UI or use one you have +3. Update the configuration below with your values + +Usage: + python examples/test_local.py +""" + +import os +import sys + +# Add parent directory to path for development +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from permissio import Permissio, ConfigBuilder +from permissio.models import UserCreate, TenantCreate, RoleCreate +from permissio.errors import PermissioApiError, PermissioNotFoundError + + +# ============================================================================ +# Configuration - UPDATE THESE VALUES +# ============================================================================ + +# Your API key (get from UI or database) +API_KEY = os.environ.get("PERMIS_API_KEY", "permis_key_your_key_here") + +# Your project and environment IDs +PROJECT_ID = os.environ.get("PERMIS_PROJECT_ID", "default") +ENVIRONMENT_ID = os.environ.get("PERMIS_ENVIRONMENT_ID", "development") + +# Local backend URL +API_URL = os.environ.get("PERMIS_API_URL", "http://localhost:8080") + + +def main(): + print("=" * 60) + print("Permissio.io Python SDK - Local Backend Test") + print("=" * 60) + print(f"\nConfiguration:") + print(f" API URL: {API_URL}") + print(f" Project: {PROJECT_ID}") + print(f" Environment: {ENVIRONMENT_ID}") + print(f" API Key: {API_KEY[:20]}..." if len(API_KEY) > 20 else f" API Key: {API_KEY}") + print() + + # Initialize client - let SDK auto-fetch project/environment from API key + config = ( + ConfigBuilder(API_KEY) + .with_api_url(API_URL) + .with_debug(True) + .build() + ) + + permissio = Permissio(config=config) + + # Initialize the SDK to fetch project/environment scope from API key + try: + permissio.init() + print(f" Auto-fetched Project: {permissio.config.project_id}") + print(f" Auto-fetched Environment: {permissio.config.environment_id}") + except Exception as e: + print(f"✗ Failed to initialize SDK: {e}") + return + + try: + # ===================================================================== + # Test 1: List Users + # ===================================================================== + print("\n--- Test 1: List Users ---") + try: + users = permissio.api.users.list(page=1, per_page=5) + print(f"✓ Found {users.pagination.total} users") + for user in users.data[:3]: + print(f" - {user.key}") + except PermissioApiError as e: + print(f"✗ Error: {e.message} (status: {e.status_code})") + + # ===================================================================== + # Test 2: List Tenants + # ===================================================================== + print("\n--- Test 2: List Tenants ---") + try: + tenants = permissio.api.tenants.list(page=1, per_page=5) + print(f"✓ Found {tenants.pagination.total} tenants") + for tenant in tenants.data[:3]: + print(f" - {tenant.key}: {tenant.name}") + except PermissioApiError as e: + print(f"✗ Error: {e.message} (status: {e.status_code})") + + # ===================================================================== + # Test 3: List Roles + # ===================================================================== + print("\n--- Test 3: List Roles ---") + try: + roles = permissio.api.roles.list(page=1, per_page=5) + print(f"✓ Found {roles.pagination.total} roles") + for role in roles.data[:3]: + print(f" - {role.key}: {role.name}") + except PermissioApiError as e: + print(f"✗ Error: {e.message} (status: {e.status_code})") + + # ===================================================================== + # Test 4: List Resources + # ===================================================================== + print("\n--- Test 4: List Resources ---") + try: + resources = permissio.api.resources.list(page=1, per_page=5) + print(f"✓ Found {resources.pagination.total} resources") + for resource in resources.data[:3]: + print(f" - {resource.key}: {resource.name}") + except PermissioApiError as e: + print(f"✗ Error: {e.message} (status: {e.status_code})") + + # ===================================================================== + # Test 5: Create and Delete User + # ===================================================================== + print("\n--- Test 5: Create and Delete User ---") + test_user_key = "sdk-test-user@example.com" + try: + # Create + new_user = permissio.api.users.create(UserCreate( + key=test_user_key, + email=test_user_key, + first_name="SDK", + last_name="Test", + )) + print(f"✓ Created user: {new_user.key}") + + # Get + fetched = permissio.api.users.get(test_user_key) + print(f"✓ Fetched user: {fetched.key}") + + # Delete + permissio.api.users.delete(test_user_key) + print(f"✓ Deleted user: {test_user_key}") + + except PermissioApiError as e: + print(f"✗ Error: {e.message} (status: {e.status_code})") + # Try to clean up + try: + permissio.api.users.delete(test_user_key) + except: + pass + + # ===================================================================== + # Test 6: Permission Check + # ===================================================================== + print("\n--- Test 6: Permission Check ---") + try: + # Get a user and resource to test with + users = permissio.api.users.list(per_page=1) + resources = permissio.api.resources.list(per_page=1) + + if users.data and resources.data: + user_key = users.data[0].key + resource_key = resources.data[0].key + + allowed = permissio.check(user_key, "read", resource_key) + print(f"✓ Check result: {user_key} can read {resource_key}: {allowed}") + else: + print("⚠ No users or resources found for permission check test") + + except PermissioApiError as e: + print(f"✗ Error: {e.message} (status: {e.status_code})") + + print("\n" + "=" * 60) + print("Tests completed!") + print("=" * 60) + + finally: + permissio.close() + + +if __name__ == "__main__": + main() diff --git a/permisio/__init__.py b/permisio/__init__.py deleted file mode 100644 index 4746696..0000000 --- a/permisio/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Permis.io Python SDK - -A Python SDK for interacting with the Permis.io authorization platform. -""" - -from permisio.client import Permis -from permisio.config import PermisConfig, ConfigBuilder -from permisio.errors import PermisError, PermisApiError, PermisValidationError - -__version__ = "0.1.0" -__all__ = [ - "Permis", - "PermisConfig", - "ConfigBuilder", - "PermisError", - "PermisApiError", - "PermisValidationError", -] diff --git a/permisio/api/__init__.py b/permisio/api/__init__.py deleted file mode 100644 index 221941f..0000000 --- a/permisio/api/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Permis.io API clients. -""" - -from permisio.api.base import BaseApiClient -from permisio.api.users import UsersApi -from permisio.api.tenants import TenantsApi -from permisio.api.roles import RolesApi -from permisio.api.resources import ResourcesApi -from permisio.api.role_assignments import RoleAssignmentsApi - -__all__ = [ - "BaseApiClient", - "UsersApi", - "TenantsApi", - "RolesApi", - "ResourcesApi", - "RoleAssignmentsApi", -] diff --git a/permisio/models/__init__.py b/permisio/models/__init__.py deleted file mode 100644 index d904e3b..0000000 --- a/permisio/models/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Permis.io data models. -""" - -from permisio.models.common import PaginatedResponse, Pagination -from permisio.models.user import User, UserCreate, UserUpdate, UserRead -from permisio.models.tenant import Tenant, TenantCreate, TenantUpdate, TenantRead -from permisio.models.role import Role, RoleCreate, RoleUpdate, RoleRead -from permisio.models.resource import Resource, ResourceCreate, ResourceUpdate, ResourceRead -from permisio.models.role_assignment import RoleAssignment, RoleAssignmentCreate, RoleAssignmentRead -from permisio.models.check import CheckRequest, CheckResponse - -__all__ = [ - # Common - "PaginatedResponse", - "Pagination", - # User - "User", - "UserCreate", - "UserUpdate", - "UserRead", - # Tenant - "Tenant", - "TenantCreate", - "TenantUpdate", - "TenantRead", - # Role - "Role", - "RoleCreate", - "RoleUpdate", - "RoleRead", - # Resource - "Resource", - "ResourceCreate", - "ResourceUpdate", - "ResourceRead", - # Role Assignment - "RoleAssignment", - "RoleAssignmentCreate", - "RoleAssignmentRead", - # Check - "CheckRequest", - "CheckResponse", -] diff --git a/permissio/__init__.py b/permissio/__init__.py new file mode 100644 index 0000000..242e0b5 --- /dev/null +++ b/permissio/__init__.py @@ -0,0 +1,19 @@ +""" +Permissio.io Python SDK + +A Python SDK for interacting with the Permissio.io authorization platform. +""" + +from permissio.client import Permissio +from permissio.config import PermissioConfig, ConfigBuilder +from permissio.errors import PermissioError, PermissioApiError, PermissioValidationError + +__version__ = "0.1.0" +__all__ = [ + "Permissio", + "PermissioConfig", + "ConfigBuilder", + "PermissioError", + "PermissioApiError", + "PermissioValidationError", +] diff --git a/permissio/api/__init__.py b/permissio/api/__init__.py new file mode 100644 index 0000000..7d315d6 --- /dev/null +++ b/permissio/api/__init__.py @@ -0,0 +1,19 @@ +""" +Permissio.io API clients. +""" + +from permissio.api.base import BaseApiClient +from permissio.api.users import UsersApi +from permissio.api.tenants import TenantsApi +from permissio.api.roles import RolesApi +from permissio.api.resources import ResourcesApi +from permissio.api.role_assignments import RoleAssignmentsApi + +__all__ = [ + "BaseApiClient", + "UsersApi", + "TenantsApi", + "RolesApi", + "ResourcesApi", + "RoleAssignmentsApi", +] diff --git a/permisio/api/base.py b/permissio/api/base.py similarity index 86% rename from permisio/api/base.py rename to permissio/api/base.py index d5b3c79..c065b65 100644 --- a/permisio/api/base.py +++ b/permissio/api/base.py @@ -1,470 +1,470 @@ -""" -Base API client for the Permis.io SDK. -""" - -import time -import logging -from typing import Optional, Dict, Any, TypeVar, Type, List, Union -from urllib.parse import urljoin - -import httpx - -from permisio.config import PermisConfig -from permisio.errors import ( - PermisApiError, - PermisNetworkError, - PermisTimeoutError, - PermisRateLimitError, - PermisAuthenticationError, - PermisPermissionError, - PermisNotFoundError, - PermisConflictError, -) - -T = TypeVar("T") - -logger = logging.getLogger("permisio") - - -class BaseApiClient: - """ - Base API client with HTTP utilities, retry logic, and error handling. - - This class provides the foundation for all resource-specific API clients. - """ - - # API URL patterns - FACTS_API_PREFIX = "/v1/facts" - SCHEMA_API_PREFIX = "/v1/schema" - ALLOWED_API_PREFIX = "/v1/allowed" - - def __init__(self, config: PermisConfig) -> None: - """ - Initialize the base API client. - - Args: - config: The SDK configuration. - """ - self.config = config - self._client: Optional[httpx.Client] = None - self._async_client: Optional[httpx.AsyncClient] = None - - @property - def client(self) -> httpx.Client: - """Get or create the synchronous HTTP client.""" - if self._client is None: - if self.config.http_client is not None: - self._client = self.config.http_client - else: - self._client = httpx.Client( - timeout=httpx.Timeout(self.config.timeout), - headers=self._get_default_headers(), - ) - return self._client - - @property - def async_client(self) -> httpx.AsyncClient: - """Get or create the asynchronous HTTP client.""" - if self._async_client is None: - self._async_client = httpx.AsyncClient( - timeout=httpx.Timeout(self.config.timeout), - headers=self._get_default_headers(), - ) - return self._async_client - - def _get_default_headers(self) -> Dict[str, str]: - """Get default headers for all requests.""" - headers = { - "Authorization": f"Bearer {self.config.token}", - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": "permisio-python/0.1.0", - } - headers.update(self.config.custom_headers) - return headers - - def _build_facts_url(self, path: str) -> str: - """ - Build a Facts API URL. - - Args: - path: The path after the project/environment segment. - - Returns: - The full URL. - - Raises: - ValueError: If project_id or environment_id is not set. - """ - if not self.config.has_scope(): - raise ValueError("project_id and environment_id are required for this operation") - - base_path = f"{self.FACTS_API_PREFIX}/{self.config.project_id}/{self.config.environment_id}" - full_path = f"{base_path}/{path.lstrip('/')}" if path else base_path - return urljoin(self.config.api_url + "/", full_path.lstrip("/")) - - def _build_schema_url(self, path: str) -> str: - """ - Build a Schema API URL. - - Args: - path: The path after the project/environment segment. - - Returns: - The full URL. - - Raises: - ValueError: If project_id or environment_id is not set. - """ - if not self.config.has_scope(): - raise ValueError("project_id and environment_id are required for this operation") - - base_path = f"{self.SCHEMA_API_PREFIX}/{self.config.project_id}/{self.config.environment_id}" - full_path = f"{base_path}/{path.lstrip('/')}" if path else base_path - return urljoin(self.config.api_url + "/", full_path.lstrip("/")) - - def _build_allowed_url(self) -> str: - """ - Build the Allowed (permission check) API URL. - - Returns: - The full URL. - - Raises: - ValueError: If project_id or environment_id is not set. - """ - if not self.config.has_scope(): - raise ValueError("project_id and environment_id are required for this operation") - - path = f"{self.ALLOWED_API_PREFIX}/{self.config.project_id}/{self.config.environment_id}" - return urljoin(self.config.api_url + "/", path.lstrip("/")) - - def _build_url(self, path: str) -> str: - """ - Build a generic API URL (no project/environment scope). - - Args: - path: The API path. - - Returns: - The full URL. - """ - return urljoin(self.config.api_url + "/", path.lstrip("/")) - - def _handle_error_response(self, response: httpx.Response) -> None: - """ - Handle error responses from the API. - - Args: - response: The HTTP response. - - Raises: - PermisApiError: An appropriate error based on the status code. - """ - status_code = response.status_code - request_id = response.headers.get("X-Request-ID") - - # Try to parse error body - try: - error_body = response.json() - message = error_body.get("message", error_body.get("error", response.text)) - code = error_body.get("code") - details = error_body.get("details", {}) - except Exception: - message = response.text or f"HTTP {status_code} error" - code = None - details = {} - - # Raise appropriate error type - if status_code == 401: - raise PermisAuthenticationError(message=message, details=details, request_id=request_id) - elif status_code == 403: - raise PermisPermissionError(message=message, details=details, request_id=request_id) - elif status_code == 404: - raise PermisNotFoundError(message=message, details=details, request_id=request_id) - elif status_code == 409: - raise PermisConflictError(message=message, details=details, request_id=request_id) - elif status_code == 429: - retry_after = response.headers.get("Retry-After") - retry_after_int = int(retry_after) if retry_after and retry_after.isdigit() else None - raise PermisRateLimitError( - message=message, retry_after=retry_after_int, details=details, request_id=request_id - ) - else: - raise PermisApiError( - message=message, status_code=status_code, code=code, details=details, request_id=request_id - ) - - def _should_retry(self, exception: Exception, attempt: int) -> bool: - """ - Determine if a request should be retried. - - Args: - exception: The exception that occurred. - attempt: The current attempt number (0-indexed). - - Returns: - True if the request should be retried. - """ - if attempt >= self.config.retry_attempts: - return False - - # Retry on network errors - if isinstance(exception, (PermisNetworkError, PermisTimeoutError)): - return True - - # Retry on specific API errors - if isinstance(exception, PermisApiError) and exception.is_retryable: - return True - - return False - - def _calculate_retry_delay(self, attempt: int, exception: Optional[Exception] = None) -> float: - """ - Calculate the delay before retrying a request. - - Uses exponential backoff with a base of 1 second. - - Args: - attempt: The current attempt number (0-indexed). - exception: The exception that caused the retry (optional). - - Returns: - The delay in seconds. - """ - # If rate limited with Retry-After header, use that - if isinstance(exception, PermisRateLimitError) and exception.retry_after: - return float(exception.retry_after) - - # Exponential backoff: 1s, 2s, 4s, 8s, ... - return min(2**attempt, 30) # Cap at 30 seconds - - def _log_request(self, method: str, url: str, body: Optional[Dict] = None) -> None: - """Log a request if debug mode is enabled.""" - if self.config.debug: - logger.debug(f"Request: {method} {url}") - if body: - logger.debug(f"Body: {body}") - - def _log_response(self, response: httpx.Response) -> None: - """Log a response if debug mode is enabled.""" - if self.config.debug: - logger.debug(f"Response: {response.status_code}") - try: - logger.debug(f"Body: {response.json()}") - except Exception: - logger.debug(f"Body: {response.text}") - - def request( - self, - method: str, - url: str, - *, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - ) -> httpx.Response: - """ - Make a synchronous HTTP request with retry logic. - - Args: - method: HTTP method (GET, POST, PUT, PATCH, DELETE). - url: The full URL. - json: Request body as JSON. - params: Query parameters. - - Returns: - The HTTP response. - - Raises: - PermisApiError: If the request fails after all retries. - """ - self._log_request(method, url, json) - - last_exception: Optional[Exception] = None - for attempt in range(self.config.retry_attempts + 1): - try: - response = self.client.request( - method=method, - url=url, - json=json, - params=self._clean_params(params), - ) - self._log_response(response) - - if response.is_success: - return response - else: - self._handle_error_response(response) - - except httpx.TimeoutException as e: - last_exception = PermisTimeoutError(original_error=e) - except httpx.NetworkError as e: - last_exception = PermisNetworkError(f"Network error: {e}", original_error=e) - except (PermisApiError, PermisNetworkError, PermisTimeoutError) as e: - last_exception = e - - if last_exception and self._should_retry(last_exception, attempt): - delay = self._calculate_retry_delay(attempt, last_exception) - if self.config.debug: - logger.debug(f"Retrying in {delay}s (attempt {attempt + 1})") - time.sleep(delay) - elif last_exception: - if self.config.throw_on_error: - raise last_exception - else: - # Return a mock response for non-throwing mode - return httpx.Response( - status_code=getattr(last_exception, "status_code", 500), - content=str(last_exception).encode(), - ) - - # This shouldn't be reached, but just in case - if last_exception: - raise last_exception - raise PermisNetworkError("Request failed with unknown error") - - async def request_async( - self, - method: str, - url: str, - *, - json: Optional[Dict[str, Any]] = None, - params: Optional[Dict[str, Any]] = None, - ) -> httpx.Response: - """ - Make an asynchronous HTTP request with retry logic. - - Args: - method: HTTP method (GET, POST, PUT, PATCH, DELETE). - url: The full URL. - json: Request body as JSON. - params: Query parameters. - - Returns: - The HTTP response. - - Raises: - PermisApiError: If the request fails after all retries. - """ - import asyncio - - self._log_request(method, url, json) - - last_exception: Optional[Exception] = None - for attempt in range(self.config.retry_attempts + 1): - try: - response = await self.async_client.request( - method=method, - url=url, - json=json, - params=self._clean_params(params), - ) - self._log_response(response) - - if response.is_success: - return response - else: - self._handle_error_response(response) - - except httpx.TimeoutException as e: - last_exception = PermisTimeoutError(original_error=e) - except httpx.NetworkError as e: - last_exception = PermisNetworkError(f"Network error: {e}", original_error=e) - except (PermisApiError, PermisNetworkError, PermisTimeoutError) as e: - last_exception = e - - if last_exception and self._should_retry(last_exception, attempt): - delay = self._calculate_retry_delay(attempt, last_exception) - if self.config.debug: - logger.debug(f"Retrying in {delay}s (attempt {attempt + 1})") - await asyncio.sleep(delay) - elif last_exception: - if self.config.throw_on_error: - raise last_exception - else: - return httpx.Response( - status_code=getattr(last_exception, "status_code", 500), - content=str(last_exception).encode(), - ) - - if last_exception: - raise last_exception - raise PermisNetworkError("Request failed with unknown error") - - def _clean_params(self, params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - """ - Clean query parameters by removing None values. - - Args: - params: The query parameters. - - Returns: - The cleaned parameters. - """ - if params is None: - return None - return {k: v for k, v in params.items() if v is not None} - - def get(self, url: str, *, params: Optional[Dict[str, Any]] = None) -> httpx.Response: - """Make a GET request.""" - return self.request("GET", url, params=params) - - def post(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: - """Make a POST request.""" - return self.request("POST", url, json=json) - - def put(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: - """Make a PUT request.""" - return self.request("PUT", url, json=json) - - def patch(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: - """Make a PATCH request.""" - return self.request("PATCH", url, json=json) - - def delete(self, url: str) -> httpx.Response: - """Make a DELETE request.""" - return self.request("DELETE", url) - - async def get_async(self, url: str, *, params: Optional[Dict[str, Any]] = None) -> httpx.Response: - """Make an async GET request.""" - return await self.request_async("GET", url, params=params) - - async def post_async(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: - """Make an async POST request.""" - return await self.request_async("POST", url, json=json) - - async def put_async(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: - """Make an async PUT request.""" - return await self.request_async("PUT", url, json=json) - - async def patch_async(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: - """Make an async PATCH request.""" - return await self.request_async("PATCH", url, json=json) - - async def delete_async(self, url: str) -> httpx.Response: - """Make an async DELETE request.""" - return await self.request_async("DELETE", url) - - def close(self) -> None: - """Close the HTTP client.""" - if self._client is not None: - self._client.close() - self._client = None - - async def close_async(self) -> None: - """Close the async HTTP client.""" - if self._async_client is not None: - await self._async_client.aclose() - self._async_client = None - - def __enter__(self) -> "BaseApiClient": - return self - - def __exit__(self, *args: Any) -> None: - self.close() - - async def __aenter__(self) -> "BaseApiClient": - return self - - async def __aexit__(self, *args: Any) -> None: - await self.close_async() +""" +Base API client for the Permissio.io SDK. +""" + +import time +import logging +from typing import Optional, Dict, Any, TypeVar, Type, List, Union +from urllib.parse import urljoin + +import httpx + +from permissio.config import PermissioConfig +from permissio.errors import ( + PermissioApiError, + PermissioNetworkError, + PermissioTimeoutError, + PermissioRateLimitError, + PermissioAuthenticationError, + PermissioPermissionError, + PermissioNotFoundError, + PermissioConflictError, +) + +T = TypeVar("T") + +logger = logging.getLogger("permisio") + + +class BaseApiClient: + """ + Base API client with HTTP utilities, retry logic, and error handling. + + This class provides the foundation for all resource-specific API clients. + """ + + # API URL patterns + FACTS_API_PREFIX = "/v1/facts" + SCHEMA_API_PREFIX = "/v1/schema" + ALLOWED_API_PREFIX = "/v1/allowed" + + def __init__(self, config: PermissioConfig) -> None: + """ + Initialize the base API client. + + Args: + config: The SDK configuration. + """ + self.config = config + self._client: Optional[httpx.Client] = None + self._async_client: Optional[httpx.AsyncClient] = None + + @property + def client(self) -> httpx.Client: + """Get or create the synchronous HTTP client.""" + if self._client is None: + if self.config.http_client is not None: + self._client = self.config.http_client + else: + self._client = httpx.Client( + timeout=httpx.Timeout(self.config.timeout), + headers=self._get_default_headers(), + ) + return self._client + + @property + def async_client(self) -> httpx.AsyncClient: + """Get or create the asynchronous HTTP client.""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + timeout=httpx.Timeout(self.config.timeout), + headers=self._get_default_headers(), + ) + return self._async_client + + def _get_default_headers(self) -> Dict[str, str]: + """Get default headers for all requests.""" + headers = { + "Authorization": f"Bearer {self.config.token}", + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "permisio-python/0.1.0", + } + headers.update(self.config.custom_headers) + return headers + + def _build_facts_url(self, path: str) -> str: + """ + Build a Facts API URL. + + Args: + path: The path after the project/environment segment. + + Returns: + The full URL. + + Raises: + ValueError: If project_id or environment_id is not set. + """ + if not self.config.has_scope(): + raise ValueError("project_id and environment_id are required for this operation") + + base_path = f"{self.FACTS_API_PREFIX}/{self.config.project_id}/{self.config.environment_id}" + full_path = f"{base_path}/{path.lstrip('/')}" if path else base_path + return urljoin(self.config.api_url + "/", full_path.lstrip("/")) + + def _build_schema_url(self, path: str) -> str: + """ + Build a Schema API URL. + + Args: + path: The path after the project/environment segment. + + Returns: + The full URL. + + Raises: + ValueError: If project_id or environment_id is not set. + """ + if not self.config.has_scope(): + raise ValueError("project_id and environment_id are required for this operation") + + base_path = f"{self.SCHEMA_API_PREFIX}/{self.config.project_id}/{self.config.environment_id}" + full_path = f"{base_path}/{path.lstrip('/')}" if path else base_path + return urljoin(self.config.api_url + "/", full_path.lstrip("/")) + + def _build_allowed_url(self) -> str: + """ + Build the Allowed (permission check) API URL. + + Returns: + The full URL. + + Raises: + ValueError: If project_id or environment_id is not set. + """ + if not self.config.has_scope(): + raise ValueError("project_id and environment_id are required for this operation") + + path = f"{self.ALLOWED_API_PREFIX}/{self.config.project_id}/{self.config.environment_id}" + return urljoin(self.config.api_url + "/", path.lstrip("/")) + + def _build_url(self, path: str) -> str: + """ + Build a generic API URL (no project/environment scope). + + Args: + path: The API path. + + Returns: + The full URL. + """ + return urljoin(self.config.api_url + "/", path.lstrip("/")) + + def _handle_error_response(self, response: httpx.Response) -> None: + """ + Handle error responses from the API. + + Args: + response: The HTTP response. + + Raises: + PermissioApiError: An appropriate error based on the status code. + """ + status_code = response.status_code + request_id = response.headers.get("X-Request-ID") + + # Try to parse error body + try: + error_body = response.json() + message = error_body.get("message", error_body.get("error", response.text)) + code = error_body.get("code") + details = error_body.get("details", {}) + except Exception: + message = response.text or f"HTTP {status_code} error" + code = None + details = {} + + # Raise appropriate error type + if status_code == 401: + raise PermissioAuthenticationError(message=message, details=details, request_id=request_id) + elif status_code == 403: + raise PermissioPermissionError(message=message, details=details, request_id=request_id) + elif status_code == 404: + raise PermissioNotFoundError(message=message, details=details, request_id=request_id) + elif status_code == 409: + raise PermissioConflictError(message=message, details=details, request_id=request_id) + elif status_code == 429: + retry_after = response.headers.get("Retry-After") + retry_after_int = int(retry_after) if retry_after and retry_after.isdigit() else None + raise PermissioRateLimitError( + message=message, retry_after=retry_after_int, details=details, request_id=request_id + ) + else: + raise PermissioApiError( + message=message, status_code=status_code, code=code, details=details, request_id=request_id + ) + + def _should_retry(self, exception: Exception, attempt: int) -> bool: + """ + Determine if a request should be retried. + + Args: + exception: The exception that occurred. + attempt: The current attempt number (0-indexed). + + Returns: + True if the request should be retried. + """ + if attempt >= self.config.retry_attempts: + return False + + # Retry on network errors + if isinstance(exception, (PermissioNetworkError, PermissioTimeoutError)): + return True + + # Retry on specific API errors + if isinstance(exception, PermissioApiError) and exception.is_retryable: + return True + + return False + + def _calculate_retry_delay(self, attempt: int, exception: Optional[Exception] = None) -> float: + """ + Calculate the delay before retrying a request. + + Uses exponential backoff with a base of 1 second. + + Args: + attempt: The current attempt number (0-indexed). + exception: The exception that caused the retry (optional). + + Returns: + The delay in seconds. + """ + # If rate limited with Retry-After header, use that + if isinstance(exception, PermissioRateLimitError) and exception.retry_after: + return float(exception.retry_after) + + # Exponential backoff: 1s, 2s, 4s, 8s, ... + return min(2**attempt, 30) # Cap at 30 seconds + + def _log_request(self, method: str, url: str, body: Optional[Dict] = None) -> None: + """Log a request if debug mode is enabled.""" + if self.config.debug: + logger.debug(f"Request: {method} {url}") + if body: + logger.debug(f"Body: {body}") + + def _log_response(self, response: httpx.Response) -> None: + """Log a response if debug mode is enabled.""" + if self.config.debug: + logger.debug(f"Response: {response.status_code}") + try: + logger.debug(f"Body: {response.json()}") + except Exception: + logger.debug(f"Body: {response.text}") + + def request( + self, + method: str, + url: str, + *, + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> httpx.Response: + """ + Make a synchronous HTTP request with retry logic. + + Args: + method: HTTP method (GET, POST, PUT, PATCH, DELETE). + url: The full URL. + json: Request body as JSON. + params: Query parameters. + + Returns: + The HTTP response. + + Raises: + PermissioApiError: If the request fails after all retries. + """ + self._log_request(method, url, json) + + last_exception: Optional[Exception] = None + for attempt in range(self.config.retry_attempts + 1): + try: + response = self.client.request( + method=method, + url=url, + json=json, + params=self._clean_params(params), + ) + self._log_response(response) + + if response.is_success: + return response + else: + self._handle_error_response(response) + + except httpx.TimeoutException as e: + last_exception = PermissioTimeoutError(original_error=e) + except httpx.NetworkError as e: + last_exception = PermissioNetworkError(f"Network error: {e}", original_error=e) + except (PermissioApiError, PermissioNetworkError, PermissioTimeoutError) as e: + last_exception = e + + if last_exception and self._should_retry(last_exception, attempt): + delay = self._calculate_retry_delay(attempt, last_exception) + if self.config.debug: + logger.debug(f"Retrying in {delay}s (attempt {attempt + 1})") + time.sleep(delay) + elif last_exception: + if self.config.throw_on_error: + raise last_exception + else: + # Return a mock response for non-throwing mode + return httpx.Response( + status_code=getattr(last_exception, "status_code", 500), + content=str(last_exception).encode(), + ) + + # This shouldn't be reached, but just in case + if last_exception: + raise last_exception + raise PermissioNetworkError("Request failed with unknown error") + + async def request_async( + self, + method: str, + url: str, + *, + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> httpx.Response: + """ + Make an asynchronous HTTP request with retry logic. + + Args: + method: HTTP method (GET, POST, PUT, PATCH, DELETE). + url: The full URL. + json: Request body as JSON. + params: Query parameters. + + Returns: + The HTTP response. + + Raises: + PermissioApiError: If the request fails after all retries. + """ + import asyncio + + self._log_request(method, url, json) + + last_exception: Optional[Exception] = None + for attempt in range(self.config.retry_attempts + 1): + try: + response = await self.async_client.request( + method=method, + url=url, + json=json, + params=self._clean_params(params), + ) + self._log_response(response) + + if response.is_success: + return response + else: + self._handle_error_response(response) + + except httpx.TimeoutException as e: + last_exception = PermissioTimeoutError(original_error=e) + except httpx.NetworkError as e: + last_exception = PermissioNetworkError(f"Network error: {e}", original_error=e) + except (PermissioApiError, PermissioNetworkError, PermissioTimeoutError) as e: + last_exception = e + + if last_exception and self._should_retry(last_exception, attempt): + delay = self._calculate_retry_delay(attempt, last_exception) + if self.config.debug: + logger.debug(f"Retrying in {delay}s (attempt {attempt + 1})") + await asyncio.sleep(delay) + elif last_exception: + if self.config.throw_on_error: + raise last_exception + else: + return httpx.Response( + status_code=getattr(last_exception, "status_code", 500), + content=str(last_exception).encode(), + ) + + if last_exception: + raise last_exception + raise PermissioNetworkError("Request failed with unknown error") + + def _clean_params(self, params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """ + Clean query parameters by removing None values. + + Args: + params: The query parameters. + + Returns: + The cleaned parameters. + """ + if params is None: + return None + return {k: v for k, v in params.items() if v is not None} + + def get(self, url: str, *, params: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Make a GET request.""" + return self.request("GET", url, params=params) + + def post(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Make a POST request.""" + return self.request("POST", url, json=json) + + def put(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Make a PUT request.""" + return self.request("PUT", url, json=json) + + def patch(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Make a PATCH request.""" + return self.request("PATCH", url, json=json) + + def delete(self, url: str) -> httpx.Response: + """Make a DELETE request.""" + return self.request("DELETE", url) + + async def get_async(self, url: str, *, params: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Make an async GET request.""" + return await self.request_async("GET", url, params=params) + + async def post_async(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Make an async POST request.""" + return await self.request_async("POST", url, json=json) + + async def put_async(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Make an async PUT request.""" + return await self.request_async("PUT", url, json=json) + + async def patch_async(self, url: str, *, json: Optional[Dict[str, Any]] = None) -> httpx.Response: + """Make an async PATCH request.""" + return await self.request_async("PATCH", url, json=json) + + async def delete_async(self, url: str) -> httpx.Response: + """Make an async DELETE request.""" + return await self.request_async("DELETE", url) + + def close(self) -> None: + """Close the HTTP client.""" + if self._client is not None: + self._client.close() + self._client = None + + async def close_async(self) -> None: + """Close the async HTTP client.""" + if self._async_client is not None: + await self._async_client.aclose() + self._async_client = None + + def __enter__(self) -> "BaseApiClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + async def __aenter__(self) -> "BaseApiClient": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close_async() diff --git a/permisio/api/resources.py b/permissio/api/resources.py similarity index 94% rename from permisio/api/resources.py rename to permissio/api/resources.py index 3f62288..ebc0e03 100644 --- a/permisio/api/resources.py +++ b/permissio/api/resources.py @@ -1,413 +1,415 @@ -""" -Resources API client for the Permis.io SDK. -""" - -from typing import Optional, Dict, Any, Union, List - -from permisio.api.base import BaseApiClient -from permisio.config import PermisConfig -from permisio.models.resource import ( - Resource, - ResourceCreate, - ResourceUpdate, - ResourceRead, - ResourceAction, - ResourceAttribute, -) -from permisio.models.common import PaginatedResponse - - -class ResourcesApi(BaseApiClient): - """ - API client for resource type operations. - - Provides methods for creating, reading, updating, and deleting resource types, - as well as managing resource actions and attributes. - Resources are part of the Schema API. - """ - - def __init__(self, config: PermisConfig) -> None: - """Initialize the Resources API client.""" - super().__init__(config) - - def list( - self, - *, - page: int = 1, - per_page: int = 10, - search: Optional[str] = None, - ) -> PaginatedResponse[ResourceRead]: - """ - List resource types. - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - search: Search query string. - - Returns: - Paginated list of resources. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if search: - params["search"] = search - - url = self._build_schema_url("resources") - response = self.get(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, ResourceRead.from_dict) - - async def list_async( - self, - *, - page: int = 1, - per_page: int = 10, - search: Optional[str] = None, - ) -> PaginatedResponse[ResourceRead]: - """ - List resource types (async). - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - search: Search query string. - - Returns: - Paginated list of resources. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if search: - params["search"] = search - - url = self._build_schema_url("resources") - response = await self.get_async(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, ResourceRead.from_dict) - - def get(self, resource_key: str) -> ResourceRead: - """ - Get a resource type by key. - - Args: - resource_key: The resource key. - - Returns: - The resource. - """ - url = self._build_schema_url(f"resources/{resource_key}") - response = super().get(url) - return ResourceRead.from_dict(response.json()) - - async def get_async(self, resource_key: str) -> ResourceRead: - """ - Get a resource type by key (async). - - Args: - resource_key: The resource key. - - Returns: - The resource. - """ - url = self._build_schema_url(f"resources/{resource_key}") - response = await super().get_async(url) - return ResourceRead.from_dict(response.json()) - - def create(self, resource: Union[ResourceCreate, Dict[str, Any]]) -> ResourceRead: - """ - Create a new resource type. - - Args: - resource: Resource data. - - Returns: - The created resource. - """ - if isinstance(resource, ResourceCreate): - resource_data = resource.to_dict() - else: - resource_data = resource - - url = self._build_schema_url("resources") - response = self.post(url, json=resource_data) - return ResourceRead.from_dict(response.json()) - - async def create_async(self, resource: Union[ResourceCreate, Dict[str, Any]]) -> ResourceRead: - """ - Create a new resource type (async). - - Args: - resource: Resource data. - - Returns: - The created resource. - """ - if isinstance(resource, ResourceCreate): - resource_data = resource.to_dict() - else: - resource_data = resource - - url = self._build_schema_url("resources") - response = await self.post_async(url, json=resource_data) - return ResourceRead.from_dict(response.json()) - - def update(self, resource_key: str, resource: Union[ResourceUpdate, Dict[str, Any]]) -> ResourceRead: - """ - Update an existing resource type. - - Args: - resource_key: The resource key. - resource: Resource update data. - - Returns: - The updated resource. - """ - if isinstance(resource, ResourceUpdate): - resource_data = resource.to_dict() - else: - resource_data = resource - - url = self._build_schema_url(f"resources/{resource_key}") - response = self.patch(url, json=resource_data) - return ResourceRead.from_dict(response.json()) - - async def update_async(self, resource_key: str, resource: Union[ResourceUpdate, Dict[str, Any]]) -> ResourceRead: - """ - Update an existing resource type (async). - - Args: - resource_key: The resource key. - resource: Resource update data. - - Returns: - The updated resource. - """ - if isinstance(resource, ResourceUpdate): - resource_data = resource.to_dict() - else: - resource_data = resource - - url = self._build_schema_url(f"resources/{resource_key}") - response = await self.patch_async(url, json=resource_data) - return ResourceRead.from_dict(response.json()) - - def delete(self, resource_key: str) -> None: - """ - Delete a resource type. - - Args: - resource_key: The resource key. - """ - url = self._build_schema_url(f"resources/{resource_key}") - super().delete(url) - - async def delete_async(self, resource_key: str) -> None: - """ - Delete a resource type (async). - - Args: - resource_key: The resource key. - """ - url = self._build_schema_url(f"resources/{resource_key}") - await super().delete_async(url) - - # Action management - - def list_actions(self, resource_key: str) -> List[ResourceAction]: - """ - List actions for a resource type. - - Args: - resource_key: The resource key. - - Returns: - List of actions. - """ - url = self._build_schema_url(f"resources/{resource_key}/actions") - response = super().get(url) - data = response.json() - if isinstance(data, list): - return [ResourceAction.from_dict(a) for a in data] - return [ResourceAction.from_dict(a) for a in data.get("data", [])] - - async def list_actions_async(self, resource_key: str) -> List[ResourceAction]: - """ - List actions for a resource type (async). - - Args: - resource_key: The resource key. - - Returns: - List of actions. - """ - url = self._build_schema_url(f"resources/{resource_key}/actions") - response = await super().get_async(url) - data = response.json() - if isinstance(data, list): - return [ResourceAction.from_dict(a) for a in data] - return [ResourceAction.from_dict(a) for a in data.get("data", [])] - - def create_action(self, resource_key: str, action: Union[ResourceAction, Dict[str, Any]]) -> ResourceAction: - """ - Create an action for a resource type. - - Args: - resource_key: The resource key. - action: Action data. - - Returns: - The created action. - """ - if isinstance(action, ResourceAction): - action_data = action.to_dict() - else: - action_data = action - - url = self._build_schema_url(f"resources/{resource_key}/actions") - response = self.post(url, json=action_data) - return ResourceAction.from_dict(response.json()) - - async def create_action_async(self, resource_key: str, action: Union[ResourceAction, Dict[str, Any]]) -> ResourceAction: - """ - Create an action for a resource type (async). - - Args: - resource_key: The resource key. - action: Action data. - - Returns: - The created action. - """ - if isinstance(action, ResourceAction): - action_data = action.to_dict() - else: - action_data = action - - url = self._build_schema_url(f"resources/{resource_key}/actions") - response = await self.post_async(url, json=action_data) - return ResourceAction.from_dict(response.json()) - - def delete_action(self, resource_key: str, action_key: str) -> None: - """ - Delete an action from a resource type. - - Args: - resource_key: The resource key. - action_key: The action key. - """ - url = self._build_schema_url(f"resources/{resource_key}/actions/{action_key}") - super().delete(url) - - async def delete_action_async(self, resource_key: str, action_key: str) -> None: - """ - Delete an action from a resource type (async). - - Args: - resource_key: The resource key. - action_key: The action key. - """ - url = self._build_schema_url(f"resources/{resource_key}/actions/{action_key}") - await super().delete_async(url) - - # Attribute management - - def list_attributes(self, resource_key: str) -> List[ResourceAttribute]: - """ - List attributes for a resource type. - - Args: - resource_key: The resource key. - - Returns: - List of attributes. - """ - url = self._build_schema_url(f"resources/{resource_key}/attributes") - response = super().get(url) - data = response.json() - if isinstance(data, list): - return [ResourceAttribute.from_dict(a) for a in data] - return [ResourceAttribute.from_dict(a) for a in data.get("data", [])] - - async def list_attributes_async(self, resource_key: str) -> List[ResourceAttribute]: - """ - List attributes for a resource type (async). - - Args: - resource_key: The resource key. - - Returns: - List of attributes. - """ - url = self._build_schema_url(f"resources/{resource_key}/attributes") - response = await super().get_async(url) - data = response.json() - if isinstance(data, list): - return [ResourceAttribute.from_dict(a) for a in data] - return [ResourceAttribute.from_dict(a) for a in data.get("data", [])] - - def create_attribute(self, resource_key: str, attribute: Union[ResourceAttribute, Dict[str, Any]]) -> ResourceAttribute: - """ - Create an attribute for a resource type. - - Args: - resource_key: The resource key. - attribute: Attribute data. - - Returns: - The created attribute. - """ - if isinstance(attribute, ResourceAttribute): - attribute_data = attribute.to_dict() - else: - attribute_data = attribute - - url = self._build_schema_url(f"resources/{resource_key}/attributes") - response = self.post(url, json=attribute_data) - return ResourceAttribute.from_dict(response.json()) - - async def create_attribute_async(self, resource_key: str, attribute: Union[ResourceAttribute, Dict[str, Any]]) -> ResourceAttribute: - """ - Create an attribute for a resource type (async). - - Args: - resource_key: The resource key. - attribute: Attribute data. - - Returns: - The created attribute. - """ - if isinstance(attribute, ResourceAttribute): - attribute_data = attribute.to_dict() - else: - attribute_data = attribute - - url = self._build_schema_url(f"resources/{resource_key}/attributes") - response = await self.post_async(url, json=attribute_data) - return ResourceAttribute.from_dict(response.json()) - - def delete_attribute(self, resource_key: str, attribute_key: str) -> None: - """ - Delete an attribute from a resource type. - - Args: - resource_key: The resource key. - attribute_key: The attribute key. - """ - url = self._build_schema_url(f"resources/{resource_key}/attributes/{attribute_key}") - super().delete(url) - - async def delete_attribute_async(self, resource_key: str, attribute_key: str) -> None: - """ - Delete an attribute from a resource type (async). - - Args: - resource_key: The resource key. - attribute_key: The attribute key. - """ - url = self._build_schema_url(f"resources/{resource_key}/attributes/{attribute_key}") - await super().delete_async(url) +""" +Resources API client for the Permissio.io SDK. +""" + +from typing import Optional, Dict, Any, Union, List + +from permissio.api.base import BaseApiClient +from permissio.config import PermissioConfig +from permissio.models.resource import ( + Resource, + ResourceCreate, + ResourceUpdate, + ResourceRead, + ResourceAction, + ResourceAttribute, +) +from permissio.models.common import PaginatedResponse + + +class ResourcesApi(BaseApiClient): + """ + API client for resource type operations. + + Provides methods for creating, reading, updating, and deleting resource types, + as well as managing resource actions and attributes. + Resources are part of the Schema API. + """ + + def __init__(self, config: PermissioConfig) -> None: + """Initialize the Resources API client.""" + super().__init__(config) + + def list( + self, + *, + page: int = 1, + per_page: int = 10, + search: Optional[str] = None, + ) -> PaginatedResponse[ResourceRead]: + """ + List resource types. + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + search: Search query string. + + Returns: + Paginated list of resources. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include_total_count": "true", + } + if search: + params["search"] = search + + url = self._build_schema_url("resources") + response = self.request("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, ResourceRead.from_dict) + + async def list_async( + self, + *, + page: int = 1, + per_page: int = 10, + search: Optional[str] = None, + ) -> PaginatedResponse[ResourceRead]: + """ + List resource types (async). + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + search: Search query string. + + Returns: + Paginated list of resources. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include_total_count": "true", + } + if search: + params["search"] = search + + url = self._build_schema_url("resources") + response = await self.request_async("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, ResourceRead.from_dict) + + def get(self, resource_key: str) -> ResourceRead: + """ + Get a resource type by key. + + Args: + resource_key: The resource key. + + Returns: + The resource. + """ + url = self._build_schema_url(f"resources/{resource_key}") + response = super().get(url) + return ResourceRead.from_dict(response.json()) + + async def get_async(self, resource_key: str) -> ResourceRead: + """ + Get a resource type by key (async). + + Args: + resource_key: The resource key. + + Returns: + The resource. + """ + url = self._build_schema_url(f"resources/{resource_key}") + response = await super().get_async(url) + return ResourceRead.from_dict(response.json()) + + def create(self, resource: Union[ResourceCreate, Dict[str, Any]]) -> ResourceRead: + """ + Create a new resource type. + + Args: + resource: Resource data. + + Returns: + The created resource. + """ + if isinstance(resource, ResourceCreate): + resource_data = resource.to_dict() + else: + resource_data = resource + + url = self._build_schema_url("resources") + response = self.post(url, json=resource_data) + return ResourceRead.from_dict(response.json()) + + async def create_async(self, resource: Union[ResourceCreate, Dict[str, Any]]) -> ResourceRead: + """ + Create a new resource type (async). + + Args: + resource: Resource data. + + Returns: + The created resource. + """ + if isinstance(resource, ResourceCreate): + resource_data = resource.to_dict() + else: + resource_data = resource + + url = self._build_schema_url("resources") + response = await self.post_async(url, json=resource_data) + return ResourceRead.from_dict(response.json()) + + def update(self, resource_key: str, resource: Union[ResourceUpdate, Dict[str, Any]]) -> ResourceRead: + """ + Update an existing resource type. + + Args: + resource_key: The resource key. + resource: Resource update data. + + Returns: + The updated resource. + """ + if isinstance(resource, ResourceUpdate): + resource_data = resource.to_dict() + else: + resource_data = resource + + url = self._build_schema_url(f"resources/{resource_key}") + response = self.patch(url, json=resource_data) + return ResourceRead.from_dict(response.json()) + + async def update_async(self, resource_key: str, resource: Union[ResourceUpdate, Dict[str, Any]]) -> ResourceRead: + """ + Update an existing resource type (async). + + Args: + resource_key: The resource key. + resource: Resource update data. + + Returns: + The updated resource. + """ + if isinstance(resource, ResourceUpdate): + resource_data = resource.to_dict() + else: + resource_data = resource + + url = self._build_schema_url(f"resources/{resource_key}") + response = await self.patch_async(url, json=resource_data) + return ResourceRead.from_dict(response.json()) + + def delete(self, resource_key: str) -> None: + """ + Delete a resource type. + + Args: + resource_key: The resource key. + """ + url = self._build_schema_url(f"resources/{resource_key}") + super().delete(url) + + async def delete_async(self, resource_key: str) -> None: + """ + Delete a resource type (async). + + Args: + resource_key: The resource key. + """ + url = self._build_schema_url(f"resources/{resource_key}") + await super().delete_async(url) + + # Action management + + def list_actions(self, resource_key: str) -> List[ResourceAction]: + """ + List actions for a resource type. + + Args: + resource_key: The resource key. + + Returns: + List of actions. + """ + url = self._build_schema_url(f"resources/{resource_key}/actions") + response = super().get(url) + data = response.json() + if isinstance(data, list): + return [ResourceAction.from_dict(a) for a in data] + return [ResourceAction.from_dict(a) for a in data.get("data", [])] + + async def list_actions_async(self, resource_key: str) -> List[ResourceAction]: + """ + List actions for a resource type (async). + + Args: + resource_key: The resource key. + + Returns: + List of actions. + """ + url = self._build_schema_url(f"resources/{resource_key}/actions") + response = await super().get_async(url) + data = response.json() + if isinstance(data, list): + return [ResourceAction.from_dict(a) for a in data] + return [ResourceAction.from_dict(a) for a in data.get("data", [])] + + def create_action(self, resource_key: str, action: Union[ResourceAction, Dict[str, Any]]) -> ResourceAction: + """ + Create an action for a resource type. + + Args: + resource_key: The resource key. + action: Action data. + + Returns: + The created action. + """ + if isinstance(action, ResourceAction): + action_data = action.to_dict() + else: + action_data = action + + url = self._build_schema_url(f"resources/{resource_key}/actions") + response = self.post(url, json=action_data) + return ResourceAction.from_dict(response.json()) + + async def create_action_async(self, resource_key: str, action: Union[ResourceAction, Dict[str, Any]]) -> ResourceAction: + """ + Create an action for a resource type (async). + + Args: + resource_key: The resource key. + action: Action data. + + Returns: + The created action. + """ + if isinstance(action, ResourceAction): + action_data = action.to_dict() + else: + action_data = action + + url = self._build_schema_url(f"resources/{resource_key}/actions") + response = await self.post_async(url, json=action_data) + return ResourceAction.from_dict(response.json()) + + def delete_action(self, resource_key: str, action_key: str) -> None: + """ + Delete an action from a resource type. + + Args: + resource_key: The resource key. + action_key: The action key. + """ + url = self._build_schema_url(f"resources/{resource_key}/actions/{action_key}") + super().delete(url) + + async def delete_action_async(self, resource_key: str, action_key: str) -> None: + """ + Delete an action from a resource type (async). + + Args: + resource_key: The resource key. + action_key: The action key. + """ + url = self._build_schema_url(f"resources/{resource_key}/actions/{action_key}") + await super().delete_async(url) + + # Attribute management + + def list_attributes(self, resource_key: str) -> List[ResourceAttribute]: + """ + List attributes for a resource type. + + Args: + resource_key: The resource key. + + Returns: + List of attributes. + """ + url = self._build_schema_url(f"resources/{resource_key}/attributes") + response = super().get(url) + data = response.json() + if isinstance(data, list): + return [ResourceAttribute.from_dict(a) for a in data] + return [ResourceAttribute.from_dict(a) for a in data.get("data", [])] + + async def list_attributes_async(self, resource_key: str) -> List[ResourceAttribute]: + """ + List attributes for a resource type (async). + + Args: + resource_key: The resource key. + + Returns: + List of attributes. + """ + url = self._build_schema_url(f"resources/{resource_key}/attributes") + response = await super().get_async(url) + data = response.json() + if isinstance(data, list): + return [ResourceAttribute.from_dict(a) for a in data] + return [ResourceAttribute.from_dict(a) for a in data.get("data", [])] + + def create_attribute(self, resource_key: str, attribute: Union[ResourceAttribute, Dict[str, Any]]) -> ResourceAttribute: + """ + Create an attribute for a resource type. + + Args: + resource_key: The resource key. + attribute: Attribute data. + + Returns: + The created attribute. + """ + if isinstance(attribute, ResourceAttribute): + attribute_data = attribute.to_dict() + else: + attribute_data = attribute + + url = self._build_schema_url(f"resources/{resource_key}/attributes") + response = self.post(url, json=attribute_data) + return ResourceAttribute.from_dict(response.json()) + + async def create_attribute_async(self, resource_key: str, attribute: Union[ResourceAttribute, Dict[str, Any]]) -> ResourceAttribute: + """ + Create an attribute for a resource type (async). + + Args: + resource_key: The resource key. + attribute: Attribute data. + + Returns: + The created attribute. + """ + if isinstance(attribute, ResourceAttribute): + attribute_data = attribute.to_dict() + else: + attribute_data = attribute + + url = self._build_schema_url(f"resources/{resource_key}/attributes") + response = await self.post_async(url, json=attribute_data) + return ResourceAttribute.from_dict(response.json()) + + def delete_attribute(self, resource_key: str, attribute_key: str) -> None: + """ + Delete an attribute from a resource type. + + Args: + resource_key: The resource key. + attribute_key: The attribute key. + """ + url = self._build_schema_url(f"resources/{resource_key}/attributes/{attribute_key}") + super().delete(url) + + async def delete_attribute_async(self, resource_key: str, attribute_key: str) -> None: + """ + Delete an attribute from a resource type (async). + + Args: + resource_key: The resource key. + attribute_key: The attribute key. + """ + url = self._build_schema_url(f"resources/{resource_key}/attributes/{attribute_key}") + await super().delete_async(url) diff --git a/permisio/api/role_assignments.py b/permissio/api/role_assignments.py similarity index 93% rename from permisio/api/role_assignments.py rename to permissio/api/role_assignments.py index c374104..fb43a09 100644 --- a/permisio/api/role_assignments.py +++ b/permissio/api/role_assignments.py @@ -1,389 +1,390 @@ -""" -Role Assignments API client for the Permis.io SDK. -""" - -from typing import Optional, Dict, Any, Union, List - -from permisio.api.base import BaseApiClient -from permisio.config import PermisConfig -from permisio.models.role_assignment import ( - RoleAssignment, - RoleAssignmentCreate, - RoleAssignmentRead, - BulkRoleAssignment, -) -from permisio.models.common import PaginatedResponse - - -class RoleAssignmentsApi(BaseApiClient): - """ - API client for role assignment operations. - - Provides methods for creating, reading, and deleting role assignments. - """ - - def __init__(self, config: PermisConfig) -> None: - """Initialize the Role Assignments API client.""" - super().__init__(config) - - def list( - self, - *, - page: int = 1, - per_page: int = 10, - user: Optional[str] = None, - role: Optional[str] = None, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> PaginatedResponse[RoleAssignmentRead]: - """ - List role assignments. - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - user: Filter by user key. - role: Filter by role key. - tenant: Filter by tenant key. - resource_instance: Filter by resource instance key. - - Returns: - Paginated list of role assignments. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if user: - params["user"] = user - if role: - params["role"] = role - if tenant: - params["tenant"] = tenant - if resource_instance: - params["resource_instance"] = resource_instance - - url = self._build_facts_url("role_assignments") - response = self.get(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, RoleAssignmentRead.from_dict) - - async def list_async( - self, - *, - page: int = 1, - per_page: int = 10, - user: Optional[str] = None, - role: Optional[str] = None, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> PaginatedResponse[RoleAssignmentRead]: - """ - List role assignments (async). - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - user: Filter by user key. - role: Filter by role key. - tenant: Filter by tenant key. - resource_instance: Filter by resource instance key. - - Returns: - Paginated list of role assignments. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if user: - params["user"] = user - if role: - params["role"] = role - if tenant: - params["tenant"] = tenant - if resource_instance: - params["resource_instance"] = resource_instance - - url = self._build_facts_url("role_assignments") - response = await self.get_async(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, RoleAssignmentRead.from_dict) - - def assign( - self, - user: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> RoleAssignmentRead: - """ - Assign a role to a user. - - Args: - user: The user key. - role: The role key. - tenant: The tenant key (optional). - resource_instance: The resource instance key (optional). - - Returns: - The created role assignment. - """ - assignment = RoleAssignmentCreate( - user=user, - role=role, - tenant=tenant, - resource_instance=resource_instance, - ) - url = self._build_facts_url("role_assignments") - response = self.post(url, json=assignment.to_dict()) - return RoleAssignmentRead.from_dict(response.json()) - - async def assign_async( - self, - user: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> RoleAssignmentRead: - """ - Assign a role to a user (async). - - Args: - user: The user key. - role: The role key. - tenant: The tenant key (optional). - resource_instance: The resource instance key (optional). - - Returns: - The created role assignment. - """ - assignment = RoleAssignmentCreate( - user=user, - role=role, - tenant=tenant, - resource_instance=resource_instance, - ) - url = self._build_facts_url("role_assignments") - response = await self.post_async(url, json=assignment.to_dict()) - return RoleAssignmentRead.from_dict(response.json()) - - def unassign( - self, - user: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> None: - """ - Unassign a role from a user. - - Args: - user: The user key. - role: The role key. - tenant: The tenant key (optional). - resource_instance: The resource instance key (optional). - """ - params: Dict[str, Any] = { - "user": user, - "role": role, - } - if tenant: - params["tenant"] = tenant - if resource_instance: - params["resource_instance"] = resource_instance - - url = self._build_facts_url("role_assignments") - self.request("DELETE", url, params=params) - - async def unassign_async( - self, - user: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> None: - """ - Unassign a role from a user (async). - - Args: - user: The user key. - role: The role key. - tenant: The tenant key (optional). - resource_instance: The resource instance key (optional). - """ - params: Dict[str, Any] = { - "user": user, - "role": role, - } - if tenant: - params["tenant"] = tenant - if resource_instance: - params["resource_instance"] = resource_instance - - url = self._build_facts_url("role_assignments") - await self.request_async("DELETE", url, params=params) - - def bulk_assign(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, Any]]]) -> List[RoleAssignmentRead]: - """ - Bulk assign roles to users. - - Args: - assignments: List of role assignments to create. - - Returns: - List of created role assignments. - """ - assignment_dicts = [] - for a in assignments: - if isinstance(a, RoleAssignmentCreate): - assignment_dicts.append(a.to_dict()) - else: - assignment_dicts.append(a) - - url = self._build_facts_url("role_assignments/bulk") - response = self.post(url, json={"assignments": assignment_dicts}) - data = response.json() - - if isinstance(data, list): - return [RoleAssignmentRead.from_dict(item) for item in data] - return [RoleAssignmentRead.from_dict(item) for item in data.get("data", [])] - - async def bulk_assign_async(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, Any]]]) -> List[RoleAssignmentRead]: - """ - Bulk assign roles to users (async). - - Args: - assignments: List of role assignments to create. - - Returns: - List of created role assignments. - """ - assignment_dicts = [] - for a in assignments: - if isinstance(a, RoleAssignmentCreate): - assignment_dicts.append(a.to_dict()) - else: - assignment_dicts.append(a) - - url = self._build_facts_url("role_assignments/bulk") - response = await self.post_async(url, json={"assignments": assignment_dicts}) - data = response.json() - - if isinstance(data, list): - return [RoleAssignmentRead.from_dict(item) for item in data] - return [RoleAssignmentRead.from_dict(item) for item in data.get("data", [])] - - def bulk_unassign(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, Any]]]) -> None: - """ - Bulk unassign roles from users. - - Args: - assignments: List of role assignments to remove. - """ - assignment_dicts = [] - for a in assignments: - if isinstance(a, RoleAssignmentCreate): - assignment_dicts.append(a.to_dict()) - else: - assignment_dicts.append(a) - - url = self._build_facts_url("role_assignments/bulk") - self.request("DELETE", url, json={"assignments": assignment_dicts}) - - async def bulk_unassign_async(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, Any]]]) -> None: - """ - Bulk unassign roles from users (async). - - Args: - assignments: List of role assignments to remove. - """ - assignment_dicts = [] - for a in assignments: - if isinstance(a, RoleAssignmentCreate): - assignment_dicts.append(a.to_dict()) - else: - assignment_dicts.append(a) - - url = self._build_facts_url("role_assignments/bulk") - await self.request_async("DELETE", url, json={"assignments": assignment_dicts}) - - def list_detailed( - self, - *, - page: int = 1, - per_page: int = 10, - user: Optional[str] = None, - role: Optional[str] = None, - tenant: Optional[str] = None, - ) -> PaginatedResponse[RoleAssignmentRead]: - """ - List role assignments with detailed information. - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - user: Filter by user key. - role: Filter by role key. - tenant: Filter by tenant key. - - Returns: - Paginated list of detailed role assignments. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if user: - params["user"] = user - if role: - params["role"] = role - if tenant: - params["tenant"] = tenant - - url = self._build_facts_url("role_assignments/detailed") - response = self.get(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, RoleAssignmentRead.from_dict) - - async def list_detailed_async( - self, - *, - page: int = 1, - per_page: int = 10, - user: Optional[str] = None, - role: Optional[str] = None, - tenant: Optional[str] = None, - ) -> PaginatedResponse[RoleAssignmentRead]: - """ - List role assignments with detailed information (async). - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - user: Filter by user key. - role: Filter by role key. - tenant: Filter by tenant key. - - Returns: - Paginated list of detailed role assignments. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if user: - params["user"] = user - if role: - params["role"] = role - if tenant: - params["tenant"] = tenant - - url = self._build_facts_url("role_assignments/detailed") - response = await self.get_async(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, RoleAssignmentRead.from_dict) +""" +Role Assignments API client for the Permissio.io SDK. +""" + +from typing import Optional, Dict, Any, Union, List + +from permissio.api.base import BaseApiClient +from permissio.config import PermissioConfig +from permissio.models.role_assignment import ( + RoleAssignment, + RoleAssignmentCreate, + RoleAssignmentRead, + BulkRoleAssignment, +) +from permissio.models.common import PaginatedResponse + + +class RoleAssignmentsApi(BaseApiClient): + """ + API client for role assignment operations. + + Provides methods for creating, reading, and deleting role assignments. + """ + + def __init__(self, config: PermissioConfig) -> None: + """Initialize the Role Assignments API client.""" + super().__init__(config) + + def list( + self, + *, + page: int = 1, + per_page: int = 10, + user: Optional[str] = None, + role: Optional[str] = None, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> PaginatedResponse[RoleAssignmentRead]: + """ + List role assignments. + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + user: Filter by user key. + role: Filter by role key. + tenant: Filter by tenant key. + resource_instance: Filter by resource instance key. + + Returns: + Paginated list of role assignments. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include_total_count": "true", + } + if user: + params["user"] = user + if role: + params["role"] = role + if tenant: + params["tenant"] = tenant + if resource_instance: + params["resource_instance"] = resource_instance + + url = self._build_facts_url("role_assignments") + response = self.request("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, RoleAssignmentRead.from_dict) + + async def list_async( + self, + *, + page: int = 1, + per_page: int = 10, + user: Optional[str] = None, + role: Optional[str] = None, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> PaginatedResponse[RoleAssignmentRead]: + """ + List role assignments (async). + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + user: Filter by user key. + role: Filter by role key. + tenant: Filter by tenant key. + resource_instance: Filter by resource instance key. + + Returns: + Paginated list of role assignments. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + } + if user: + params["user"] = user + if role: + params["role"] = role + if tenant: + params["tenant"] = tenant + if resource_instance: + params["resource_instance"] = resource_instance + + url = self._build_facts_url("role_assignments") + response = await self.request_async("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, RoleAssignmentRead.from_dict) + + def assign( + self, + user: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> RoleAssignmentRead: + """ + Assign a role to a user. + + Args: + user: The user key. + role: The role key. + tenant: The tenant key (optional). + resource_instance: The resource instance key (optional). + + Returns: + The created role assignment. + """ + assignment = RoleAssignmentCreate( + user=user, + role=role, + tenant=tenant, + resource_instance=resource_instance, + ) + url = self._build_facts_url("role_assignments") + response = self.post(url, json=assignment.to_dict()) + return RoleAssignmentRead.from_dict(response.json()) + + async def assign_async( + self, + user: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> RoleAssignmentRead: + """ + Assign a role to a user (async). + + Args: + user: The user key. + role: The role key. + tenant: The tenant key (optional). + resource_instance: The resource instance key (optional). + + Returns: + The created role assignment. + """ + assignment = RoleAssignmentCreate( + user=user, + role=role, + tenant=tenant, + resource_instance=resource_instance, + ) + url = self._build_facts_url("role_assignments") + response = await self.post_async(url, json=assignment.to_dict()) + return RoleAssignmentRead.from_dict(response.json()) + + def unassign( + self, + user: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> None: + """ + Unassign a role from a user. + + Args: + user: The user key. + role: The role key. + tenant: The tenant key (optional). + resource_instance: The resource instance key (optional). + """ + params: Dict[str, Any] = { + "user": user, + "role": role, + } + if tenant: + params["tenant"] = tenant + if resource_instance: + params["resource_instance"] = resource_instance + + url = self._build_facts_url("role_assignments") + self.request("DELETE", url, params=params) + + async def unassign_async( + self, + user: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> None: + """ + Unassign a role from a user (async). + + Args: + user: The user key. + role: The role key. + tenant: The tenant key (optional). + resource_instance: The resource instance key (optional). + """ + params: Dict[str, Any] = { + "user": user, + "role": role, + } + if tenant: + params["tenant"] = tenant + if resource_instance: + params["resource_instance"] = resource_instance + + url = self._build_facts_url("role_assignments") + await self.request_async("DELETE", url, params=params) + + def bulk_assign(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, Any]]]) -> List[RoleAssignmentRead]: + """ + Bulk assign roles to users. + + Args: + assignments: List of role assignments to create. + + Returns: + List of created role assignments. + """ + assignment_dicts = [] + for a in assignments: + if isinstance(a, RoleAssignmentCreate): + assignment_dicts.append(a.to_dict()) + else: + assignment_dicts.append(a) + + url = self._build_facts_url("role_assignments/bulk") + response = self.post(url, json={"assignments": assignment_dicts}) + data = response.json() + + if isinstance(data, list): + return [RoleAssignmentRead.from_dict(item) for item in data] + return [RoleAssignmentRead.from_dict(item) for item in data.get("data", [])] + + async def bulk_assign_async(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, Any]]]) -> List[RoleAssignmentRead]: + """ + Bulk assign roles to users (async). + + Args: + assignments: List of role assignments to create. + + Returns: + List of created role assignments. + """ + assignment_dicts = [] + for a in assignments: + if isinstance(a, RoleAssignmentCreate): + assignment_dicts.append(a.to_dict()) + else: + assignment_dicts.append(a) + + url = self._build_facts_url("role_assignments/bulk") + response = await self.post_async(url, json={"assignments": assignment_dicts}) + data = response.json() + + if isinstance(data, list): + return [RoleAssignmentRead.from_dict(item) for item in data] + return [RoleAssignmentRead.from_dict(item) for item in data.get("data", [])] + + def bulk_unassign(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, Any]]]) -> None: + """ + Bulk unassign roles from users. + + Args: + assignments: List of role assignments to remove. + """ + assignment_dicts = [] + for a in assignments: + if isinstance(a, RoleAssignmentCreate): + assignment_dicts.append(a.to_dict()) + else: + assignment_dicts.append(a) + + url = self._build_facts_url("role_assignments/bulk") + self.request("DELETE", url, json={"assignments": assignment_dicts}) + + async def bulk_unassign_async(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, Any]]]) -> None: + """ + Bulk unassign roles from users (async). + + Args: + assignments: List of role assignments to remove. + """ + assignment_dicts = [] + for a in assignments: + if isinstance(a, RoleAssignmentCreate): + assignment_dicts.append(a.to_dict()) + else: + assignment_dicts.append(a) + + url = self._build_facts_url("role_assignments/bulk") + await self.request_async("DELETE", url, json={"assignments": assignment_dicts}) + + def list_detailed( + self, + *, + page: int = 1, + per_page: int = 10, + user: Optional[str] = None, + role: Optional[str] = None, + tenant: Optional[str] = None, + ) -> PaginatedResponse[RoleAssignmentRead]: + """ + List role assignments with detailed information. + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + user: Filter by user key. + role: Filter by role key. + tenant: Filter by tenant key. + + Returns: + Paginated list of detailed role assignments. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + } + if user: + params["user"] = user + if role: + params["role"] = role + if tenant: + params["tenant"] = tenant + + url = self._build_facts_url("role_assignments/detailed") + response = self.get(url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, RoleAssignmentRead.from_dict) + + async def list_detailed_async( + self, + *, + page: int = 1, + per_page: int = 10, + user: Optional[str] = None, + role: Optional[str] = None, + tenant: Optional[str] = None, + ) -> PaginatedResponse[RoleAssignmentRead]: + """ + List role assignments with detailed information (async). + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + user: Filter by user key. + role: Filter by role key. + tenant: Filter by tenant key. + + Returns: + Paginated list of detailed role assignments. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + } + if user: + params["user"] = user + if role: + params["role"] = role + if tenant: + params["tenant"] = tenant + + url = self._build_facts_url("role_assignments/detailed") + response = await self.get_async(url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, RoleAssignmentRead.from_dict) diff --git a/permisio/api/roles.py b/permissio/api/roles.py similarity index 91% rename from permisio/api/roles.py rename to permissio/api/roles.py index 08bb5c3..88ec90f 100644 --- a/permisio/api/roles.py +++ b/permissio/api/roles.py @@ -1,269 +1,271 @@ -""" -Roles API client for the Permis.io SDK. -""" - -from typing import Optional, Dict, Any, Union, List - -from permisio.api.base import BaseApiClient -from permisio.config import PermisConfig -from permisio.models.role import Role, RoleCreate, RoleUpdate, RoleRead -from permisio.models.common import PaginatedResponse - - -class RolesApi(BaseApiClient): - """ - API client for role operations. - - Provides methods for creating, reading, updating, and deleting roles. - Roles are part of the Schema API. - """ - - def __init__(self, config: PermisConfig) -> None: - """Initialize the Roles API client.""" - super().__init__(config) - - def list( - self, - *, - page: int = 1, - per_page: int = 10, - search: Optional[str] = None, - ) -> PaginatedResponse[RoleRead]: - """ - List roles. - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - search: Search query string. - - Returns: - Paginated list of roles. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if search: - params["search"] = search - - url = self._build_schema_url("roles") - response = self.get(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, RoleRead.from_dict) - - async def list_async( - self, - *, - page: int = 1, - per_page: int = 10, - search: Optional[str] = None, - ) -> PaginatedResponse[RoleRead]: - """ - List roles (async). - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - search: Search query string. - - Returns: - Paginated list of roles. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if search: - params["search"] = search - - url = self._build_schema_url("roles") - response = await self.get_async(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, RoleRead.from_dict) - - def get(self, role_key: str) -> RoleRead: - """ - Get a role by key. - - Args: - role_key: The role key. - - Returns: - The role. - """ - url = self._build_schema_url(f"roles/{role_key}") - response = super().get(url) - return RoleRead.from_dict(response.json()) - - async def get_async(self, role_key: str) -> RoleRead: - """ - Get a role by key (async). - - Args: - role_key: The role key. - - Returns: - The role. - """ - url = self._build_schema_url(f"roles/{role_key}") - response = await super().get_async(url) - return RoleRead.from_dict(response.json()) - - def create(self, role: Union[RoleCreate, Dict[str, Any]]) -> RoleRead: - """ - Create a new role. - - Args: - role: Role data. - - Returns: - The created role. - """ - if isinstance(role, RoleCreate): - role_data = role.to_dict() - else: - role_data = role - - url = self._build_schema_url("roles") - response = self.post(url, json=role_data) - return RoleRead.from_dict(response.json()) - - async def create_async(self, role: Union[RoleCreate, Dict[str, Any]]) -> RoleRead: - """ - Create a new role (async). - - Args: - role: Role data. - - Returns: - The created role. - """ - if isinstance(role, RoleCreate): - role_data = role.to_dict() - else: - role_data = role - - url = self._build_schema_url("roles") - response = await self.post_async(url, json=role_data) - return RoleRead.from_dict(response.json()) - - def update(self, role_key: str, role: Union[RoleUpdate, Dict[str, Any]]) -> RoleRead: - """ - Update an existing role. - - Args: - role_key: The role key. - role: Role update data. - - Returns: - The updated role. - """ - if isinstance(role, RoleUpdate): - role_data = role.to_dict() - else: - role_data = role - - url = self._build_schema_url(f"roles/{role_key}") - response = self.patch(url, json=role_data) - return RoleRead.from_dict(response.json()) - - async def update_async(self, role_key: str, role: Union[RoleUpdate, Dict[str, Any]]) -> RoleRead: - """ - Update an existing role (async). - - Args: - role_key: The role key. - role: Role update data. - - Returns: - The updated role. - """ - if isinstance(role, RoleUpdate): - role_data = role.to_dict() - else: - role_data = role - - url = self._build_schema_url(f"roles/{role_key}") - response = await self.patch_async(url, json=role_data) - return RoleRead.from_dict(response.json()) - - def delete(self, role_key: str) -> None: - """ - Delete a role. - - Args: - role_key: The role key. - """ - url = self._build_schema_url(f"roles/{role_key}") - super().delete(url) - - async def delete_async(self, role_key: str) -> None: - """ - Delete a role (async). - - Args: - role_key: The role key. - """ - url = self._build_schema_url(f"roles/{role_key}") - await super().delete_async(url) - - def add_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: - """ - Add permissions to a role. - - Args: - role_key: The role key. - permissions: List of permission keys to add. - - Returns: - The updated role. - """ - url = self._build_schema_url(f"roles/{role_key}/permissions") - response = self.post(url, json={"permissions": permissions}) - return RoleRead.from_dict(response.json()) - - async def add_permissions_async(self, role_key: str, permissions: List[str]) -> RoleRead: - """ - Add permissions to a role (async). - - Args: - role_key: The role key. - permissions: List of permission keys to add. - - Returns: - The updated role. - """ - url = self._build_schema_url(f"roles/{role_key}/permissions") - response = await self.post_async(url, json={"permissions": permissions}) - return RoleRead.from_dict(response.json()) - - def remove_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: - """ - Remove permissions from a role. - - Args: - role_key: The role key. - permissions: List of permission keys to remove. - - Returns: - The updated role. - """ - url = self._build_schema_url(f"roles/{role_key}/permissions") - response = self.request("DELETE", url, json={"permissions": permissions}) - return RoleRead.from_dict(response.json()) - - async def remove_permissions_async(self, role_key: str, permissions: List[str]) -> RoleRead: - """ - Remove permissions from a role (async). - - Args: - role_key: The role key. - permissions: List of permission keys to remove. - - Returns: - The updated role. - """ - url = self._build_schema_url(f"roles/{role_key}/permissions") - response = await self.request_async("DELETE", url, json={"permissions": permissions}) - return RoleRead.from_dict(response.json()) +""" +Roles API client for the Permissio.io SDK. +""" + +from typing import Optional, Dict, Any, Union, List + +from permissio.api.base import BaseApiClient +from permissio.config import PermissioConfig +from permissio.models.role import Role, RoleCreate, RoleUpdate, RoleRead +from permissio.models.common import PaginatedResponse + + +class RolesApi(BaseApiClient): + """ + API client for role operations. + + Provides methods for creating, reading, updating, and deleting roles. + Roles are part of the Schema API. + """ + + def __init__(self, config: PermissioConfig) -> None: + """Initialize the Roles API client.""" + super().__init__(config) + + def list( + self, + *, + page: int = 1, + per_page: int = 10, + search: Optional[str] = None, + ) -> PaginatedResponse[RoleRead]: + """ + List roles. + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + search: Search query string. + + Returns: + Paginated list of roles. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include_total_count": "true", + } + if search: + params["search"] = search + + url = self._build_schema_url("roles") + response = self.request("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, RoleRead.from_dict) + + async def list_async( + self, + *, + page: int = 1, + per_page: int = 10, + search: Optional[str] = None, + ) -> PaginatedResponse[RoleRead]: + """ + List roles (async). + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + search: Search query string. + + Returns: + Paginated list of roles. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include_total_count": "true", + } + if search: + params["search"] = search + + url = self._build_schema_url("roles") + response = await self.request_async("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, RoleRead.from_dict) + + def get(self, role_key: str) -> RoleRead: + """ + Get a role by key. + + Args: + role_key: The role key. + + Returns: + The role. + """ + url = self._build_schema_url(f"roles/{role_key}") + response = super().get(url) + return RoleRead.from_dict(response.json()) + + async def get_async(self, role_key: str) -> RoleRead: + """ + Get a role by key (async). + + Args: + role_key: The role key. + + Returns: + The role. + """ + url = self._build_schema_url(f"roles/{role_key}") + response = await super().get_async(url) + return RoleRead.from_dict(response.json()) + + def create(self, role: Union[RoleCreate, Dict[str, Any]]) -> RoleRead: + """ + Create a new role. + + Args: + role: Role data. + + Returns: + The created role. + """ + if isinstance(role, RoleCreate): + role_data = role.to_dict() + else: + role_data = role + + url = self._build_schema_url("roles") + response = self.post(url, json=role_data) + return RoleRead.from_dict(response.json()) + + async def create_async(self, role: Union[RoleCreate, Dict[str, Any]]) -> RoleRead: + """ + Create a new role (async). + + Args: + role: Role data. + + Returns: + The created role. + """ + if isinstance(role, RoleCreate): + role_data = role.to_dict() + else: + role_data = role + + url = self._build_schema_url("roles") + response = await self.post_async(url, json=role_data) + return RoleRead.from_dict(response.json()) + + def update(self, role_key: str, role: Union[RoleUpdate, Dict[str, Any]]) -> RoleRead: + """ + Update an existing role. + + Args: + role_key: The role key. + role: Role update data. + + Returns: + The updated role. + """ + if isinstance(role, RoleUpdate): + role_data = role.to_dict() + else: + role_data = role + + url = self._build_schema_url(f"roles/{role_key}") + response = self.patch(url, json=role_data) + return RoleRead.from_dict(response.json()) + + async def update_async(self, role_key: str, role: Union[RoleUpdate, Dict[str, Any]]) -> RoleRead: + """ + Update an existing role (async). + + Args: + role_key: The role key. + role: Role update data. + + Returns: + The updated role. + """ + if isinstance(role, RoleUpdate): + role_data = role.to_dict() + else: + role_data = role + + url = self._build_schema_url(f"roles/{role_key}") + response = await self.patch_async(url, json=role_data) + return RoleRead.from_dict(response.json()) + + def delete(self, role_key: str) -> None: + """ + Delete a role. + + Args: + role_key: The role key. + """ + url = self._build_schema_url(f"roles/{role_key}") + super().delete(url) + + async def delete_async(self, role_key: str) -> None: + """ + Delete a role (async). + + Args: + role_key: The role key. + """ + url = self._build_schema_url(f"roles/{role_key}") + await super().delete_async(url) + + def add_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: + """ + Add permissions to a role. + + Args: + role_key: The role key. + permissions: List of permission keys to add. + + Returns: + The updated role. + """ + url = self._build_schema_url(f"roles/{role_key}/permissions") + response = self.post(url, json={"permissions": permissions}) + return RoleRead.from_dict(response.json()) + + async def add_permissions_async(self, role_key: str, permissions: List[str]) -> RoleRead: + """ + Add permissions to a role (async). + + Args: + role_key: The role key. + permissions: List of permission keys to add. + + Returns: + The updated role. + """ + url = self._build_schema_url(f"roles/{role_key}/permissions") + response = await self.post_async(url, json={"permissions": permissions}) + return RoleRead.from_dict(response.json()) + + def remove_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: + """ + Remove permissions from a role. + + Args: + role_key: The role key. + permissions: List of permission keys to remove. + + Returns: + The updated role. + """ + url = self._build_schema_url(f"roles/{role_key}/permissions") + response = self.request("DELETE", url, json={"permissions": permissions}) + return RoleRead.from_dict(response.json()) + + async def remove_permissions_async(self, role_key: str, permissions: List[str]) -> RoleRead: + """ + Remove permissions from a role (async). + + Args: + role_key: The role key. + permissions: List of permission keys to remove. + + Returns: + The updated role. + """ + url = self._build_schema_url(f"roles/{role_key}/permissions") + response = await self.request_async("DELETE", url, json={"permissions": permissions}) + return RoleRead.from_dict(response.json()) diff --git a/permisio/api/tenants.py b/permissio/api/tenants.py similarity index 89% rename from permisio/api/tenants.py rename to permissio/api/tenants.py index 2cac0d1..b48e4bd 100644 --- a/permisio/api/tenants.py +++ b/permissio/api/tenants.py @@ -1,208 +1,210 @@ -""" -Tenants API client for the Permis.io SDK. -""" - -from typing import Optional, Dict, Any, Union - -from permisio.api.base import BaseApiClient -from permisio.config import PermisConfig -from permisio.models.tenant import Tenant, TenantCreate, TenantUpdate, TenantRead -from permisio.models.common import PaginatedResponse - - -class TenantsApi(BaseApiClient): - """ - API client for tenant operations. - - Provides methods for creating, reading, updating, and deleting tenants. - """ - - def __init__(self, config: PermisConfig) -> None: - """Initialize the Tenants API client.""" - super().__init__(config) - - def list( - self, - *, - page: int = 1, - per_page: int = 10, - search: Optional[str] = None, - ) -> PaginatedResponse[TenantRead]: - """ - List tenants. - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - search: Search query string. - - Returns: - Paginated list of tenants. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if search: - params["search"] = search - - url = self._build_facts_url("tenants") - response = self.get(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, TenantRead.from_dict) - - async def list_async( - self, - *, - page: int = 1, - per_page: int = 10, - search: Optional[str] = None, - ) -> PaginatedResponse[TenantRead]: - """ - List tenants (async). - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - search: Search query string. - - Returns: - Paginated list of tenants. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if search: - params["search"] = search - - url = self._build_facts_url("tenants") - response = await self.get_async(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, TenantRead.from_dict) - - def get(self, tenant_key: str) -> TenantRead: - """ - Get a tenant by key. - - Args: - tenant_key: The tenant key. - - Returns: - The tenant. - """ - url = self._build_facts_url(f"tenants/{tenant_key}") - response = super().get(url) - return TenantRead.from_dict(response.json()) - - async def get_async(self, tenant_key: str) -> TenantRead: - """ - Get a tenant by key (async). - - Args: - tenant_key: The tenant key. - - Returns: - The tenant. - """ - url = self._build_facts_url(f"tenants/{tenant_key}") - response = await super().get_async(url) - return TenantRead.from_dict(response.json()) - - def create(self, tenant: Union[TenantCreate, Dict[str, Any]]) -> TenantRead: - """ - Create a new tenant. - - Args: - tenant: Tenant data. - - Returns: - The created tenant. - """ - if isinstance(tenant, TenantCreate): - tenant_data = tenant.to_dict() - else: - tenant_data = tenant - - url = self._build_facts_url("tenants") - response = self.post(url, json=tenant_data) - return TenantRead.from_dict(response.json()) - - async def create_async(self, tenant: Union[TenantCreate, Dict[str, Any]]) -> TenantRead: - """ - Create a new tenant (async). - - Args: - tenant: Tenant data. - - Returns: - The created tenant. - """ - if isinstance(tenant, TenantCreate): - tenant_data = tenant.to_dict() - else: - tenant_data = tenant - - url = self._build_facts_url("tenants") - response = await self.post_async(url, json=tenant_data) - return TenantRead.from_dict(response.json()) - - def update(self, tenant_key: str, tenant: Union[TenantUpdate, Dict[str, Any]]) -> TenantRead: - """ - Update an existing tenant. - - Args: - tenant_key: The tenant key. - tenant: Tenant update data. - - Returns: - The updated tenant. - """ - if isinstance(tenant, TenantUpdate): - tenant_data = tenant.to_dict() - else: - tenant_data = tenant - - url = self._build_facts_url(f"tenants/{tenant_key}") - response = self.patch(url, json=tenant_data) - return TenantRead.from_dict(response.json()) - - async def update_async(self, tenant_key: str, tenant: Union[TenantUpdate, Dict[str, Any]]) -> TenantRead: - """ - Update an existing tenant (async). - - Args: - tenant_key: The tenant key. - tenant: Tenant update data. - - Returns: - The updated tenant. - """ - if isinstance(tenant, TenantUpdate): - tenant_data = tenant.to_dict() - else: - tenant_data = tenant - - url = self._build_facts_url(f"tenants/{tenant_key}") - response = await self.patch_async(url, json=tenant_data) - return TenantRead.from_dict(response.json()) - - def delete(self, tenant_key: str) -> None: - """ - Delete a tenant. - - Args: - tenant_key: The tenant key. - """ - url = self._build_facts_url(f"tenants/{tenant_key}") - super().delete(url) - - async def delete_async(self, tenant_key: str) -> None: - """ - Delete a tenant (async). - - Args: - tenant_key: The tenant key. - """ - url = self._build_facts_url(f"tenants/{tenant_key}") - await super().delete_async(url) +""" +Tenants API client for the Permissio.io SDK. +""" + +from typing import Optional, Dict, Any, Union + +from permissio.api.base import BaseApiClient +from permissio.config import PermissioConfig +from permissio.models.tenant import Tenant, TenantCreate, TenantUpdate, TenantRead +from permissio.models.common import PaginatedResponse + + +class TenantsApi(BaseApiClient): + """ + API client for tenant operations. + + Provides methods for creating, reading, updating, and deleting tenants. + """ + + def __init__(self, config: PermissioConfig) -> None: + """Initialize the Tenants API client.""" + super().__init__(config) + + def list( + self, + *, + page: int = 1, + per_page: int = 10, + search: Optional[str] = None, + ) -> PaginatedResponse[TenantRead]: + """ + List tenants. + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + search: Search query string. + + Returns: + Paginated list of tenants. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include_total_count": "true", + } + if search: + params["search"] = search + + url = self._build_facts_url("tenants") + response = self.request("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, TenantRead.from_dict) + + async def list_async( + self, + *, + page: int = 1, + per_page: int = 10, + search: Optional[str] = None, + ) -> PaginatedResponse[TenantRead]: + """ + List tenants (async). + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + search: Search query string. + + Returns: + Paginated list of tenants. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include_total_count": "true", + } + if search: + params["search"] = search + + url = self._build_facts_url("tenants") + response = await self.request_async("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, TenantRead.from_dict) + + def get(self, tenant_key: str) -> TenantRead: + """ + Get a tenant by key. + + Args: + tenant_key: The tenant key. + + Returns: + The tenant. + """ + url = self._build_facts_url(f"tenants/{tenant_key}") + response = super().get(url) + return TenantRead.from_dict(response.json()) + + async def get_async(self, tenant_key: str) -> TenantRead: + """ + Get a tenant by key (async). + + Args: + tenant_key: The tenant key. + + Returns: + The tenant. + """ + url = self._build_facts_url(f"tenants/{tenant_key}") + response = await super().get_async(url) + return TenantRead.from_dict(response.json()) + + def create(self, tenant: Union[TenantCreate, Dict[str, Any]]) -> TenantRead: + """ + Create a new tenant. + + Args: + tenant: Tenant data. + + Returns: + The created tenant. + """ + if isinstance(tenant, TenantCreate): + tenant_data = tenant.to_dict() + else: + tenant_data = tenant + + url = self._build_facts_url("tenants") + response = self.post(url, json=tenant_data) + return TenantRead.from_dict(response.json()) + + async def create_async(self, tenant: Union[TenantCreate, Dict[str, Any]]) -> TenantRead: + """ + Create a new tenant (async). + + Args: + tenant: Tenant data. + + Returns: + The created tenant. + """ + if isinstance(tenant, TenantCreate): + tenant_data = tenant.to_dict() + else: + tenant_data = tenant + + url = self._build_facts_url("tenants") + response = await self.post_async(url, json=tenant_data) + return TenantRead.from_dict(response.json()) + + def update(self, tenant_key: str, tenant: Union[TenantUpdate, Dict[str, Any]]) -> TenantRead: + """ + Update an existing tenant. + + Args: + tenant_key: The tenant key. + tenant: Tenant update data. + + Returns: + The updated tenant. + """ + if isinstance(tenant, TenantUpdate): + tenant_data = tenant.to_dict() + else: + tenant_data = tenant + + url = self._build_facts_url(f"tenants/{tenant_key}") + response = self.patch(url, json=tenant_data) + return TenantRead.from_dict(response.json()) + + async def update_async(self, tenant_key: str, tenant: Union[TenantUpdate, Dict[str, Any]]) -> TenantRead: + """ + Update an existing tenant (async). + + Args: + tenant_key: The tenant key. + tenant: Tenant update data. + + Returns: + The updated tenant. + """ + if isinstance(tenant, TenantUpdate): + tenant_data = tenant.to_dict() + else: + tenant_data = tenant + + url = self._build_facts_url(f"tenants/{tenant_key}") + response = await self.patch_async(url, json=tenant_data) + return TenantRead.from_dict(response.json()) + + def delete(self, tenant_key: str) -> None: + """ + Delete a tenant. + + Args: + tenant_key: The tenant key. + """ + url = self._build_facts_url(f"tenants/{tenant_key}") + super().delete(url) + + async def delete_async(self, tenant_key: str) -> None: + """ + Delete a tenant (async). + + Args: + tenant_key: The tenant key. + """ + url = self._build_facts_url(f"tenants/{tenant_key}") + await super().delete_async(url) diff --git a/permisio/api/users.py b/permissio/api/users.py similarity index 92% rename from permisio/api/users.py rename to permissio/api/users.py index 8e99cf9..0ec202a 100644 --- a/permisio/api/users.py +++ b/permissio/api/users.py @@ -1,421 +1,423 @@ -""" -Users API client for the Permis.io SDK. -""" - -from typing import Optional, Dict, Any, List, Union - -from permisio.api.base import BaseApiClient -from permisio.config import PermisConfig -from permisio.models.user import User, UserCreate, UserUpdate, UserRead, UserSync -from permisio.models.role_assignment import RoleAssignment, RoleAssignmentCreate -from permisio.models.common import PaginatedResponse, ListParams - - -class UsersApi(BaseApiClient): - """ - API client for user operations. - - Provides methods for creating, reading, updating, and deleting users, - as well as managing user role assignments. - """ - - def __init__(self, config: PermisConfig) -> None: - """Initialize the Users API client.""" - super().__init__(config) - - def list( - self, - *, - page: int = 1, - per_page: int = 10, - search: Optional[str] = None, - tenant: Optional[str] = None, - ) -> PaginatedResponse[UserRead]: - """ - List users. - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - search: Search query string. - tenant: Filter by tenant key. - - Returns: - Paginated list of users. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if search: - params["search"] = search - if tenant: - params["tenant"] = tenant - - url = self._build_facts_url("users") - response = self.get(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, UserRead.from_dict) - - async def list_async( - self, - *, - page: int = 1, - per_page: int = 10, - search: Optional[str] = None, - tenant: Optional[str] = None, - ) -> PaginatedResponse[UserRead]: - """ - List users (async). - - Args: - page: Page number (1-indexed). - per_page: Number of items per page. - search: Search query string. - tenant: Filter by tenant key. - - Returns: - Paginated list of users. - """ - params: Dict[str, Any] = { - "page": page, - "per_page": per_page, - } - if search: - params["search"] = search - if tenant: - params["tenant"] = tenant - - url = self._build_facts_url("users") - response = await self.get_async(url, params=params) - data = response.json() - return PaginatedResponse.from_dict(data, UserRead.from_dict) - - def get(self, user_key: str) -> UserRead: - """ - Get a user by key. - - Args: - user_key: The user key. - - Returns: - The user. - """ - url = self._build_facts_url(f"users/{user_key}") - response = super().get(url) - return UserRead.from_dict(response.json()) - - async def get_async(self, user_key: str) -> UserRead: - """ - Get a user by key (async). - - Args: - user_key: The user key. - - Returns: - The user. - """ - url = self._build_facts_url(f"users/{user_key}") - response = await super().get_async(url) - return UserRead.from_dict(response.json()) - - def create(self, user: Union[UserCreate, Dict[str, Any]]) -> UserRead: - """ - Create a new user. - - Args: - user: User data. - - Returns: - The created user. - """ - if isinstance(user, UserCreate): - user_data = user.to_dict() - else: - user_data = user - - url = self._build_facts_url("users") - response = self.post(url, json=user_data) - return UserRead.from_dict(response.json()) - - async def create_async(self, user: Union[UserCreate, Dict[str, Any]]) -> UserRead: - """ - Create a new user (async). - - Args: - user: User data. - - Returns: - The created user. - """ - if isinstance(user, UserCreate): - user_data = user.to_dict() - else: - user_data = user - - url = self._build_facts_url("users") - response = await self.post_async(url, json=user_data) - return UserRead.from_dict(response.json()) - - def update(self, user_key: str, user: Union[UserUpdate, Dict[str, Any]]) -> UserRead: - """ - Update an existing user. - - Args: - user_key: The user key. - user: User update data. - - Returns: - The updated user. - """ - if isinstance(user, UserUpdate): - user_data = user.to_dict() - else: - user_data = user - - url = self._build_facts_url(f"users/{user_key}") - response = self.patch(url, json=user_data) - return UserRead.from_dict(response.json()) - - async def update_async(self, user_key: str, user: Union[UserUpdate, Dict[str, Any]]) -> UserRead: - """ - Update an existing user (async). - - Args: - user_key: The user key. - user: User update data. - - Returns: - The updated user. - """ - if isinstance(user, UserUpdate): - user_data = user.to_dict() - else: - user_data = user - - url = self._build_facts_url(f"users/{user_key}") - response = await self.patch_async(url, json=user_data) - return UserRead.from_dict(response.json()) - - def delete(self, user_key: str) -> None: - """ - Delete a user. - - Args: - user_key: The user key. - """ - url = self._build_facts_url(f"users/{user_key}") - super().delete(url) - - async def delete_async(self, user_key: str) -> None: - """ - Delete a user (async). - - Args: - user_key: The user key. - """ - url = self._build_facts_url(f"users/{user_key}") - await super().delete_async(url) - - def sync(self, user: Union[UserSync, Dict[str, Any]]) -> UserRead: - """ - Sync a user (create or update with roles). - - Args: - user: User sync data including roles. - - Returns: - The synced user. - """ - if isinstance(user, UserSync): - user_data = user.to_dict() - else: - user_data = user - - url = self._build_facts_url("users") - response = self.put(url, json=user_data) - return UserRead.from_dict(response.json()) - - async def sync_async(self, user: Union[UserSync, Dict[str, Any]]) -> UserRead: - """ - Sync a user (create or update with roles) (async). - - Args: - user: User sync data including roles. - - Returns: - The synced user. - """ - if isinstance(user, UserSync): - user_data = user.to_dict() - else: - user_data = user - - url = self._build_facts_url("users") - response = await self.put_async(url, json=user_data) - return UserRead.from_dict(response.json()) - - def assign_role( - self, - user_key: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> RoleAssignment: - """ - Assign a role to a user. - - Args: - user_key: The user key. - role: The role key. - tenant: The tenant key (optional). - resource_instance: The resource instance key (optional). - - Returns: - The role assignment. - """ - assignment = RoleAssignmentCreate( - user=user_key, - role=role, - tenant=tenant, - resource_instance=resource_instance, - ) - url = self._build_facts_url("role_assignments") - response = self.post(url, json=assignment.to_dict()) - return RoleAssignment.from_dict(response.json()) - - async def assign_role_async( - self, - user_key: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> RoleAssignment: - """ - Assign a role to a user (async). - - Args: - user_key: The user key. - role: The role key. - tenant: The tenant key (optional). - resource_instance: The resource instance key (optional). - - Returns: - The role assignment. - """ - assignment = RoleAssignmentCreate( - user=user_key, - role=role, - tenant=tenant, - resource_instance=resource_instance, - ) - url = self._build_facts_url("role_assignments") - response = await self.post_async(url, json=assignment.to_dict()) - return RoleAssignment.from_dict(response.json()) - - def unassign_role( - self, - user_key: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> None: - """ - Unassign a role from a user. - - Args: - user_key: The user key. - role: The role key. - tenant: The tenant key (optional). - resource_instance: The resource instance key (optional). - """ - params: Dict[str, Any] = { - "user": user_key, - "role": role, - } - if tenant: - params["tenant"] = tenant - if resource_instance: - params["resource_instance"] = resource_instance - - url = self._build_facts_url("role_assignments") - self.request("DELETE", url, params=params) - - async def unassign_role_async( - self, - user_key: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> None: - """ - Unassign a role from a user (async). - - Args: - user_key: The user key. - role: The role key. - tenant: The tenant key (optional). - resource_instance: The resource instance key (optional). - """ - params: Dict[str, Any] = { - "user": user_key, - "role": role, - } - if tenant: - params["tenant"] = tenant - if resource_instance: - params["resource_instance"] = resource_instance - - url = self._build_facts_url("role_assignments") - await self.request_async("DELETE", url, params=params) - - def get_roles(self, user_key: str, *, tenant: Optional[str] = None) -> List[RoleAssignment]: - """ - Get roles assigned to a user. - - Args: - user_key: The user key. - tenant: Filter by tenant key (optional). - - Returns: - List of role assignments. - """ - params: Dict[str, Any] = {"user": user_key} - if tenant: - params["tenant"] = tenant - - url = self._build_facts_url("role_assignments") - response = super().get(url, params=params) - data = response.json() - - # Handle both paginated and non-paginated responses - if isinstance(data, list): - return [RoleAssignment.from_dict(item) for item in data] - return [RoleAssignment.from_dict(item) for item in data.get("data", [])] - - async def get_roles_async(self, user_key: str, *, tenant: Optional[str] = None) -> List[RoleAssignment]: - """ - Get roles assigned to a user (async). - - Args: - user_key: The user key. - tenant: Filter by tenant key (optional). - - Returns: - List of role assignments. - """ - params: Dict[str, Any] = {"user": user_key} - if tenant: - params["tenant"] = tenant - - url = self._build_facts_url("role_assignments") - response = await super().get_async(url, params=params) - data = response.json() - - if isinstance(data, list): - return [RoleAssignment.from_dict(item) for item in data] - return [RoleAssignment.from_dict(item) for item in data.get("data", [])] +""" +Users API client for the Permissio.io SDK. +""" + +from typing import Optional, Dict, Any, List, Union + +from permissio.api.base import BaseApiClient +from permissio.config import PermissioConfig +from permissio.models.user import User, UserCreate, UserUpdate, UserRead, UserSync +from permissio.models.role_assignment import RoleAssignment, RoleAssignmentCreate +from permissio.models.common import PaginatedResponse, ListParams + + +class UsersApi(BaseApiClient): + """ + API client for user operations. + + Provides methods for creating, reading, updating, and deleting users, + as well as managing user role assignments. + """ + + def __init__(self, config: PermissioConfig) -> None: + """Initialize the Users API client.""" + super().__init__(config) + + def list( + self, + *, + page: int = 1, + per_page: int = 10, + search: Optional[str] = None, + tenant: Optional[str] = None, + ) -> PaginatedResponse[UserRead]: + """ + List users. + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + search: Search query string. + tenant: Filter by tenant key. + + Returns: + Paginated list of users. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include_total_count": "true", + } + if search: + params["search"] = search + if tenant: + params["tenant"] = tenant + + url = self._build_facts_url("users") + response = self.request("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, UserRead.from_dict) + + async def list_async( + self, + *, + page: int = 1, + per_page: int = 10, + search: Optional[str] = None, + tenant: Optional[str] = None, + ) -> PaginatedResponse[UserRead]: + """ + List users (async). + + Args: + page: Page number (1-indexed). + per_page: Number of items per page. + search: Search query string. + tenant: Filter by tenant key. + + Returns: + Paginated list of users. + """ + params: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include_total_count": "true", + } + if search: + params["search"] = search + if tenant: + params["tenant"] = tenant + + url = self._build_facts_url("users") + response = await self.request_async("GET", url, params=params) + data = response.json() + return PaginatedResponse.from_dict(data, UserRead.from_dict) + + def get(self, user_key: str) -> UserRead: + """ + Get a user by key. + + Args: + user_key: The user key. + + Returns: + The user. + """ + url = self._build_facts_url(f"users/{user_key}") + response = super().get(url) + return UserRead.from_dict(response.json()) + + async def get_async(self, user_key: str) -> UserRead: + """ + Get a user by key (async). + + Args: + user_key: The user key. + + Returns: + The user. + """ + url = self._build_facts_url(f"users/{user_key}") + response = await super().get_async(url) + return UserRead.from_dict(response.json()) + + def create(self, user: Union[UserCreate, Dict[str, Any]]) -> UserRead: + """ + Create a new user. + + Args: + user: User data. + + Returns: + The created user. + """ + if isinstance(user, UserCreate): + user_data = user.to_dict() + else: + user_data = user + + url = self._build_facts_url("users") + response = self.post(url, json=user_data) + return UserRead.from_dict(response.json()) + + async def create_async(self, user: Union[UserCreate, Dict[str, Any]]) -> UserRead: + """ + Create a new user (async). + + Args: + user: User data. + + Returns: + The created user. + """ + if isinstance(user, UserCreate): + user_data = user.to_dict() + else: + user_data = user + + url = self._build_facts_url("users") + response = await self.post_async(url, json=user_data) + return UserRead.from_dict(response.json()) + + def update(self, user_key: str, user: Union[UserUpdate, Dict[str, Any]]) -> UserRead: + """ + Update an existing user. + + Args: + user_key: The user key. + user: User update data. + + Returns: + The updated user. + """ + if isinstance(user, UserUpdate): + user_data = user.to_dict() + else: + user_data = user + + url = self._build_facts_url(f"users/{user_key}") + response = self.patch(url, json=user_data) + return UserRead.from_dict(response.json()) + + async def update_async(self, user_key: str, user: Union[UserUpdate, Dict[str, Any]]) -> UserRead: + """ + Update an existing user (async). + + Args: + user_key: The user key. + user: User update data. + + Returns: + The updated user. + """ + if isinstance(user, UserUpdate): + user_data = user.to_dict() + else: + user_data = user + + url = self._build_facts_url(f"users/{user_key}") + response = await self.patch_async(url, json=user_data) + return UserRead.from_dict(response.json()) + + def delete(self, user_key: str) -> None: + """ + Delete a user. + + Args: + user_key: The user key. + """ + url = self._build_facts_url(f"users/{user_key}") + super().delete(url) + + async def delete_async(self, user_key: str) -> None: + """ + Delete a user (async). + + Args: + user_key: The user key. + """ + url = self._build_facts_url(f"users/{user_key}") + await super().delete_async(url) + + def sync(self, user: Union[UserSync, Dict[str, Any]]) -> UserRead: + """ + Sync a user (create or update with roles). + + Args: + user: User sync data including roles. + + Returns: + The synced user. + """ + if isinstance(user, UserSync): + user_data = user.to_dict() + else: + user_data = user + + url = self._build_facts_url("users") + response = self.put(url, json=user_data) + return UserRead.from_dict(response.json()) + + async def sync_async(self, user: Union[UserSync, Dict[str, Any]]) -> UserRead: + """ + Sync a user (create or update with roles) (async). + + Args: + user: User sync data including roles. + + Returns: + The synced user. + """ + if isinstance(user, UserSync): + user_data = user.to_dict() + else: + user_data = user + + url = self._build_facts_url("users") + response = await self.put_async(url, json=user_data) + return UserRead.from_dict(response.json()) + + def assign_role( + self, + user_key: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> RoleAssignment: + """ + Assign a role to a user. + + Args: + user_key: The user key. + role: The role key. + tenant: The tenant key (optional). + resource_instance: The resource instance key (optional). + + Returns: + The role assignment. + """ + assignment = RoleAssignmentCreate( + user=user_key, + role=role, + tenant=tenant, + resource_instance=resource_instance, + ) + url = self._build_facts_url("role_assignments") + response = self.post(url, json=assignment.to_dict()) + return RoleAssignment.from_dict(response.json()) + + async def assign_role_async( + self, + user_key: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> RoleAssignment: + """ + Assign a role to a user (async). + + Args: + user_key: The user key. + role: The role key. + tenant: The tenant key (optional). + resource_instance: The resource instance key (optional). + + Returns: + The role assignment. + """ + assignment = RoleAssignmentCreate( + user=user_key, + role=role, + tenant=tenant, + resource_instance=resource_instance, + ) + url = self._build_facts_url("role_assignments") + response = await self.post_async(url, json=assignment.to_dict()) + return RoleAssignment.from_dict(response.json()) + + def unassign_role( + self, + user_key: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> None: + """ + Unassign a role from a user. + + Args: + user_key: The user key. + role: The role key. + tenant: The tenant key (optional). + resource_instance: The resource instance key (optional). + """ + params: Dict[str, Any] = { + "user": user_key, + "role": role, + } + if tenant: + params["tenant"] = tenant + if resource_instance: + params["resource_instance"] = resource_instance + + url = self._build_facts_url("role_assignments") + self.request("DELETE", url, params=params) + + async def unassign_role_async( + self, + user_key: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> None: + """ + Unassign a role from a user (async). + + Args: + user_key: The user key. + role: The role key. + tenant: The tenant key (optional). + resource_instance: The resource instance key (optional). + """ + params: Dict[str, Any] = { + "user": user_key, + "role": role, + } + if tenant: + params["tenant"] = tenant + if resource_instance: + params["resource_instance"] = resource_instance + + url = self._build_facts_url("role_assignments") + await self.request_async("DELETE", url, params=params) + + def get_roles(self, user_key: str, *, tenant: Optional[str] = None) -> List[RoleAssignment]: + """ + Get roles assigned to a user. + + Args: + user_key: The user key. + tenant: Filter by tenant key (optional). + + Returns: + List of role assignments. + """ + params: Dict[str, Any] = {"user": user_key} + if tenant: + params["tenant"] = tenant + + url = self._build_facts_url("role_assignments") + response = super().get(url, params=params) + data = response.json() + + # Handle both paginated and non-paginated responses + if isinstance(data, list): + return [RoleAssignment.from_dict(item) for item in data] + return [RoleAssignment.from_dict(item) for item in data.get("data", [])] + + async def get_roles_async(self, user_key: str, *, tenant: Optional[str] = None) -> List[RoleAssignment]: + """ + Get roles assigned to a user (async). + + Args: + user_key: The user key. + tenant: Filter by tenant key (optional). + + Returns: + List of role assignments. + """ + params: Dict[str, Any] = {"user": user_key} + if tenant: + params["tenant"] = tenant + + url = self._build_facts_url("role_assignments") + response = await super().get_async(url, params=params) + data = response.json() + + if isinstance(data, list): + return [RoleAssignment.from_dict(item) for item in data] + return [RoleAssignment.from_dict(item) for item in data.get("data", [])] diff --git a/permisio/client.py b/permissio/client.py similarity index 57% rename from permisio/client.py rename to permissio/client.py index 84ad9e0..eada373 100644 --- a/permisio/client.py +++ b/permissio/client.py @@ -1,596 +1,856 @@ -""" -Main Permis.io SDK client. -""" - -from typing import Optional, Dict, Any, Union - -from permisio.config import PermisConfig, ConfigBuilder, resolve_config -from permisio.api.base import BaseApiClient -from permisio.api.users import UsersApi -from permisio.api.tenants import TenantsApi -from permisio.api.roles import RolesApi -from permisio.api.resources import ResourcesApi -from permisio.api.role_assignments import RoleAssignmentsApi -from permisio.models.check import CheckRequest, CheckResponse, BulkCheckRequest, BulkCheckResponse -from permisio.enforcement.models import ( - CheckUser, - CheckResource, - UserBuilder, - ResourceBuilder, - normalize_user, - normalize_resource, - normalize_context, -) - - -class PermisApi: - """ - Container for all API clients. - - Provides access to all resource-specific API clients. - """ - - def __init__(self, config: PermisConfig) -> None: - """ - Initialize all API clients. - - Args: - config: SDK configuration. - """ - self._config = config - self._users = UsersApi(config) - self._tenants = TenantsApi(config) - self._roles = RolesApi(config) - self._resources = ResourcesApi(config) - self._role_assignments = RoleAssignmentsApi(config) - - @property - def users(self) -> UsersApi: - """Get the Users API client.""" - return self._users - - @property - def tenants(self) -> TenantsApi: - """Get the Tenants API client.""" - return self._tenants - - @property - def roles(self) -> RolesApi: - """Get the Roles API client.""" - return self._roles - - @property - def resources(self) -> ResourcesApi: - """Get the Resources API client.""" - return self._resources - - @property - def role_assignments(self) -> RoleAssignmentsApi: - """Get the Role Assignments API client.""" - return self._role_assignments - - def close(self) -> None: - """Close all API clients.""" - self._users.close() - self._tenants.close() - self._roles.close() - self._resources.close() - self._role_assignments.close() - - async def close_async(self) -> None: - """Close all API clients (async).""" - await self._users.close_async() - await self._tenants.close_async() - await self._roles.close_async() - await self._resources.close_async() - await self._role_assignments.close_async() - - -class Permis: - """ - Main Permis.io SDK client. - - This is the primary entry point for interacting with the Permis.io API. - It provides both permission checking methods and access to resource APIs. - - Example: - # Create client - permis = Permis( - token="permis_key_your_api_key", - project_id="my-project", - environment_id="production", - ) - - # Check permission - if permis.check("user@example.com", "read", "document"): - print("Access granted") - - # Use API clients - users = permis.api.users.list() - - # Close when done - permis.close() - - Example with context manager: - with Permis(token="permis_key_your_api_key") as permis: - allowed = permis.check("user@example.com", "read", "document") - """ - - def __init__( - self, - token: Optional[str] = None, - *, - config: Optional[Union[PermisConfig, Dict[str, Any]]] = None, - api_url: Optional[str] = None, - project_id: Optional[str] = None, - environment_id: Optional[str] = None, - timeout: Optional[float] = None, - debug: bool = False, - retry_attempts: Optional[int] = None, - throw_on_error: bool = True, - custom_headers: Optional[Dict[str, str]] = None, - ) -> None: - """ - Initialize the Permis.io SDK client. - - Args: - token: API key for authentication. Required if config is not provided. - config: Optional pre-built PermisConfig or config dictionary. - api_url: Base URL for the API. - project_id: Project identifier. - environment_id: Environment identifier. - timeout: Request timeout in seconds. - debug: Enable debug logging. - retry_attempts: Number of retry attempts. - throw_on_error: Whether to raise exceptions on API errors. - custom_headers: Additional headers to include in requests. - """ - # Build configuration - if config is not None: - self._config = resolve_config(config) - elif token is not None: - builder = ConfigBuilder(token) - if api_url: - builder.with_api_url(api_url) - if project_id: - builder.with_project_id(project_id) - if environment_id: - builder.with_environment_id(environment_id) - if timeout: - builder.with_timeout(timeout) - builder.with_debug(debug) - if retry_attempts is not None: - builder.with_retry_attempts(retry_attempts) - builder.with_throw_on_error(throw_on_error) - if custom_headers: - builder.with_custom_headers(custom_headers) - self._config = builder.build() - else: - raise ValueError("Either 'token' or 'config' must be provided") - - # Initialize API container - self._api = PermisApi(self._config) - - # Create base client for check operations - self._base_client = BaseApiClient(self._config) - - @property - def config(self) -> PermisConfig: - """Get the SDK configuration.""" - return self._config - - @property - def api(self) -> PermisApi: - """Get the API clients container.""" - return self._api - - def check( - self, - user: Union[str, Dict[str, Any], CheckUser, UserBuilder], - action: str, - resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder], - *, - tenant: Optional[str] = None, - context: Optional[Dict[str, Any]] = None, - ) -> bool: - """ - Check if a user has permission to perform an action on a resource. - - Args: - user: User identifier (key, dict with attributes, or CheckUser). - action: Action to check permission for. - resource: Resource to check (type, dict with attributes, or CheckResource). - tenant: Tenant key (optional, can also be specified in resource). - context: Additional context for the check. - - Returns: - True if the action is allowed, False otherwise. - - Example: - # Simple check - allowed = permis.check("user@example.com", "read", "document") - - # With tenant - allowed = permis.check("user@example.com", "read", "document", tenant="acme") - - # With resource instance - allowed = permis.check( - "user@example.com", - "read", - {"type": "document", "key": "doc-123"} - ) - - # With ABAC - allowed = permis.check( - {"key": "user@example.com", "attributes": {"department": "engineering"}}, - "read", - {"type": "document", "attributes": {"classification": "internal"}} - ) - """ - response = self.check_with_details(user, action, resource, tenant=tenant, context=context) - return response.allowed - - async def check_async( - self, - user: Union[str, Dict[str, Any], CheckUser, UserBuilder], - action: str, - resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder], - *, - tenant: Optional[str] = None, - context: Optional[Dict[str, Any]] = None, - ) -> bool: - """ - Check if a user has permission to perform an action on a resource (async). - - Args: - user: User identifier (key, dict with attributes, or CheckUser). - action: Action to check permission for. - resource: Resource to check (type, dict with attributes, or CheckResource). - tenant: Tenant key (optional, can also be specified in resource). - context: Additional context for the check. - - Returns: - True if the action is allowed, False otherwise. - """ - response = await self.check_with_details_async(user, action, resource, tenant=tenant, context=context) - return response.allowed - - def check_with_details( - self, - user: Union[str, Dict[str, Any], CheckUser, UserBuilder], - action: str, - resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder], - *, - tenant: Optional[str] = None, - context: Optional[Dict[str, Any]] = None, - ) -> CheckResponse: - """ - Check permission and get detailed response. - - Args: - user: User identifier (key, dict with attributes, or CheckUser). - action: Action to check permission for. - resource: Resource to check (type, dict with attributes, or CheckResource). - tenant: Tenant key (optional, can also be specified in resource). - context: Additional context for the check. - - Returns: - CheckResponse with allowed status and additional details. - """ - # Normalize inputs - user_data = normalize_user(user) - resource_data = normalize_resource(resource) - context_data = normalize_context(context) - - # Add tenant to resource if provided - if tenant and "tenant" not in resource_data: - resource_data["tenant"] = tenant - - # Build request - request_data = { - "user": user_data, - "action": action, - "resource": resource_data, - } - if context_data: - request_data["context"] = context_data - - # Make request - url = self._base_client._build_allowed_url() - response = self._base_client.post(url, json=request_data) - return CheckResponse.from_dict(response.json()) - - async def check_with_details_async( - self, - user: Union[str, Dict[str, Any], CheckUser, UserBuilder], - action: str, - resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder], - *, - tenant: Optional[str] = None, - context: Optional[Dict[str, Any]] = None, - ) -> CheckResponse: - """ - Check permission and get detailed response (async). - - Args: - user: User identifier (key, dict with attributes, or CheckUser). - action: Action to check permission for. - resource: Resource to check (type, dict with attributes, or CheckResource). - tenant: Tenant key (optional, can also be specified in resource). - context: Additional context for the check. - - Returns: - CheckResponse with allowed status and additional details. - """ - # Normalize inputs - user_data = normalize_user(user) - resource_data = normalize_resource(resource) - context_data = normalize_context(context) - - # Add tenant to resource if provided - if tenant and "tenant" not in resource_data: - resource_data["tenant"] = tenant - - # Build request - request_data = { - "user": user_data, - "action": action, - "resource": resource_data, - } - if context_data: - request_data["context"] = context_data - - # Make request - url = self._base_client._build_allowed_url() - response = await self._base_client.post_async(url, json=request_data) - return CheckResponse.from_dict(response.json()) - - def bulk_check( - self, - checks: list, - ) -> BulkCheckResponse: - """ - Check multiple permissions in a single request. - - Args: - checks: List of check requests (dicts or CheckRequest objects). - - Returns: - BulkCheckResponse with results for each check. - - Example: - results = permis.bulk_check([ - {"user": "user1", "action": "read", "resource": "document"}, - {"user": "user1", "action": "write", "resource": "document"}, - {"user": "user2", "action": "read", "resource": "document"}, - ]) - for result in results.results: - print(f"Allowed: {result.allowed}") - """ - check_dicts = [] - for check in checks: - if hasattr(check, "to_dict"): - check_dicts.append(check.to_dict()) - elif isinstance(check, dict): - # Normalize the check - normalized = { - "user": normalize_user(check.get("user", "")), - "action": check.get("action", ""), - "resource": normalize_resource(check.get("resource", "")), - } - if "tenant" in check: - normalized["tenant"] = check["tenant"] - if "context" in check: - normalized["context"] = check["context"] - check_dicts.append(normalized) - else: - raise ValueError(f"Invalid check type: {type(check)}") - - url = self._base_client._build_allowed_url() + "/bulk" - response = self._base_client.post(url, json={"checks": check_dicts}) - return BulkCheckResponse.from_dict(response.json()) - - async def bulk_check_async( - self, - checks: list, - ) -> BulkCheckResponse: - """ - Check multiple permissions in a single request (async). - - Args: - checks: List of check requests (dicts or CheckRequest objects). - - Returns: - BulkCheckResponse with results for each check. - """ - check_dicts = [] - for check in checks: - if hasattr(check, "to_dict"): - check_dicts.append(check.to_dict()) - elif isinstance(check, dict): - normalized = { - "user": normalize_user(check.get("user", "")), - "action": check.get("action", ""), - "resource": normalize_resource(check.get("resource", "")), - } - if "tenant" in check: - normalized["tenant"] = check["tenant"] - if "context" in check: - normalized["context"] = check["context"] - check_dicts.append(normalized) - else: - raise ValueError(f"Invalid check type: {type(check)}") - - url = self._base_client._build_allowed_url() + "/bulk" - response = await self._base_client.post_async(url, json={"checks": check_dicts}) - return BulkCheckResponse.from_dict(response.json()) - - def close(self) -> None: - """Close the client and release resources.""" - self._base_client.close() - self._api.close() - - async def close_async(self) -> None: - """Close the client and release resources (async).""" - await self._base_client.close_async() - await self._api.close_async() - - def __enter__(self) -> "Permis": - return self - - def __exit__(self, *args: Any) -> None: - self.close() - - async def __aenter__(self) -> "Permis": - return self - - async def __aexit__(self, *args: Any) -> None: - await self.close_async() - - # Convenience methods that mirror Permit.io SDK - - def sync_user(self, user: Union[Dict[str, Any], Any]) -> Any: - """ - Sync a user (create or update). - - This is a convenience method that wraps api.users.sync(). - - Args: - user: User data to sync. - - Returns: - The synced user. - """ - return self._api.users.sync(user) - - async def sync_user_async(self, user: Union[Dict[str, Any], Any]) -> Any: - """ - Sync a user (create or update) (async). - - Args: - user: User data to sync. - - Returns: - The synced user. - """ - return await self._api.users.sync_async(user) - - def assign_role( - self, - user: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> Any: - """ - Assign a role to a user. - - This is a convenience method that wraps api.role_assignments.assign(). - - Args: - user: User key. - role: Role key. - tenant: Tenant key (optional). - resource_instance: Resource instance key (optional). - - Returns: - The role assignment. - """ - return self._api.role_assignments.assign( - user, role, tenant=tenant, resource_instance=resource_instance - ) - - async def assign_role_async( - self, - user: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> Any: - """ - Assign a role to a user (async). - - Args: - user: User key. - role: Role key. - tenant: Tenant key (optional). - resource_instance: Resource instance key (optional). - - Returns: - The role assignment. - """ - return await self._api.role_assignments.assign_async( - user, role, tenant=tenant, resource_instance=resource_instance - ) - - def unassign_role( - self, - user: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> None: - """ - Unassign a role from a user. - - This is a convenience method that wraps api.role_assignments.unassign(). - - Args: - user: User key. - role: Role key. - tenant: Tenant key (optional). - resource_instance: Resource instance key (optional). - """ - self._api.role_assignments.unassign( - user, role, tenant=tenant, resource_instance=resource_instance - ) - - async def unassign_role_async( - self, - user: str, - role: str, - *, - tenant: Optional[str] = None, - resource_instance: Optional[str] = None, - ) -> None: - """ - Unassign a role from a user (async). - - Args: - user: User key. - role: Role key. - tenant: Tenant key (optional). - resource_instance: Resource instance key (optional). - """ - await self._api.role_assignments.unassign_async( - user, role, tenant=tenant, resource_instance=resource_instance - ) - - def create_tenant(self, tenant: Union[Dict[str, Any], Any]) -> Any: - """ - Create a tenant. - - This is a convenience method that wraps api.tenants.create(). - - Args: - tenant: Tenant data. - - Returns: - The created tenant. - """ - return self._api.tenants.create(tenant) - - async def create_tenant_async(self, tenant: Union[Dict[str, Any], Any]) -> Any: - """ - Create a tenant (async). - - Args: - tenant: Tenant data. - - Returns: - The created tenant. - """ - return await self._api.tenants.create_async(tenant) +""" +Main Permissio.io SDK client. +""" + +from typing import Optional, Dict, Any, Union + +from permissio.config import PermissioConfig, ConfigBuilder, resolve_config +from permissio.api.base import BaseApiClient +from permissio.api.users import UsersApi +from permissio.api.tenants import TenantsApi +from permissio.api.roles import RolesApi +from permissio.api.resources import ResourcesApi +from permissio.api.role_assignments import RoleAssignmentsApi +from permissio.models.check import CheckRequest, CheckResponse, BulkCheckRequest, BulkCheckResponse +from permissio.enforcement.models import ( + CheckUser, + CheckResource, + UserBuilder, + ResourceBuilder, + normalize_user, + normalize_resource, + normalize_context, +) + + +class PermissioApi: + """ + Container for all API clients. + + Provides access to all resource-specific API clients. + """ + + def __init__(self, config: PermissioConfig) -> None: + """ + Initialize all API clients. + + Args: + config: SDK configuration. + """ + self._config = config + self._users = UsersApi(config) + self._tenants = TenantsApi(config) + self._roles = RolesApi(config) + self._resources = ResourcesApi(config) + self._role_assignments = RoleAssignmentsApi(config) + + @property + def users(self) -> UsersApi: + """Get the Users API client.""" + return self._users + + @property + def tenants(self) -> TenantsApi: + """Get the Tenants API client.""" + return self._tenants + + @property + def roles(self) -> RolesApi: + """Get the Roles API client.""" + return self._roles + + @property + def resources(self) -> ResourcesApi: + """Get the Resources API client.""" + return self._resources + + @property + def role_assignments(self) -> RoleAssignmentsApi: + """Get the Role Assignments API client.""" + return self._role_assignments + + def close(self) -> None: + """Close all API clients.""" + self._users.close() + self._tenants.close() + self._roles.close() + self._resources.close() + self._role_assignments.close() + + async def close_async(self) -> None: + """Close all API clients (async).""" + await self._users.close_async() + await self._tenants.close_async() + await self._roles.close_async() + await self._resources.close_async() + await self._role_assignments.close_async() + + +class Permissio: + """ + Main Permissio.io SDK client. + + This is the primary entry point for interacting with the Permissio.io API. + It provides both permission checking methods and access to resource APIs. + + Example: + # Create client + permissio = Permissio( + token="permis_key_your_api_key", + project_id="my-project", + environment_id="production", + ) + + # Check permission + if permissio.check("user@example.com", "read", "document"): + print("Access granted") + + # Use API clients + users = permissio.api.users.list() + + # Close when done + permissio.close() + + Example with context manager: + with Permissio(token="permis_key_your_api_key") as permissio: + allowed = permissio.check("user@example.com", "read", "document") + """ + + def __init__( + self, + token: Optional[str] = None, + *, + config: Optional[Union[PermissioConfig, Dict[str, Any]]] = None, + api_url: Optional[str] = None, + project_id: Optional[str] = None, + environment_id: Optional[str] = None, + timeout: Optional[float] = None, + debug: bool = False, + retry_attempts: Optional[int] = None, + throw_on_error: bool = True, + custom_headers: Optional[Dict[str, str]] = None, + ) -> None: + """ + Initialize the Permissio.io SDK client. + + Args: + token: API key for authentication. Required if config is not provided. + config: Optional pre-built PermissioConfig or config dictionary. + api_url: Base URL for the API. + project_id: Project identifier. + environment_id: Environment identifier. + timeout: Request timeout in seconds. + debug: Enable debug logging. + retry_attempts: Number of retry attempts. + throw_on_error: Whether to raise exceptions on API errors. + custom_headers: Additional headers to include in requests. + """ + # Build configuration + if config is not None: + self._config = resolve_config(config) + elif token is not None: + builder = ConfigBuilder(token) + if api_url: + builder.with_api_url(api_url) + if project_id: + builder.with_project_id(project_id) + if environment_id: + builder.with_environment_id(environment_id) + if timeout: + builder.with_timeout(timeout) + builder.with_debug(debug) + if retry_attempts is not None: + builder.with_retry_attempts(retry_attempts) + builder.with_throw_on_error(throw_on_error) + if custom_headers: + builder.with_custom_headers(custom_headers) + self._config = builder.build() + else: + raise ValueError("Either 'token' or 'config' must be provided") + + # Initialize API container + self._api = PermissioApi(self._config) + + # Create base client for check operations + self._base_client = BaseApiClient(self._config) + + # Track if scope has been initialized + self._scope_initialized = False + + def init(self) -> None: + """ + Initialize the SDK by fetching project/environment scope from the API key. + + This method fetches the project_id and environment_id from the API key + if they are not already set. Call this before using the SDK if you haven't + provided project_id and environment_id in the configuration. + + Example: + permissio = Permissio(token="permis_key_your_api_key") + permissio.init() # Fetches project/environment from API key + # Now you can use the SDK + """ + if self._scope_initialized: + return + + if self._config.has_scope(): + self._scope_initialized = True + return + + self._fetch_and_set_scope() + self._scope_initialized = True + + async def init_async(self) -> None: + """ + Initialize the SDK by fetching project/environment scope from the API key (async). + + This method fetches the project_id and environment_id from the API key + if they are not already set. + """ + if self._scope_initialized: + return + + if self._config.has_scope(): + self._scope_initialized = True + return + + await self._fetch_and_set_scope_async() + self._scope_initialized = True + + def _fetch_and_set_scope(self) -> None: + """Fetch and set the project/environment scope from the API key.""" + import logging + logger = logging.getLogger("permissio") + + url = f"{self._config.api_url}/v1/api-key/scope" + try: + response = self._base_client.request("GET", url) + data = response.json() + project_id = data.get("project_id") + environment_id = data.get("environment_id") + + if project_id and environment_id: + self._config.update_scope(project_id, environment_id) + if self._config.debug: + logger.debug(f"Auto-fetched scope: project_id={project_id}, environment_id={environment_id}") + else: + raise ValueError( + "Failed to fetch API key scope. " + "Either provide project_id and environment_id in config, " + "or ensure the API key has valid scope." + ) + except Exception as e: + if not self._config.has_scope(): + raise ValueError( + f"Failed to fetch API key scope: {e}. " + "Either provide project_id and environment_id in config, " + "or ensure the API key has valid scope." + ) from e + + async def _fetch_and_set_scope_async(self) -> None: + """Fetch and set the project/environment scope from the API key (async).""" + import logging + logger = logging.getLogger("permissio") + + url = f"{self._config.api_url}/v1/api-key/scope" + try: + response = await self._base_client.request_async("GET", url) + data = response.json() + project_id = data.get("project_id") + environment_id = data.get("environment_id") + + if project_id and environment_id: + self._config.update_scope(project_id, environment_id) + if self._config.debug: + logger.debug(f"Auto-fetched scope: project_id={project_id}, environment_id={environment_id}") + else: + raise ValueError( + "Failed to fetch API key scope. " + "Either provide project_id and environment_id in config, " + "or ensure the API key has valid scope." + ) + except Exception as e: + if not self._config.has_scope(): + raise ValueError( + f"Failed to fetch API key scope: {e}. " + "Either provide project_id and environment_id in config, " + "or ensure the API key has valid scope." + ) from e + + @property + def config(self) -> PermissioConfig: + """Get the SDK configuration.""" + return self._config + + @property + def api(self) -> PermissioApi: + """Get the API clients container.""" + return self._api + + def check( + self, + user: Union[str, Dict[str, Any], CheckUser, UserBuilder], + action: str, + resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder], + *, + tenant: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> bool: + """ + Check if a user has permission to perform an action on a resource. + + Args: + user: User identifier (key, dict with attributes, or CheckUser). + action: Action to check permission for. + resource: Resource to check (type, dict with attributes, or CheckResource). + tenant: Tenant key (optional, can also be specified in resource). + context: Additional context for the check. + + Returns: + True if the action is allowed, False otherwise. + + Example: + # Simple check + allowed = permissio.check("user@example.com", "read", "document") + + # With tenant + allowed = permissio.check("user@example.com", "read", "document", tenant="acme") + + # With resource instance + allowed = permissio.check( + "user@example.com", + "read", + {"type": "document", "key": "doc-123"} + ) + + # With ABAC + allowed = permissio.check( + {"key": "user@example.com", "attributes": {"department": "engineering"}}, + "read", + {"type": "document", "attributes": {"classification": "internal"}} + ) + """ + response = self.check_with_details(user, action, resource, tenant=tenant, context=context) + return response.allowed + + async def check_async( + self, + user: Union[str, Dict[str, Any], CheckUser, UserBuilder], + action: str, + resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder], + *, + tenant: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> bool: + """ + Check if a user has permission to perform an action on a resource (async). + + Args: + user: User identifier (key, dict with attributes, or CheckUser). + action: Action to check permission for. + resource: Resource to check (type, dict with attributes, or CheckResource). + tenant: Tenant key (optional, can also be specified in resource). + context: Additional context for the check. + + Returns: + True if the action is allowed, False otherwise. + """ + response = await self.check_with_details_async(user, action, resource, tenant=tenant, context=context) + return response.allowed + + def check_with_details( + self, + user: Union[str, Dict[str, Any], CheckUser, UserBuilder], + action: str, + resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder], + *, + tenant: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> CheckResponse: + """ + Check permission and get detailed response. + + This performs client-side permission checking by: + 1. Fetching user's role assignments + 2. Fetching role definitions with permissions + 3. Checking if any role grants the required permission + + Args: + user: User identifier (key, dict with attributes, or CheckUser). + action: Action to check permission for. + resource: Resource to check (type, dict with attributes, or CheckResource). + tenant: Tenant key (optional, can also be specified in resource). + context: Additional context for the check. + + Returns: + CheckResponse with allowed status and additional details. + """ + import logging + logger = logging.getLogger("permissio") + + # Normalize inputs + user_data = normalize_user(user) + resource_data = normalize_resource(resource) + + # Extract user key and resource type + user_key = user_data.get("key", "") + resource_type = resource_data.get("type", "") + tenant_key = tenant or resource_data.get("tenant") + + # Build required permission string + required_permission = f"{resource_type}:{action}" + + if self._config.debug: + logger.debug(f"Permission check: user={user_key}, action={action}, resource={resource_type}, permission={required_permission}") + + # 1. Get user's role assignments + try: + assignments = self._api.users.get_roles(user_key, tenant=tenant_key) + except Exception as e: + return CheckResponse( + allowed=False, + reason=f"Error fetching role assignments: {e}", + ) + + if not assignments: + return CheckResponse( + allowed=False, + reason=f"User {user_key} has no role assignments", + ) + + # 2. Get unique role keys from assignments + role_keys = set(a.role for a in assignments) + + if self._config.debug: + logger.debug(f"User's role keys: {list(role_keys)}") + + # 3. Fetch all roles and build permission map + try: + roles_response = self._api.roles.list(per_page=100) + except Exception as e: + return CheckResponse( + allowed=False, + reason=f"Error fetching roles: {e}", + ) + + roles_map = {role.key: role for role in roles_response.data} + + # 4. Check if any assigned role grants the required permission + matched_roles = [] + + for role_key in role_keys: + permissions = self._get_role_permissions(role_key, roles_map, set()) + + if self._config.debug: + logger.debug(f"Role {role_key} permissions: {permissions}") + + for perm in permissions: + if (perm == required_permission or + perm == f"{resource_type}:*" or + perm == "*:*"): + matched_roles.append(role_key) + break + + allowed = len(matched_roles) > 0 + + if allowed: + reason = f"Granted by role(s): {', '.join(matched_roles)}" + else: + reason = f"No role grants permission {required_permission}" + + return CheckResponse( + allowed=allowed, + reason=reason, + matched_roles=matched_roles, + ) + + def _get_role_permissions( + self, + role_key: str, + roles_map: Dict[str, Any], + visited: set, + ) -> list: + """ + Get all permissions for a role, including inherited permissions. + + Args: + role_key: The role key. + roles_map: Map of role keys to role objects. + visited: Set of already visited role keys (for cycle detection). + + Returns: + List of permission strings. + """ + if role_key in visited: + return [] + + visited.add(role_key) + role = roles_map.get(role_key) + if not role: + return [] + + permissions = list(role.permissions) if role.permissions else [] + + # Handle role inheritance (extends) + if role.extends: + for parent_key in role.extends: + parent_permissions = self._get_role_permissions(parent_key, roles_map, visited) + permissions.extend(parent_permissions) + + return permissions + + async def check_with_details_async( + self, + user: Union[str, Dict[str, Any], CheckUser, UserBuilder], + action: str, + resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder], + *, + tenant: Optional[str] = None, + context: Optional[Dict[str, Any]] = None, + ) -> CheckResponse: + """ + Check permission and get detailed response (async). + + This performs client-side permission checking by: + 1. Fetching user's role assignments + 2. Fetching role definitions with permissions + 3. Checking if any role grants the required permission + + Args: + user: User identifier (key, dict with attributes, or CheckUser). + action: Action to check permission for. + resource: Resource to check (type, dict with attributes, or CheckResource). + tenant: Tenant key (optional, can also be specified in resource). + context: Additional context for the check. + + Returns: + CheckResponse with allowed status and additional details. + """ + import logging + logger = logging.getLogger("permissio") + + # Normalize inputs + user_data = normalize_user(user) + resource_data = normalize_resource(resource) + + # Extract user key and resource type + user_key = user_data.get("key", "") + resource_type = resource_data.get("type", "") + tenant_key = tenant or resource_data.get("tenant") + + # Build required permission string + required_permission = f"{resource_type}:{action}" + + if self._config.debug: + logger.debug(f"Permission check: user={user_key}, action={action}, resource={resource_type}, permission={required_permission}") + + # 1. Get user's role assignments + try: + assignments = await self._api.users.get_roles_async(user_key, tenant=tenant_key) + except Exception as e: + return CheckResponse( + allowed=False, + reason=f"Error fetching role assignments: {e}", + ) + + if not assignments: + return CheckResponse( + allowed=False, + reason=f"User {user_key} has no role assignments", + ) + + # 2. Get unique role keys from assignments + role_keys = set(a.role for a in assignments) + + if self._config.debug: + logger.debug(f"User's role keys: {list(role_keys)}") + + # 3. Fetch all roles and build permission map + try: + roles_response = await self._api.roles.list_async(per_page=100) + except Exception as e: + return CheckResponse( + allowed=False, + reason=f"Error fetching roles: {e}", + ) + + roles_map = {role.key: role for role in roles_response.data} + + # 4. Check if any assigned role grants the required permission + matched_roles = [] + + for role_key in role_keys: + permissions = self._get_role_permissions(role_key, roles_map, set()) + + if self._config.debug: + logger.debug(f"Role {role_key} permissions: {permissions}") + + for perm in permissions: + if (perm == required_permission or + perm == f"{resource_type}:*" or + perm == "*:*"): + matched_roles.append(role_key) + break + + allowed = len(matched_roles) > 0 + + if allowed: + reason = f"Granted by role(s): {', '.join(matched_roles)}" + else: + reason = f"No role grants permission {required_permission}" + + return CheckResponse( + allowed=allowed, + reason=reason, + matched_roles=matched_roles, + ) + + def bulk_check( + self, + checks: list, + ) -> BulkCheckResponse: + """ + Check multiple permissions in a single request. + + Args: + checks: List of check requests (dicts or CheckRequest objects). + + Returns: + BulkCheckResponse with results for each check. + + Example: + results = permissio.bulk_check([ + {"user": "user1", "action": "read", "resource": "document"}, + {"user": "user1", "action": "write", "resource": "document"}, + {"user": "user2", "action": "read", "resource": "document"}, + ]) + for result in results.results: + print(f"Allowed: {result.allowed}") + """ + check_dicts = [] + for check in checks: + if hasattr(check, "to_dict"): + check_dicts.append(check.to_dict()) + elif isinstance(check, dict): + # Normalize the check + normalized = { + "user": normalize_user(check.get("user", "")), + "action": check.get("action", ""), + "resource": normalize_resource(check.get("resource", "")), + } + if "tenant" in check: + normalized["tenant"] = check["tenant"] + if "context" in check: + normalized["context"] = check["context"] + check_dicts.append(normalized) + else: + raise ValueError(f"Invalid check type: {type(check)}") + + url = self._base_client._build_allowed_url() + "/bulk" + response = self._base_client.post(url, json={"checks": check_dicts}) + return BulkCheckResponse.from_dict(response.json()) + + async def bulk_check_async( + self, + checks: list, + ) -> BulkCheckResponse: + """ + Check multiple permissions in a single request (async). + + Args: + checks: List of check requests (dicts or CheckRequest objects). + + Returns: + BulkCheckResponse with results for each check. + """ + check_dicts = [] + for check in checks: + if hasattr(check, "to_dict"): + check_dicts.append(check.to_dict()) + elif isinstance(check, dict): + normalized = { + "user": normalize_user(check.get("user", "")), + "action": check.get("action", ""), + "resource": normalize_resource(check.get("resource", "")), + } + if "tenant" in check: + normalized["tenant"] = check["tenant"] + if "context" in check: + normalized["context"] = check["context"] + check_dicts.append(normalized) + else: + raise ValueError(f"Invalid check type: {type(check)}") + + url = self._base_client._build_allowed_url() + "/bulk" + response = await self._base_client.post_async(url, json={"checks": check_dicts}) + return BulkCheckResponse.from_dict(response.json()) + + def close(self) -> None: + """Close the client and release resources.""" + self._base_client.close() + self._api.close() + + async def close_async(self) -> None: + """Close the client and release resources (async).""" + await self._base_client.close_async() + await self._api.close_async() + + def __enter__(self) -> "Permissio": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + async def __aenter__(self) -> "Permissio": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close_async() + + # Convenience methods that mirror Permit.io SDK + + def sync_user(self, user: Union[Dict[str, Any], Any]) -> Any: + """ + Sync a user (create or update). + + This is a convenience method that wraps api.users.sync(). + + Args: + user: User data to sync. + + Returns: + The synced user. + """ + return self._api.users.sync(user) + + async def sync_user_async(self, user: Union[Dict[str, Any], Any]) -> Any: + """ + Sync a user (create or update) (async). + + Args: + user: User data to sync. + + Returns: + The synced user. + """ + return await self._api.users.sync_async(user) + + def assign_role( + self, + user: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> Any: + """ + Assign a role to a user. + + This is a convenience method that wraps api.role_assignments.assign(). + + Args: + user: User key. + role: Role key. + tenant: Tenant key (optional). + resource_instance: Resource instance key (optional). + + Returns: + The role assignment. + """ + return self._api.role_assignments.assign( + user, role, tenant=tenant, resource_instance=resource_instance + ) + + async def assign_role_async( + self, + user: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> Any: + """ + Assign a role to a user (async). + + Args: + user: User key. + role: Role key. + tenant: Tenant key (optional). + resource_instance: Resource instance key (optional). + + Returns: + The role assignment. + """ + return await self._api.role_assignments.assign_async( + user, role, tenant=tenant, resource_instance=resource_instance + ) + + def unassign_role( + self, + user: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> None: + """ + Unassign a role from a user. + + This is a convenience method that wraps api.role_assignments.unassign(). + + Args: + user: User key. + role: Role key. + tenant: Tenant key (optional). + resource_instance: Resource instance key (optional). + """ + self._api.role_assignments.unassign( + user, role, tenant=tenant, resource_instance=resource_instance + ) + + async def unassign_role_async( + self, + user: str, + role: str, + *, + tenant: Optional[str] = None, + resource_instance: Optional[str] = None, + ) -> None: + """ + Unassign a role from a user (async). + + Args: + user: User key. + role: Role key. + tenant: Tenant key (optional). + resource_instance: Resource instance key (optional). + """ + await self._api.role_assignments.unassign_async( + user, role, tenant=tenant, resource_instance=resource_instance + ) + + def create_tenant(self, tenant: Union[Dict[str, Any], Any]) -> Any: + """ + Create a tenant. + + This is a convenience method that wraps api.tenants.create(). + + Args: + tenant: Tenant data. + + Returns: + The created tenant. + """ + return self._api.tenants.create(tenant) + + async def create_tenant_async(self, tenant: Union[Dict[str, Any], Any]) -> Any: + """ + Create a tenant (async). + + Args: + tenant: Tenant data. + + Returns: + The created tenant. + """ + return await self._api.tenants.create_async(tenant) diff --git a/permisio/config.py b/permissio/config.py similarity index 87% rename from permisio/config.py rename to permissio/config.py index f341a62..23c9f0e 100644 --- a/permisio/config.py +++ b/permissio/config.py @@ -1,247 +1,247 @@ -""" -Configuration types and utilities for the Permis.io SDK. -""" - -from dataclasses import dataclass, field -from typing import Optional, Dict, Any -import httpx - -# Constants -DEFAULT_API_URL = "https://api.permis.io" -DEFAULT_TIMEOUT = 30.0 -DEFAULT_RETRY_ATTEMPTS = 3 -API_KEY_PREFIX = "permis_key_" - - -@dataclass -class PermisConfig: - """ - Configuration for the Permis.io SDK. - - Attributes: - token: API key for authentication (required). Must start with 'permis_key_'. - api_url: Base URL for the Permis.io API. Defaults to https://api.permis.io. - project_id: Project identifier. Auto-fetched from API key if not provided. - environment_id: Environment identifier. Auto-fetched from API key if not provided. - timeout: Request timeout in seconds. Defaults to 30. - debug: Enable debug logging. Defaults to False. - retry_attempts: Number of retry attempts for failed requests. Defaults to 3. - throw_on_error: Whether to raise exceptions on API errors. Defaults to True. - custom_headers: Additional headers to include in requests. - http_client: Optional custom httpx client. - """ - - token: str - api_url: str = DEFAULT_API_URL - project_id: Optional[str] = None - environment_id: Optional[str] = None - timeout: float = DEFAULT_TIMEOUT - debug: bool = False - retry_attempts: int = DEFAULT_RETRY_ATTEMPTS - throw_on_error: bool = True - custom_headers: Dict[str, str] = field(default_factory=dict) - http_client: Optional[httpx.Client] = None - - def __post_init__(self) -> None: - """Normalize API URL by removing trailing slash.""" - if self.api_url.endswith("/"): - self.api_url = self.api_url.rstrip("/") - - def has_scope(self) -> bool: - """Check if both project_id and environment_id are set.""" - return bool(self.project_id and self.environment_id) - - def update_scope(self, project_id: str, environment_id: str) -> None: - """Update the project_id and environment_id.""" - self.project_id = project_id - self.environment_id = environment_id - - def validate(self) -> None: - """ - Validate the configuration. - - Raises: - ValueError: If validation fails. - """ - if not self.token: - raise ValueError("API token is required") - - if not self.token.startswith(API_KEY_PREFIX): - raise ValueError(f"Invalid API key format: must start with '{API_KEY_PREFIX}'") - - if not self.api_url: - raise ValueError("API URL is required") - - if self.timeout <= 0: - raise ValueError("Timeout must be positive") - - if self.retry_attempts < 0: - raise ValueError("Retry attempts must be non-negative") - - def to_dict(self) -> Dict[str, Any]: - """Convert configuration to dictionary.""" - return { - "token": self.token, - "api_url": self.api_url, - "project_id": self.project_id, - "environment_id": self.environment_id, - "timeout": self.timeout, - "debug": self.debug, - "retry_attempts": self.retry_attempts, - "throw_on_error": self.throw_on_error, - "custom_headers": self.custom_headers, - } - - -class ConfigBuilder: - """ - Builder pattern for creating PermisConfig instances. - - Example: - config = ( - ConfigBuilder("permis_key_your_api_key_here") - .with_project_id("project-id") - .with_environment_id("env-id") - .with_debug(True) - .with_timeout(60.0) - .build() - ) - """ - - def __init__(self, token: str) -> None: - """ - Initialize the config builder with the API token. - - Args: - token: The API key for authentication. - """ - self._token = token - self._api_url = DEFAULT_API_URL - self._project_id: Optional[str] = None - self._environment_id: Optional[str] = None - self._timeout = DEFAULT_TIMEOUT - self._debug = False - self._retry_attempts = DEFAULT_RETRY_ATTEMPTS - self._throw_on_error = True - self._custom_headers: Dict[str, str] = {} - self._http_client: Optional[httpx.Client] = None - - def with_api_url(self, url: str) -> "ConfigBuilder": - """Set the API URL.""" - self._api_url = url - return self - - def with_project_id(self, project_id: str) -> "ConfigBuilder": - """Set the project ID.""" - self._project_id = project_id - return self - - def with_environment_id(self, environment_id: str) -> "ConfigBuilder": - """Set the environment ID.""" - self._environment_id = environment_id - return self - - def with_timeout(self, timeout: float) -> "ConfigBuilder": - """Set the request timeout in seconds.""" - self._timeout = timeout - return self - - def with_debug(self, debug: bool) -> "ConfigBuilder": - """Enable or disable debug logging.""" - self._debug = debug - return self - - def with_retry_attempts(self, attempts: int) -> "ConfigBuilder": - """Set the number of retry attempts.""" - self._retry_attempts = attempts - return self - - def with_throw_on_error(self, throw_on_error: bool) -> "ConfigBuilder": - """Set whether to raise exceptions on API errors.""" - self._throw_on_error = throw_on_error - return self - - def with_custom_header(self, key: str, value: str) -> "ConfigBuilder": - """Add a custom header.""" - self._custom_headers[key] = value - return self - - def with_custom_headers(self, headers: Dict[str, str]) -> "ConfigBuilder": - """Add multiple custom headers.""" - self._custom_headers.update(headers) - return self - - def with_http_client(self, client: httpx.Client) -> "ConfigBuilder": - """Set a custom HTTP client.""" - self._http_client = client - return self - - def build(self) -> PermisConfig: - """ - Build the configuration. - - Returns: - The built PermisConfig instance. - """ - return PermisConfig( - token=self._token, - api_url=self._api_url, - project_id=self._project_id, - environment_id=self._environment_id, - timeout=self._timeout, - debug=self._debug, - retry_attempts=self._retry_attempts, - throw_on_error=self._throw_on_error, - custom_headers=self._custom_headers.copy(), - http_client=self._http_client, - ) - - def build_with_validation(self) -> PermisConfig: - """ - Build the configuration and validate it. - - Returns: - The built and validated PermisConfig instance. - - Raises: - ValueError: If validation fails. - """ - config = self.build() - config.validate() - return config - - -def resolve_config(config: Any) -> PermisConfig: - """ - Resolve configuration from various input types. - - Args: - config: A PermisConfig instance, a dict with configuration values, - or a string (treated as API token). - - Returns: - A PermisConfig instance. - - Raises: - ValueError: If the config type is not recognized. - """ - if isinstance(config, PermisConfig): - return config - - if isinstance(config, str): - return PermisConfig(token=config) - - if isinstance(config, dict): - return PermisConfig( - token=config.get("token", ""), - api_url=config.get("api_url", DEFAULT_API_URL), - project_id=config.get("project_id"), - environment_id=config.get("environment_id"), - timeout=config.get("timeout", DEFAULT_TIMEOUT), - debug=config.get("debug", False), - retry_attempts=config.get("retry_attempts", DEFAULT_RETRY_ATTEMPTS), - throw_on_error=config.get("throw_on_error", True), - custom_headers=config.get("custom_headers", {}), - ) - - raise ValueError(f"Unsupported config type: {type(config)}") +""" +Configuration types and utilities for the Permissio.io SDK. +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any +import httpx + +# Constants +DEFAULT_API_URL = "https://api.permissio.io" +DEFAULT_TIMEOUT = 30.0 +DEFAULT_RETRY_ATTEMPTS = 3 +API_KEY_PREFIX = "permis_key_" + + +@dataclass +class PermissioConfig: + """ + Configuration for the Permissio.io SDK. + + Attributes: + token: API key for authentication (required). Must start with 'permis_key_'. + api_url: Base URL for the Permissio.io API. Defaults to https://api.permissio.io. + project_id: Project identifier. Auto-fetched from API key if not provided. + environment_id: Environment identifier. Auto-fetched from API key if not provided. + timeout: Request timeout in seconds. Defaults to 30. + debug: Enable debug logging. Defaults to False. + retry_attempts: Number of retry attempts for failed requests. Defaults to 3. + throw_on_error: Whether to raise exceptions on API errors. Defaults to True. + custom_headers: Additional headers to include in requests. + http_client: Optional custom httpx client. + """ + + token: str + api_url: str = DEFAULT_API_URL + project_id: Optional[str] = None + environment_id: Optional[str] = None + timeout: float = DEFAULT_TIMEOUT + debug: bool = False + retry_attempts: int = DEFAULT_RETRY_ATTEMPTS + throw_on_error: bool = True + custom_headers: Dict[str, str] = field(default_factory=dict) + http_client: Optional[httpx.Client] = None + + def __post_init__(self) -> None: + """Normalize API URL by removing trailing slash.""" + if self.api_url.endswith("/"): + self.api_url = self.api_url.rstrip("/") + + def has_scope(self) -> bool: + """Check if both project_id and environment_id are set.""" + return bool(self.project_id and self.environment_id) + + def update_scope(self, project_id: str, environment_id: str) -> None: + """Update the project_id and environment_id.""" + self.project_id = project_id + self.environment_id = environment_id + + def validate(self) -> None: + """ + Validate the configuration. + + Raises: + ValueError: If validation fails. + """ + if not self.token: + raise ValueError("API token is required") + + if not self.token.startswith(API_KEY_PREFIX): + raise ValueError(f"Invalid API key format: must start with '{API_KEY_PREFIX}'") + + if not self.api_url: + raise ValueError("API URL is required") + + if self.timeout <= 0: + raise ValueError("Timeout must be positive") + + if self.retry_attempts < 0: + raise ValueError("Retry attempts must be non-negative") + + def to_dict(self) -> Dict[str, Any]: + """Convert configuration to dictionary.""" + return { + "token": self.token, + "api_url": self.api_url, + "project_id": self.project_id, + "environment_id": self.environment_id, + "timeout": self.timeout, + "debug": self.debug, + "retry_attempts": self.retry_attempts, + "throw_on_error": self.throw_on_error, + "custom_headers": self.custom_headers, + } + + +class ConfigBuilder: + """ + Builder pattern for creating PermissioConfig instances. + + Example: + config = ( + ConfigBuilder("permis_key_your_api_key_here") + .with_project_id("project-id") + .with_environment_id("env-id") + .with_debug(True) + .with_timeout(60.0) + .build() + ) + """ + + def __init__(self, token: str) -> None: + """ + Initialize the config builder with the API token. + + Args: + token: The API key for authentication. + """ + self._token = token + self._api_url = DEFAULT_API_URL + self._project_id: Optional[str] = None + self._environment_id: Optional[str] = None + self._timeout = DEFAULT_TIMEOUT + self._debug = False + self._retry_attempts = DEFAULT_RETRY_ATTEMPTS + self._throw_on_error = True + self._custom_headers: Dict[str, str] = {} + self._http_client: Optional[httpx.Client] = None + + def with_api_url(self, url: str) -> "ConfigBuilder": + """Set the API URL.""" + self._api_url = url + return self + + def with_project_id(self, project_id: str) -> "ConfigBuilder": + """Set the project ID.""" + self._project_id = project_id + return self + + def with_environment_id(self, environment_id: str) -> "ConfigBuilder": + """Set the environment ID.""" + self._environment_id = environment_id + return self + + def with_timeout(self, timeout: float) -> "ConfigBuilder": + """Set the request timeout in seconds.""" + self._timeout = timeout + return self + + def with_debug(self, debug: bool) -> "ConfigBuilder": + """Enable or disable debug logging.""" + self._debug = debug + return self + + def with_retry_attempts(self, attempts: int) -> "ConfigBuilder": + """Set the number of retry attempts.""" + self._retry_attempts = attempts + return self + + def with_throw_on_error(self, throw_on_error: bool) -> "ConfigBuilder": + """Set whether to raise exceptions on API errors.""" + self._throw_on_error = throw_on_error + return self + + def with_custom_header(self, key: str, value: str) -> "ConfigBuilder": + """Add a custom header.""" + self._custom_headers[key] = value + return self + + def with_custom_headers(self, headers: Dict[str, str]) -> "ConfigBuilder": + """Add multiple custom headers.""" + self._custom_headers.update(headers) + return self + + def with_http_client(self, client: httpx.Client) -> "ConfigBuilder": + """Set a custom HTTP client.""" + self._http_client = client + return self + + def build(self) -> PermissioConfig: + """ + Build the configuration. + + Returns: + The built PermissioConfig instance. + """ + return PermissioConfig( + token=self._token, + api_url=self._api_url, + project_id=self._project_id, + environment_id=self._environment_id, + timeout=self._timeout, + debug=self._debug, + retry_attempts=self._retry_attempts, + throw_on_error=self._throw_on_error, + custom_headers=self._custom_headers.copy(), + http_client=self._http_client, + ) + + def build_with_validation(self) -> PermissioConfig: + """ + Build the configuration and validate it. + + Returns: + The built and validated PermissioConfig instance. + + Raises: + ValueError: If validation fails. + """ + config = self.build() + config.validate() + return config + + +def resolve_config(config: Any) -> PermissioConfig: + """ + Resolve configuration from various input types. + + Args: + config: A PermissioConfig instance, a dict with configuration values, + or a string (treated as API token). + + Returns: + A PermissioConfig instance. + + Raises: + ValueError: If the config type is not recognized. + """ + if isinstance(config, PermissioConfig): + return config + + if isinstance(config, str): + return PermissioConfig(token=config) + + if isinstance(config, dict): + return PermissioConfig( + token=config.get("token", ""), + api_url=config.get("api_url", DEFAULT_API_URL), + project_id=config.get("project_id"), + environment_id=config.get("environment_id"), + timeout=config.get("timeout", DEFAULT_TIMEOUT), + debug=config.get("debug", False), + retry_attempts=config.get("retry_attempts", DEFAULT_RETRY_ATTEMPTS), + throw_on_error=config.get("throw_on_error", True), + custom_headers=config.get("custom_headers", {}), + ) + + raise ValueError(f"Unsupported config type: {type(config)}") diff --git a/permisio/enforcement/__init__.py b/permissio/enforcement/__init__.py similarity index 69% rename from permisio/enforcement/__init__.py rename to permissio/enforcement/__init__.py index 1ef1c42..33befe5 100644 --- a/permisio/enforcement/__init__.py +++ b/permissio/enforcement/__init__.py @@ -1,21 +1,21 @@ -""" -Permis.io enforcement models for permission checks. -""" - -from permisio.enforcement.models import ( - UserBuilder, - ResourceBuilder, - ContextBuilder, - CheckUser, - CheckResource, - CheckContext, -) - -__all__ = [ - "UserBuilder", - "ResourceBuilder", - "ContextBuilder", - "CheckUser", - "CheckResource", - "CheckContext", -] +""" +Permissio.io enforcement models for permission checks. +""" + +from permissio.enforcement.models import ( + UserBuilder, + ResourceBuilder, + ContextBuilder, + CheckUser, + CheckResource, + CheckContext, +) + +__all__ = [ + "UserBuilder", + "ResourceBuilder", + "ContextBuilder", + "CheckUser", + "CheckResource", + "CheckContext", +] diff --git a/permisio/enforcement/models.py b/permissio/enforcement/models.py similarity index 96% rename from permisio/enforcement/models.py rename to permissio/enforcement/models.py index 7f9a52c..99a5d35 100644 --- a/permisio/enforcement/models.py +++ b/permissio/enforcement/models.py @@ -1,321 +1,321 @@ -""" -Enforcement models for permission checks in the Permis.io SDK. - -This module provides builder patterns for constructing permission check requests -with support for ABAC (Attribute-Based Access Control). -""" - -from dataclasses import dataclass, field -from typing import Optional, Dict, Any, Union - - -@dataclass -class CheckUser: - """ - User representation for permission checks. - - Attributes: - key: User identifier (user key or ID). - attributes: User attributes for ABAC. - first_name: User's first name. - last_name: User's last name. - email: User's email. - """ - - key: str - attributes: Dict[str, Any] = field(default_factory=dict) - first_name: Optional[str] = None - last_name: Optional[str] = None - email: Optional[str] = None - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for API request.""" - data: Dict[str, Any] = {"key": self.key} - if self.attributes: - data["attributes"] = self.attributes - if self.first_name is not None: - data["first_name"] = self.first_name - if self.last_name is not None: - data["last_name"] = self.last_name - if self.email is not None: - data["email"] = self.email - return data - - -@dataclass -class CheckResource: - """ - Resource representation for permission checks. - - Attributes: - type: Resource type key. - key: Resource instance key (optional). - tenant: Tenant key (optional). - attributes: Resource attributes for ABAC. - """ - - type: str - key: Optional[str] = None - tenant: Optional[str] = None - attributes: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for API request.""" - data: Dict[str, Any] = {"type": self.type} - if self.key is not None: - data["key"] = self.key - if self.tenant is not None: - data["tenant"] = self.tenant - if self.attributes: - data["attributes"] = self.attributes - return data - - -@dataclass -class CheckContext: - """ - Additional context for permission checks. - - Attributes: - data: Arbitrary context data. - """ - - data: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for API request.""" - return self.data.copy() - - -class UserBuilder: - """ - Builder for constructing CheckUser instances. - - Example: - user = ( - UserBuilder("user@example.com") - .with_attribute("department", "engineering") - .with_attribute("level", 5) - .with_first_name("John") - .with_last_name("Doe") - .build() - ) - """ - - def __init__(self, key: str) -> None: - """ - Initialize the user builder. - - Args: - key: User identifier (key or ID). - """ - self._key = key - self._attributes: Dict[str, Any] = {} - self._first_name: Optional[str] = None - self._last_name: Optional[str] = None - self._email: Optional[str] = None - - def with_attribute(self, key: str, value: Any) -> "UserBuilder": - """Add a user attribute.""" - self._attributes[key] = value - return self - - def with_attributes(self, attributes: Dict[str, Any]) -> "UserBuilder": - """Add multiple user attributes.""" - self._attributes.update(attributes) - return self - - def with_first_name(self, first_name: str) -> "UserBuilder": - """Set the user's first name.""" - self._first_name = first_name - return self - - def with_last_name(self, last_name: str) -> "UserBuilder": - """Set the user's last name.""" - self._last_name = last_name - return self - - def with_email(self, email: str) -> "UserBuilder": - """Set the user's email.""" - self._email = email - return self - - def build(self) -> CheckUser: - """Build the CheckUser instance.""" - return CheckUser( - key=self._key, - attributes=self._attributes.copy(), - first_name=self._first_name, - last_name=self._last_name, - email=self._email, - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert directly to dictionary without building.""" - return self.build().to_dict() - - -class ResourceBuilder: - """ - Builder for constructing CheckResource instances. - - Example: - resource = ( - ResourceBuilder("document") - .with_key("doc-123") - .with_tenant("acme-corp") - .with_attribute("classification", "confidential") - .build() - ) - """ - - def __init__(self, resource_type: str) -> None: - """ - Initialize the resource builder. - - Args: - resource_type: The resource type key. - """ - self._type = resource_type - self._key: Optional[str] = None - self._tenant: Optional[str] = None - self._attributes: Dict[str, Any] = {} - - def with_key(self, key: str) -> "ResourceBuilder": - """Set the resource instance key.""" - self._key = key - return self - - def with_tenant(self, tenant: str) -> "ResourceBuilder": - """Set the tenant key.""" - self._tenant = tenant - return self - - def with_attribute(self, key: str, value: Any) -> "ResourceBuilder": - """Add a resource attribute.""" - self._attributes[key] = value - return self - - def with_attributes(self, attributes: Dict[str, Any]) -> "ResourceBuilder": - """Add multiple resource attributes.""" - self._attributes.update(attributes) - return self - - def build(self) -> CheckResource: - """Build the CheckResource instance.""" - return CheckResource( - type=self._type, - key=self._key, - tenant=self._tenant, - attributes=self._attributes.copy(), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert directly to dictionary without building.""" - return self.build().to_dict() - - -class ContextBuilder: - """ - Builder for constructing CheckContext instances. - - Example: - context = ( - ContextBuilder() - .with_value("ip_address", "192.168.1.1") - .with_value("request_time", "2024-01-01T12:00:00Z") - .build() - ) - """ - - def __init__(self) -> None: - """Initialize the context builder.""" - self._data: Dict[str, Any] = {} - - def with_value(self, key: str, value: Any) -> "ContextBuilder": - """Add a context value.""" - self._data[key] = value - return self - - def with_values(self, values: Dict[str, Any]) -> "ContextBuilder": - """Add multiple context values.""" - self._data.update(values) - return self - - def build(self) -> CheckContext: - """Build the CheckContext instance.""" - return CheckContext(data=self._data.copy()) - - def to_dict(self) -> Dict[str, Any]: - """Convert directly to dictionary without building.""" - return self.build().to_dict() - - -def normalize_user(user: Union[str, Dict[str, Any], CheckUser, UserBuilder]) -> Dict[str, Any]: - """ - Normalize a user input to a dictionary. - - Args: - user: User as string (key), dict, CheckUser, or UserBuilder. - - Returns: - Dictionary representation of the user. - """ - if isinstance(user, str): - return {"key": user} - elif isinstance(user, CheckUser): - return user.to_dict() - elif isinstance(user, UserBuilder): - return user.to_dict() - elif isinstance(user, dict): - # Ensure 'key' is present - if "key" not in user and "userId" in user: - user = user.copy() - user["key"] = user.pop("userId") - return user - else: - raise ValueError(f"Invalid user type: {type(user)}") - - -def normalize_resource(resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder]) -> Dict[str, Any]: - """ - Normalize a resource input to a dictionary. - - Args: - resource: Resource as string (type), dict, CheckResource, or ResourceBuilder. - - Returns: - Dictionary representation of the resource. - """ - if isinstance(resource, str): - return {"type": resource} - elif isinstance(resource, CheckResource): - return resource.to_dict() - elif isinstance(resource, ResourceBuilder): - return resource.to_dict() - elif isinstance(resource, dict): - return resource - else: - raise ValueError(f"Invalid resource type: {type(resource)}") - - -def normalize_context(context: Optional[Union[Dict[str, Any], CheckContext, ContextBuilder]]) -> Dict[str, Any]: - """ - Normalize a context input to a dictionary. - - Args: - context: Context as dict, CheckContext, ContextBuilder, or None. - - Returns: - Dictionary representation of the context. - """ - if context is None: - return {} - elif isinstance(context, CheckContext): - return context.to_dict() - elif isinstance(context, ContextBuilder): - return context.to_dict() - elif isinstance(context, dict): - return context - else: - raise ValueError(f"Invalid context type: {type(context)}") +""" +Enforcement models for permission checks in the Permissio.io SDK. + +This module provides builder patterns for constructing permission check requests +with support for ABAC (Attribute-Based Access Control). +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, Union + + +@dataclass +class CheckUser: + """ + User representation for permission checks. + + Attributes: + key: User identifier (user key or ID). + attributes: User attributes for ABAC. + first_name: User's first name. + last_name: User's last name. + email: User's email. + """ + + key: str + attributes: Dict[str, Any] = field(default_factory=dict) + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API request.""" + data: Dict[str, Any] = {"key": self.key} + if self.attributes: + data["attributes"] = self.attributes + if self.first_name is not None: + data["first_name"] = self.first_name + if self.last_name is not None: + data["last_name"] = self.last_name + if self.email is not None: + data["email"] = self.email + return data + + +@dataclass +class CheckResource: + """ + Resource representation for permission checks. + + Attributes: + type: Resource type key. + key: Resource instance key (optional). + tenant: Tenant key (optional). + attributes: Resource attributes for ABAC. + """ + + type: str + key: Optional[str] = None + tenant: Optional[str] = None + attributes: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API request.""" + data: Dict[str, Any] = {"type": self.type} + if self.key is not None: + data["key"] = self.key + if self.tenant is not None: + data["tenant"] = self.tenant + if self.attributes: + data["attributes"] = self.attributes + return data + + +@dataclass +class CheckContext: + """ + Additional context for permission checks. + + Attributes: + data: Arbitrary context data. + """ + + data: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for API request.""" + return self.data.copy() + + +class UserBuilder: + """ + Builder for constructing CheckUser instances. + + Example: + user = ( + UserBuilder("user@example.com") + .with_attribute("department", "engineering") + .with_attribute("level", 5) + .with_first_name("John") + .with_last_name("Doe") + .build() + ) + """ + + def __init__(self, key: str) -> None: + """ + Initialize the user builder. + + Args: + key: User identifier (key or ID). + """ + self._key = key + self._attributes: Dict[str, Any] = {} + self._first_name: Optional[str] = None + self._last_name: Optional[str] = None + self._email: Optional[str] = None + + def with_attribute(self, key: str, value: Any) -> "UserBuilder": + """Add a user attribute.""" + self._attributes[key] = value + return self + + def with_attributes(self, attributes: Dict[str, Any]) -> "UserBuilder": + """Add multiple user attributes.""" + self._attributes.update(attributes) + return self + + def with_first_name(self, first_name: str) -> "UserBuilder": + """Set the user's first name.""" + self._first_name = first_name + return self + + def with_last_name(self, last_name: str) -> "UserBuilder": + """Set the user's last name.""" + self._last_name = last_name + return self + + def with_email(self, email: str) -> "UserBuilder": + """Set the user's email.""" + self._email = email + return self + + def build(self) -> CheckUser: + """Build the CheckUser instance.""" + return CheckUser( + key=self._key, + attributes=self._attributes.copy(), + first_name=self._first_name, + last_name=self._last_name, + email=self._email, + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert directly to dictionary without building.""" + return self.build().to_dict() + + +class ResourceBuilder: + """ + Builder for constructing CheckResource instances. + + Example: + resource = ( + ResourceBuilder("document") + .with_key("doc-123") + .with_tenant("acme-corp") + .with_attribute("classification", "confidential") + .build() + ) + """ + + def __init__(self, resource_type: str) -> None: + """ + Initialize the resource builder. + + Args: + resource_type: The resource type key. + """ + self._type = resource_type + self._key: Optional[str] = None + self._tenant: Optional[str] = None + self._attributes: Dict[str, Any] = {} + + def with_key(self, key: str) -> "ResourceBuilder": + """Set the resource instance key.""" + self._key = key + return self + + def with_tenant(self, tenant: str) -> "ResourceBuilder": + """Set the tenant key.""" + self._tenant = tenant + return self + + def with_attribute(self, key: str, value: Any) -> "ResourceBuilder": + """Add a resource attribute.""" + self._attributes[key] = value + return self + + def with_attributes(self, attributes: Dict[str, Any]) -> "ResourceBuilder": + """Add multiple resource attributes.""" + self._attributes.update(attributes) + return self + + def build(self) -> CheckResource: + """Build the CheckResource instance.""" + return CheckResource( + type=self._type, + key=self._key, + tenant=self._tenant, + attributes=self._attributes.copy(), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert directly to dictionary without building.""" + return self.build().to_dict() + + +class ContextBuilder: + """ + Builder for constructing CheckContext instances. + + Example: + context = ( + ContextBuilder() + .with_value("ip_address", "192.168.1.1") + .with_value("request_time", "2024-01-01T12:00:00Z") + .build() + ) + """ + + def __init__(self) -> None: + """Initialize the context builder.""" + self._data: Dict[str, Any] = {} + + def with_value(self, key: str, value: Any) -> "ContextBuilder": + """Add a context value.""" + self._data[key] = value + return self + + def with_values(self, values: Dict[str, Any]) -> "ContextBuilder": + """Add multiple context values.""" + self._data.update(values) + return self + + def build(self) -> CheckContext: + """Build the CheckContext instance.""" + return CheckContext(data=self._data.copy()) + + def to_dict(self) -> Dict[str, Any]: + """Convert directly to dictionary without building.""" + return self.build().to_dict() + + +def normalize_user(user: Union[str, Dict[str, Any], CheckUser, UserBuilder]) -> Dict[str, Any]: + """ + Normalize a user input to a dictionary. + + Args: + user: User as string (key), dict, CheckUser, or UserBuilder. + + Returns: + Dictionary representation of the user. + """ + if isinstance(user, str): + return {"key": user} + elif isinstance(user, CheckUser): + return user.to_dict() + elif isinstance(user, UserBuilder): + return user.to_dict() + elif isinstance(user, dict): + # Ensure 'key' is present + if "key" not in user and "userId" in user: + user = user.copy() + user["key"] = user.pop("userId") + return user + else: + raise ValueError(f"Invalid user type: {type(user)}") + + +def normalize_resource(resource: Union[str, Dict[str, Any], CheckResource, ResourceBuilder]) -> Dict[str, Any]: + """ + Normalize a resource input to a dictionary. + + Args: + resource: Resource as string (type), dict, CheckResource, or ResourceBuilder. + + Returns: + Dictionary representation of the resource. + """ + if isinstance(resource, str): + return {"type": resource} + elif isinstance(resource, CheckResource): + return resource.to_dict() + elif isinstance(resource, ResourceBuilder): + return resource.to_dict() + elif isinstance(resource, dict): + return resource + else: + raise ValueError(f"Invalid resource type: {type(resource)}") + + +def normalize_context(context: Optional[Union[Dict[str, Any], CheckContext, ContextBuilder]]) -> Dict[str, Any]: + """ + Normalize a context input to a dictionary. + + Args: + context: Context as dict, CheckContext, ContextBuilder, or None. + + Returns: + Dictionary representation of the context. + """ + if context is None: + return {} + elif isinstance(context, CheckContext): + return context.to_dict() + elif isinstance(context, ContextBuilder): + return context.to_dict() + elif isinstance(context, dict): + return context + else: + raise ValueError(f"Invalid context type: {type(context)}") diff --git a/permisio/errors.py b/permissio/errors.py similarity index 86% rename from permisio/errors.py rename to permissio/errors.py index 675a9fa..17d2cd4 100644 --- a/permisio/errors.py +++ b/permissio/errors.py @@ -1,202 +1,202 @@ -""" -Error types for the Permis.io SDK. -""" - -from typing import Optional, Dict, Any - - -class PermisError(Exception): - """ - Base exception for all Permis.io SDK errors. - - Attributes: - message: Human-readable error message. - """ - - def __init__(self, message: str) -> None: - super().__init__(message) - self.message = message - - -class PermisValidationError(PermisError): - """ - Exception raised for configuration or input validation errors. - - Attributes: - message: Human-readable error message. - field: The field that failed validation (optional). - """ - - def __init__(self, message: str, field: Optional[str] = None) -> None: - super().__init__(message) - self.field = field - - -class PermisApiError(PermisError): - """ - Exception raised for API request errors. - - Attributes: - message: Human-readable error message. - status_code: HTTP status code from the API response. - code: Error code from the API response (optional). - details: Additional error details from the API (optional). - request_id: Request ID for debugging (optional). - """ - - def __init__( - self, - message: str, - status_code: int, - code: Optional[str] = None, - details: Optional[Dict[str, Any]] = None, - request_id: Optional[str] = None, - ) -> None: - super().__init__(message) - self.status_code = status_code - self.code = code - self.details = details or {} - self.request_id = request_id - - @property - def is_not_found(self) -> bool: - """Check if this is a 404 Not Found error.""" - return self.status_code == 404 - - @property - def is_unauthorized(self) -> bool: - """Check if this is a 401 Unauthorized error.""" - return self.status_code == 401 - - @property - def is_forbidden(self) -> bool: - """Check if this is a 403 Forbidden error.""" - return self.status_code == 403 - - @property - def is_bad_request(self) -> bool: - """Check if this is a 400 Bad Request error.""" - return self.status_code == 400 - - @property - def is_conflict(self) -> bool: - """Check if this is a 409 Conflict error.""" - return self.status_code == 409 - - @property - def is_server_error(self) -> bool: - """Check if this is a 5xx Server Error.""" - return 500 <= self.status_code < 600 - - @property - def is_retryable(self) -> bool: - """Check if this error is potentially retryable.""" - return self.status_code in (429, 500, 502, 503, 504) - - def __str__(self) -> str: - parts = [f"PermisApiError: {self.message}"] - parts.append(f"(status_code={self.status_code}") - if self.code: - parts.append(f", code={self.code}") - if self.request_id: - parts.append(f", request_id={self.request_id}") - parts.append(")") - return "".join(parts) - - def __repr__(self) -> str: - return ( - f"PermisApiError(message={self.message!r}, status_code={self.status_code}, " - f"code={self.code!r}, details={self.details!r}, request_id={self.request_id!r})" - ) - - -class PermisNetworkError(PermisError): - """ - Exception raised for network-related errors. - - Attributes: - message: Human-readable error message. - original_error: The original exception that caused this error (optional). - """ - - def __init__(self, message: str, original_error: Optional[Exception] = None) -> None: - super().__init__(message) - self.original_error = original_error - - -class PermisTimeoutError(PermisNetworkError): - """Exception raised when a request times out.""" - - def __init__(self, message: str = "Request timed out", original_error: Optional[Exception] = None) -> None: - super().__init__(message, original_error) - - -class PermisRateLimitError(PermisApiError): - """ - Exception raised when rate limited by the API. - - Attributes: - retry_after: Seconds to wait before retrying (optional). - """ - - def __init__( - self, - message: str = "Rate limit exceeded", - retry_after: Optional[int] = None, - details: Optional[Dict[str, Any]] = None, - request_id: Optional[str] = None, - ) -> None: - super().__init__(message, status_code=429, code="rate_limit_exceeded", details=details, request_id=request_id) - self.retry_after = retry_after - - -class PermisAuthenticationError(PermisApiError): - """Exception raised for authentication failures.""" - - def __init__( - self, - message: str = "Authentication failed", - details: Optional[Dict[str, Any]] = None, - request_id: Optional[str] = None, - ) -> None: - super().__init__(message, status_code=401, code="unauthorized", details=details, request_id=request_id) - - -class PermisPermissionError(PermisApiError): - """Exception raised when the API key lacks required permissions.""" - - def __init__( - self, - message: str = "Permission denied", - details: Optional[Dict[str, Any]] = None, - request_id: Optional[str] = None, - ) -> None: - super().__init__(message, status_code=403, code="forbidden", details=details, request_id=request_id) - - -class PermisNotFoundError(PermisApiError): - """Exception raised when a resource is not found.""" - - def __init__( - self, - message: str = "Resource not found", - resource_type: Optional[str] = None, - resource_id: Optional[str] = None, - details: Optional[Dict[str, Any]] = None, - request_id: Optional[str] = None, - ) -> None: - super().__init__(message, status_code=404, code="not_found", details=details, request_id=request_id) - self.resource_type = resource_type - self.resource_id = resource_id - - -class PermisConflictError(PermisApiError): - """Exception raised when there's a resource conflict (e.g., duplicate key).""" - - def __init__( - self, - message: str = "Resource conflict", - details: Optional[Dict[str, Any]] = None, - request_id: Optional[str] = None, - ) -> None: - super().__init__(message, status_code=409, code="conflict", details=details, request_id=request_id) +""" +Error types for the Permissio.io SDK. +""" + +from typing import Optional, Dict, Any + + +class PermissioError(Exception): + """ + Base exception for all Permissio.io SDK errors. + + Attributes: + message: Human-readable error message. + """ + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class PermissioValidationError(PermissioError): + """ + Exception raised for configuration or input validation errors. + + Attributes: + message: Human-readable error message. + field: The field that failed validation (optional). + """ + + def __init__(self, message: str, field: Optional[str] = None) -> None: + super().__init__(message) + self.field = field + + +class PermissioApiError(PermissioError): + """ + Exception raised for API request errors. + + Attributes: + message: Human-readable error message. + status_code: HTTP status code from the API response. + code: Error code from the API response (optional). + details: Additional error details from the API (optional). + request_id: Request ID for debugging (optional). + """ + + def __init__( + self, + message: str, + status_code: int, + code: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.code = code + self.details = details or {} + self.request_id = request_id + + @property + def is_not_found(self) -> bool: + """Check if this is a 404 Not Found error.""" + return self.status_code == 404 + + @property + def is_unauthorized(self) -> bool: + """Check if this is a 401 Unauthorized error.""" + return self.status_code == 401 + + @property + def is_forbidden(self) -> bool: + """Check if this is a 403 Forbidden error.""" + return self.status_code == 403 + + @property + def is_bad_request(self) -> bool: + """Check if this is a 400 Bad Request error.""" + return self.status_code == 400 + + @property + def is_conflict(self) -> bool: + """Check if this is a 409 Conflict error.""" + return self.status_code == 409 + + @property + def is_server_error(self) -> bool: + """Check if this is a 5xx Server Error.""" + return 500 <= self.status_code < 600 + + @property + def is_retryable(self) -> bool: + """Check if this error is potentially retryable.""" + return self.status_code in (429, 500, 502, 503, 504) + + def __str__(self) -> str: + parts = [f"PermissioApiError: {self.message}"] + parts.append(f"(status_code={self.status_code}") + if self.code: + parts.append(f", code={self.code}") + if self.request_id: + parts.append(f", request_id={self.request_id}") + parts.append(")") + return "".join(parts) + + def __repr__(self) -> str: + return ( + f"PermissioApiError(message={self.message!r}, status_code={self.status_code}, " + f"code={self.code!r}, details={self.details!r}, request_id={self.request_id!r})" + ) + + +class PermissioNetworkError(PermissioError): + """ + Exception raised for network-related errors. + + Attributes: + message: Human-readable error message. + original_error: The original exception that caused this error (optional). + """ + + def __init__(self, message: str, original_error: Optional[Exception] = None) -> None: + super().__init__(message) + self.original_error = original_error + + +class PermissioTimeoutError(PermissioNetworkError): + """Exception raised when a request times out.""" + + def __init__(self, message: str = "Request timed out", original_error: Optional[Exception] = None) -> None: + super().__init__(message, original_error) + + +class PermissioRateLimitError(PermissioApiError): + """ + Exception raised when rate limited by the API. + + Attributes: + retry_after: Seconds to wait before retrying (optional). + """ + + def __init__( + self, + message: str = "Rate limit exceeded", + retry_after: Optional[int] = None, + details: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__(message, status_code=429, code="rate_limit_exceeded", details=details, request_id=request_id) + self.retry_after = retry_after + + +class PermissioAuthenticationError(PermissioApiError): + """Exception raised for authentication failures.""" + + def __init__( + self, + message: str = "Authentication failed", + details: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__(message, status_code=401, code="unauthorized", details=details, request_id=request_id) + + +class PermissioPermissionError(PermissioApiError): + """Exception raised when the API key lacks required permissions.""" + + def __init__( + self, + message: str = "Permission denied", + details: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__(message, status_code=403, code="forbidden", details=details, request_id=request_id) + + +class PermissioNotFoundError(PermissioApiError): + """Exception raised when a resource is not found.""" + + def __init__( + self, + message: str = "Resource not found", + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__(message, status_code=404, code="not_found", details=details, request_id=request_id) + self.resource_type = resource_type + self.resource_id = resource_id + + +class PermissioConflictError(PermissioApiError): + """Exception raised when there's a resource conflict (e.g., duplicate key).""" + + def __init__( + self, + message: str = "Resource conflict", + details: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None, + ) -> None: + super().__init__(message, status_code=409, code="conflict", details=details, request_id=request_id) diff --git a/permissio/models/__init__.py b/permissio/models/__init__.py new file mode 100644 index 0000000..ae37a4c --- /dev/null +++ b/permissio/models/__init__.py @@ -0,0 +1,44 @@ +""" +Permissio.io data models. +""" + +from permissio.models.common import PaginatedResponse, Pagination +from permissio.models.user import User, UserCreate, UserUpdate, UserRead +from permissio.models.tenant import Tenant, TenantCreate, TenantUpdate, TenantRead +from permissio.models.role import Role, RoleCreate, RoleUpdate, RoleRead +from permissio.models.resource import Resource, ResourceCreate, ResourceUpdate, ResourceRead +from permissio.models.role_assignment import RoleAssignment, RoleAssignmentCreate, RoleAssignmentRead +from permissio.models.check import CheckRequest, CheckResponse + +__all__ = [ + # Common + "PaginatedResponse", + "Pagination", + # User + "User", + "UserCreate", + "UserUpdate", + "UserRead", + # Tenant + "Tenant", + "TenantCreate", + "TenantUpdate", + "TenantRead", + # Role + "Role", + "RoleCreate", + "RoleUpdate", + "RoleRead", + # Resource + "Resource", + "ResourceCreate", + "ResourceUpdate", + "ResourceRead", + # Role Assignment + "RoleAssignment", + "RoleAssignmentCreate", + "RoleAssignmentRead", + # Check + "CheckRequest", + "CheckResponse", +] diff --git a/permisio/models/check.py b/permissio/models/check.py similarity index 93% rename from permisio/models/check.py rename to permissio/models/check.py index 90bd647..33a8a87 100644 --- a/permisio/models/check.py +++ b/permissio/models/check.py @@ -1,138 +1,141 @@ -""" -Permission check models for the Permis.io SDK. -""" - -from dataclasses import dataclass, field -from typing import Optional, Dict, Any, Union - - -@dataclass -class CheckRequest: - """ - Request for a permission check. - - Attributes: - user: User identifier (key or full user object). - action: Action to check permission for. - resource: Resource to check permission on (type or full resource object). - tenant: Tenant identifier (optional). - context: Additional context for the check (optional). - """ - - user: Union[str, Dict[str, Any]] - action: str - resource: Union[str, Dict[str, Any]] - tenant: Optional[str] = None - context: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - # Normalize user - if isinstance(self.user, str): - user_data = {"key": self.user} - else: - user_data = self.user - - # Normalize resource - if isinstance(self.resource, str): - resource_data = {"type": self.resource} - else: - resource_data = self.resource - - data: Dict[str, Any] = { - "user": user_data, - "action": self.action, - "resource": resource_data, - } - - if self.tenant is not None: - data["tenant"] = self.tenant - if self.context: - data["context"] = self.context - - return data - - -@dataclass -class CheckResponse: - """ - Response from a permission check. - - Attributes: - allowed: Whether the action is allowed. - user: User information from the check. - action: Action that was checked. - resource: Resource that was checked. - tenant: Tenant that was checked (if applicable). - reason: Reason for the decision (optional). - debug: Debug information (optional). - """ - - allowed: bool - user: Optional[Dict[str, Any]] = None - action: Optional[str] = None - resource: Optional[Dict[str, Any]] = None - tenant: Optional[str] = None - reason: Optional[str] = None - debug: Optional[Dict[str, Any]] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "CheckResponse": - """Create a CheckResponse from a dictionary.""" - return cls( - allowed=data.get("allowed", False), - user=data.get("user"), - action=data.get("action"), - resource=data.get("resource"), - tenant=data.get("tenant"), - reason=data.get("reason"), - debug=data.get("debug"), - ) - - def __bool__(self) -> bool: - """Allow using the response directly in boolean context.""" - return self.allowed - - -@dataclass -class BulkCheckRequest: - """ - Request for bulk permission checks. - - Attributes: - checks: List of check requests. - """ - - checks: list = field(default_factory=list) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - return { - "checks": [c.to_dict() if hasattr(c, "to_dict") else c for c in self.checks] - } - - -@dataclass -class BulkCheckResponse: - """ - Response from bulk permission checks. - - Attributes: - results: List of check responses. - """ - - results: list = field(default_factory=list) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "BulkCheckResponse": - """Create a BulkCheckResponse from a dictionary.""" - results = [CheckResponse.from_dict(r) for r in data.get("results", [])] - return cls(results=results) - - def all_allowed(self) -> bool: - """Check if all permissions are allowed.""" - return all(r.allowed for r in self.results) - - def any_allowed(self) -> bool: - """Check if any permission is allowed.""" - return any(r.allowed for r in self.results) +""" +Permission check models for the Permissio.io SDK. +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, Union, List + + +@dataclass +class CheckRequest: + """ + Request for a permission check. + + Attributes: + user: User identifier (key or full user object). + action: Action to check permission for. + resource: Resource to check permission on (type or full resource object). + tenant: Tenant identifier (optional). + context: Additional context for the check (optional). + """ + + user: Union[str, Dict[str, Any]] + action: str + resource: Union[str, Dict[str, Any]] + tenant: Optional[str] = None + context: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + # Normalize user + if isinstance(self.user, str): + user_data = {"key": self.user} + else: + user_data = self.user + + # Normalize resource + if isinstance(self.resource, str): + resource_data = {"type": self.resource} + else: + resource_data = self.resource + + data: Dict[str, Any] = { + "user": user_data, + "action": self.action, + "resource": resource_data, + } + + if self.tenant is not None: + data["tenant"] = self.tenant + if self.context: + data["context"] = self.context + + return data + + +@dataclass +class CheckResponse: + """ + Response from a permission check. + + Attributes: + allowed: Whether the action is allowed. + user: User information from the check. + action: Action that was checked. + resource: Resource that was checked. + tenant: Tenant that was checked (if applicable). + reason: Reason for the decision (optional). + matched_roles: List of roles that granted the permission (optional). + debug: Debug information (optional). + """ + + allowed: bool + user: Optional[Dict[str, Any]] = None + action: Optional[str] = None + resource: Optional[Dict[str, Any]] = None + tenant: Optional[str] = None + reason: Optional[str] = None + matched_roles: Optional[List[str]] = None + debug: Optional[Dict[str, Any]] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "CheckResponse": + """Create a CheckResponse from a dictionary.""" + return cls( + allowed=data.get("allowed", False), + user=data.get("user"), + action=data.get("action"), + resource=data.get("resource"), + tenant=data.get("tenant"), + reason=data.get("reason"), + matched_roles=data.get("matched_roles"), + debug=data.get("debug"), + ) + + def __bool__(self) -> bool: + """Allow using the response directly in boolean context.""" + return self.allowed + + +@dataclass +class BulkCheckRequest: + """ + Request for bulk permission checks. + + Attributes: + checks: List of check requests. + """ + + checks: list = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + return { + "checks": [c.to_dict() if hasattr(c, "to_dict") else c for c in self.checks] + } + + +@dataclass +class BulkCheckResponse: + """ + Response from bulk permission checks. + + Attributes: + results: List of check responses. + """ + + results: list = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "BulkCheckResponse": + """Create a BulkCheckResponse from a dictionary.""" + results = [CheckResponse.from_dict(r) for r in data.get("results", [])] + return cls(results=results) + + def all_allowed(self) -> bool: + """Check if all permissions are allowed.""" + return all(r.allowed for r in self.results) + + def any_allowed(self) -> bool: + """Check if any permission is allowed.""" + return any(r.allowed for r in self.results) diff --git a/permisio/models/common.py b/permissio/models/common.py similarity index 86% rename from permisio/models/common.py rename to permissio/models/common.py index c4cf331..cf224d8 100644 --- a/permisio/models/common.py +++ b/permissio/models/common.py @@ -1,138 +1,145 @@ -""" -Common models and utilities for the Permis.io SDK. -""" - -from dataclasses import dataclass, field -from typing import TypeVar, Generic, List, Optional, Dict, Any -from datetime import datetime - - -T = TypeVar("T") - - -@dataclass -class Pagination: - """ - Pagination information for list responses. - - Attributes: - page: Current page number (1-indexed). - per_page: Number of items per page. - total: Total number of items. - total_pages: Total number of pages. - """ - - page: int = 1 - per_page: int = 10 - total: int = 0 - total_pages: int = 0 - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Pagination": - """Create a Pagination from a dictionary.""" - return cls( - page=data.get("page", 1), - per_page=data.get("per_page", data.get("perPage", 10)), - total=data.get("total", 0), - total_pages=data.get("total_pages", data.get("totalPages", 0)), - ) - - -@dataclass -class PaginatedResponse(Generic[T]): - """ - Generic paginated response wrapper. - - Attributes: - data: List of items in the current page. - pagination: Pagination information. - """ - - data: List[T] - pagination: Pagination - - @classmethod - def from_dict(cls, data: Dict[str, Any], item_factory: callable) -> "PaginatedResponse[T]": - """ - Create a PaginatedResponse from a dictionary. - - Args: - data: The response dictionary. - item_factory: A callable to create items from dictionaries. - - Returns: - A PaginatedResponse instance. - """ - items = [item_factory(item) for item in data.get("data", [])] - pagination = Pagination.from_dict(data.get("pagination", {})) - return cls(data=items, pagination=pagination) - - -@dataclass -class ListParams: - """ - Common parameters for list operations. - - Attributes: - page: Page number (1-indexed). - per_page: Number of items per page. - search: Search query string. - sort_by: Field to sort by. - sort_order: Sort order ('asc' or 'desc'). - """ - - page: int = 1 - per_page: int = 10 - search: Optional[str] = None - sort_by: Optional[str] = None - sort_order: Optional[str] = None - - def to_dict(self) -> Dict[str, Any]: - """Convert to query parameters dictionary.""" - params: Dict[str, Any] = { - "page": self.page, - "per_page": self.per_page, - } - if self.search: - params["search"] = self.search - if self.sort_by: - params["sort_by"] = self.sort_by - if self.sort_order: - params["sort_order"] = self.sort_order - return params - - -def parse_datetime(value: Optional[str]) -> Optional[datetime]: - """ - Parse a datetime string from ISO format. - - Args: - value: The datetime string. - - Returns: - A datetime object or None. - """ - if value is None: - return None - try: - # Handle various ISO formats - if value.endswith("Z"): - value = value[:-1] + "+00:00" - return datetime.fromisoformat(value) - except ValueError: - return None - - -def format_datetime(value: Optional[datetime]) -> Optional[str]: - """ - Format a datetime to ISO format string. - - Args: - value: The datetime object. - - Returns: - An ISO format string or None. - """ - if value is None: - return None - return value.isoformat() +""" +Common models and utilities for the Permissio.io SDK. +""" + +from dataclasses import dataclass, field +from typing import TypeVar, Generic, List, Optional, Dict, Any +from datetime import datetime + + +T = TypeVar("T") + + +@dataclass +class Pagination: + """ + Pagination information for list responses. + + Attributes: + page: Current page number (1-indexed). + per_page: Number of items per page. + total: Total number of items. + total_pages: Total number of pages. + """ + + page: int = 1 + per_page: int = 10 + total: int = 0 + total_pages: int = 0 + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Pagination": + """Create a Pagination from a dictionary.""" + return cls( + page=data.get("page", 1), + per_page=data.get("per_page", data.get("perPage", 10)), + total=data.get("total", data.get("total_count", 0)), + total_pages=data.get("total_pages", data.get("totalPages", data.get("page_count", 0))), + ) + + +@dataclass +class PaginatedResponse(Generic[T]): + """ + Generic paginated response wrapper. + + Attributes: + data: List of items in the current page. + pagination: Pagination information. + """ + + data: List[T] + pagination: Pagination + + @classmethod + def from_dict(cls, data: Dict[str, Any], item_factory: callable) -> "PaginatedResponse[T]": + """ + Create a PaginatedResponse from a dictionary. + + Args: + data: The response dictionary. + item_factory: A callable to create items from dictionaries. + + Returns: + A PaginatedResponse instance. + """ + items = [item_factory(item) for item in data.get("data", [])] + + # Handle both nested pagination object and flat response with total_count/page_count + if "pagination" in data: + pagination = Pagination.from_dict(data.get("pagination", {})) + else: + # Flat response format: total_count, page_count at top level + pagination = Pagination.from_dict(data) + + return cls(data=items, pagination=pagination) + + +@dataclass +class ListParams: + """ + Common parameters for list operations. + + Attributes: + page: Page number (1-indexed). + per_page: Number of items per page. + search: Search query string. + sort_by: Field to sort by. + sort_order: Sort order ('asc' or 'desc'). + """ + + page: int = 1 + per_page: int = 10 + search: Optional[str] = None + sort_by: Optional[str] = None + sort_order: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to query parameters dictionary.""" + params: Dict[str, Any] = { + "page": self.page, + "per_page": self.per_page, + } + if self.search: + params["search"] = self.search + if self.sort_by: + params["sort_by"] = self.sort_by + if self.sort_order: + params["sort_order"] = self.sort_order + return params + + +def parse_datetime(value: Optional[str]) -> Optional[datetime]: + """ + Parse a datetime string from ISO format. + + Args: + value: The datetime string. + + Returns: + A datetime object or None. + """ + if value is None: + return None + try: + # Handle various ISO formats + if value.endswith("Z"): + value = value[:-1] + "+00:00" + return datetime.fromisoformat(value) + except ValueError: + return None + + +def format_datetime(value: Optional[datetime]) -> Optional[str]: + """ + Format a datetime to ISO format string. + + Args: + value: The datetime object. + + Returns: + An ISO format string or None. + """ + if value is None: + return None + return value.isoformat() diff --git a/permisio/models/resource.py b/permissio/models/resource.py similarity index 88% rename from permisio/models/resource.py rename to permissio/models/resource.py index cd1b391..f7d868b 100644 --- a/permisio/models/resource.py +++ b/permissio/models/resource.py @@ -1,240 +1,257 @@ -""" -Resource models for the Permis.io SDK. -""" - -from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List -from datetime import datetime - -from permisio.models.common import parse_datetime, format_datetime - - -@dataclass -class ResourceAction: - """ - An action that can be performed on a resource. - - Attributes: - key: Action key (e.g., 'read', 'write', 'delete'). - name: Action display name. - description: Action description. - """ - - key: str - name: Optional[str] = None - description: Optional[str] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ResourceAction": - """Create a ResourceAction from a dictionary.""" - return cls( - key=data.get("key", ""), - name=data.get("name"), - description=data.get("description"), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary.""" - data: Dict[str, Any] = {"key": self.key} - if self.name is not None: - data["name"] = self.name - if self.description is not None: - data["description"] = self.description - return data - - -@dataclass -class ResourceAttribute: - """ - An attribute of a resource type. - - Attributes: - key: Attribute key. - type: Attribute data type (e.g., 'string', 'number', 'boolean'). - description: Attribute description. - """ - - key: str - type: str = "string" - description: Optional[str] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ResourceAttribute": - """Create a ResourceAttribute from a dictionary.""" - return cls( - key=data.get("key", ""), - type=data.get("type", "string"), - description=data.get("description"), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary.""" - data: Dict[str, Any] = {"key": self.key, "type": self.type} - if self.description is not None: - data["description"] = self.description - return data - - -@dataclass -class Resource: - """ - A resource type in the Permis.io system. - - Attributes: - id: Unique resource identifier. - key: Resource key. - name: Resource display name. - description: Resource description. - actions: List of actions for this resource. - attributes: List of attributes for this resource. - urn: Resource URN pattern. - created_at: When the resource was created. - updated_at: When the resource was last updated. - """ - - id: str - key: str - name: Optional[str] = None - description: Optional[str] = None - actions: List[ResourceAction] = field(default_factory=list) - attributes: List[ResourceAttribute] = field(default_factory=list) - urn: Optional[str] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Resource": - """Create a Resource from a dictionary.""" - actions = [ResourceAction.from_dict(a) for a in data.get("actions", [])] - attributes = [ResourceAttribute.from_dict(a) for a in data.get("attributes", [])] - return cls( - id=data.get("id", ""), - key=data.get("key", ""), - name=data.get("name"), - description=data.get("description"), - actions=actions, - attributes=attributes, - urn=data.get("urn"), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary.""" - return { - "id": self.id, - "key": self.key, - "name": self.name, - "description": self.description, - "actions": [a.to_dict() for a in self.actions], - "attributes": [a.to_dict() for a in self.attributes], - "urn": self.urn, - "created_at": format_datetime(self.created_at), - "updated_at": format_datetime(self.updated_at), - } - - -@dataclass -class ResourceCreate: - """ - Data for creating a new resource type. - - Attributes: - key: Resource key. - name: Resource display name. - description: Resource description. - actions: List of actions for this resource. - attributes: List of attributes for this resource. - urn: Resource URN pattern. - """ - - key: str - name: Optional[str] = None - description: Optional[str] = None - actions: List[ResourceAction] = field(default_factory=list) - attributes: List[ResourceAttribute] = field(default_factory=list) - urn: Optional[str] = None - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = {"key": self.key} - if self.name is not None: - data["name"] = self.name - if self.description is not None: - data["description"] = self.description - if self.actions: - data["actions"] = [a.to_dict() for a in self.actions] - if self.attributes: - data["attributes"] = [a.to_dict() for a in self.attributes] - if self.urn is not None: - data["urn"] = self.urn - return data - - -@dataclass -class ResourceUpdate: - """ - Data for updating an existing resource type. - - Attributes: - name: Resource display name. - description: Resource description. - actions: List of actions for this resource. - attributes: List of attributes for this resource. - urn: Resource URN pattern. - """ - - name: Optional[str] = None - description: Optional[str] = None - actions: Optional[List[ResourceAction]] = None - attributes: Optional[List[ResourceAttribute]] = None - urn: Optional[str] = None - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = {} - if self.name is not None: - data["name"] = self.name - 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] - if self.attributes is not None: - data["attributes"] = [a.to_dict() for a in self.attributes] - if self.urn is not None: - data["urn"] = self.urn - return data - - -@dataclass -class ResourceRead: - """ - Resource data as returned from read operations (alias for Resource). - """ - - id: str - key: str - name: Optional[str] = None - description: Optional[str] = None - actions: List[ResourceAction] = field(default_factory=list) - attributes: List[ResourceAttribute] = field(default_factory=list) - urn: Optional[str] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ResourceRead": - """Create a ResourceRead from a dictionary.""" - actions = [ResourceAction.from_dict(a) for a in data.get("actions", [])] - attributes = [ResourceAttribute.from_dict(a) for a in data.get("attributes", [])] - return cls( - id=data.get("id", ""), - key=data.get("key", ""), - name=data.get("name"), - description=data.get("description"), - actions=actions, - attributes=attributes, - urn=data.get("urn"), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), - ) +""" +Resource models for the Permissio.io SDK. +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, List +from datetime import datetime + +from permissio.models.common import parse_datetime, format_datetime + + +@dataclass +class ResourceAction: + """ + An action that can be performed on a resource. + + Attributes: + key: Action key (e.g., 'read', 'write', 'delete'). + name: Action display name. + description: Action description. + """ + + key: str + name: Optional[str] = None + description: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ResourceAction": + """Create a ResourceAction from a dictionary.""" + return cls( + key=data.get("key", ""), + name=data.get("name"), + description=data.get("description"), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary.""" + data: Dict[str, Any] = {"key": self.key} + if self.name is not None: + data["name"] = self.name + if self.description is not None: + data["description"] = self.description + return data + + +@dataclass +class ResourceAttribute: + """ + An attribute of a resource type. + + Attributes: + key: Attribute key. + type: Attribute data type (e.g., 'string', 'number', 'boolean'). + description: Attribute description. + """ + + key: str + type: str = "string" + description: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ResourceAttribute": + """Create a ResourceAttribute from a dictionary.""" + return cls( + key=data.get("key", ""), + type=data.get("type", "string"), + description=data.get("description"), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary.""" + data: Dict[str, Any] = {"key": self.key, "type": self.type} + if self.description is not None: + data["description"] = self.description + return data + + +@dataclass +class Resource: + """ + A resource type in the Permissio.io system. + + Attributes: + id: Unique resource identifier. + key: Resource key. + name: Resource display name. + description: Resource description. + actions: List of actions for this resource. + attributes: List of attributes for this resource. + urn: Resource URN pattern. + created_at: When the resource was created. + updated_at: When the resource was last updated. + """ + + id: str + key: str + name: Optional[str] = None + description: Optional[str] = None + actions: List[ResourceAction] = field(default_factory=list) + attributes: List[ResourceAttribute] = field(default_factory=list) + urn: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Resource": + """Create a Resource from a dictionary.""" + actions = [ResourceAction.from_dict(a) for a in data.get("actions", [])] + attributes = [ResourceAttribute.from_dict(a) for a in data.get("attributes", [])] + return cls( + id=data.get("id", ""), + key=data.get("key", ""), + name=data.get("name"), + description=data.get("description"), + actions=actions, + attributes=attributes, + urn=data.get("urn"), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary.""" + return { + "id": self.id, + "key": self.key, + "name": self.name, + "description": self.description, + "actions": [a.to_dict() for a in self.actions], + "attributes": [a.to_dict() for a in self.attributes], + "urn": self.urn, + "created_at": format_datetime(self.created_at), + "updated_at": format_datetime(self.updated_at), + } + + +@dataclass +class ResourceCreate: + """ + Data for creating a new resource type. + + Attributes: + key: Resource key. + name: Resource display name. + description: Resource description. + actions: List of actions for this resource. + attributes: List of attributes for this resource. + urn: Resource URN pattern. + """ + + key: str + name: Optional[str] = None + description: Optional[str] = None + actions: List[ResourceAction] = field(default_factory=list) + attributes: List[ResourceAttribute] = field(default_factory=list) + urn: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = {"key": self.key} + if self.name is not None: + data["name"] = self.name + if self.description is not None: + data["description"] = self.description + if self.actions: + data["actions"] = [a.to_dict() for a in self.actions] + if self.attributes: + data["attributes"] = [a.to_dict() for a in self.attributes] + if self.urn is not None: + data["urn"] = self.urn + return data + + +@dataclass +class ResourceUpdate: + """ + Data for updating an existing resource type. + + Attributes: + name: Resource display name. + description: Resource description. + actions: List of actions for this resource. + attributes: List of attributes for this resource. + urn: Resource URN pattern. + """ + + name: Optional[str] = None + description: Optional[str] = None + actions: Optional[List[ResourceAction]] = None + attributes: Optional[List[ResourceAttribute]] = None + urn: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = {} + if self.name is not None: + data["name"] = self.name + 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] + if self.attributes is not None: + data["attributes"] = [a.to_dict() for a in self.attributes] + if self.urn is not None: + data["urn"] = self.urn + return data + + +@dataclass +class ResourceRead: + """ + Resource data as returned from read operations (alias for Resource). + """ + + id: str + key: str + name: Optional[str] = None + description: Optional[str] = None + actions: List[ResourceAction] = field(default_factory=list) + attributes: List[ResourceAttribute] = field(default_factory=list) + urn: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ResourceRead": + """Create a ResourceRead from a dictionary.""" + # Handle actions as either strings or objects + raw_actions = data.get("actions", []) + actions = [] + for a in raw_actions: + if isinstance(a, str): + actions.append(ResourceAction(key=a)) + elif isinstance(a, dict): + actions.append(ResourceAction.from_dict(a)) + + # Handle attributes as either strings or objects + raw_attributes = data.get("attributes", []) + attributes = [] + if isinstance(raw_attributes, list): + for a in raw_attributes: + if isinstance(a, str): + attributes.append(ResourceAttribute(key=a, type="string")) + elif isinstance(a, dict): + attributes.append(ResourceAttribute.from_dict(a)) + + return cls( + id=data.get("id", ""), + key=data.get("key", ""), + name=data.get("name"), + description=data.get("description"), + actions=actions, + attributes=attributes, + urn=data.get("urn"), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), + ) diff --git a/permisio/models/role.py b/permissio/models/role.py similarity index 94% rename from permisio/models/role.py rename to permissio/models/role.py index 4352eb2..9bd7944 100644 --- a/permisio/models/role.py +++ b/permissio/models/role.py @@ -1,170 +1,170 @@ -""" -Role models for the Permis.io SDK. -""" - -from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List -from datetime import datetime - -from permisio.models.common import parse_datetime, format_datetime - - -@dataclass -class Role: - """ - A role in the Permis.io system. - - Attributes: - id: Unique role identifier. - key: Role key. - name: Role display name. - description: Role description. - permissions: List of permission keys. - extends: List of role keys this role extends. - attributes: Custom role attributes. - created_at: When the role was created. - updated_at: When the role was last updated. - """ - - id: str - key: str - name: Optional[str] = None - description: Optional[str] = None - permissions: List[str] = field(default_factory=list) - extends: List[str] = field(default_factory=list) - attributes: Dict[str, Any] = field(default_factory=dict) - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Role": - """Create a Role from a dictionary.""" - return cls( - id=data.get("id", ""), - key=data.get("key", ""), - name=data.get("name"), - description=data.get("description"), - permissions=data.get("permissions", []), - extends=data.get("extends", []), - attributes=data.get("attributes", {}), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary.""" - return { - "id": self.id, - "key": self.key, - "name": self.name, - "description": self.description, - "permissions": self.permissions, - "extends": self.extends, - "attributes": self.attributes, - "created_at": format_datetime(self.created_at), - "updated_at": format_datetime(self.updated_at), - } - - -@dataclass -class RoleCreate: - """ - Data for creating a new role. - - Attributes: - key: Role key. - name: Role display name. - description: Role description. - permissions: List of permission keys. - extends: List of role keys this role extends. - attributes: Custom role attributes. - """ - - key: str - name: Optional[str] = None - description: Optional[str] = None - permissions: List[str] = field(default_factory=list) - extends: List[str] = field(default_factory=list) - attributes: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = {"key": self.key} - if self.name is not None: - data["name"] = self.name - if self.description is not None: - data["description"] = self.description - if self.permissions: - data["permissions"] = self.permissions - if self.extends: - data["extends"] = self.extends - if self.attributes: - data["attributes"] = self.attributes - return data - - -@dataclass -class RoleUpdate: - """ - Data for updating an existing role. - - Attributes: - name: Role display name. - description: Role description. - permissions: List of permission keys. - extends: List of role keys this role extends. - attributes: Custom role attributes. - """ - - name: Optional[str] = None - description: Optional[str] = None - permissions: Optional[List[str]] = None - extends: Optional[List[str]] = None - attributes: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = {} - if self.name is not None: - data["name"] = self.name - if self.description is not None: - data["description"] = self.description - if self.permissions is not None: - data["permissions"] = self.permissions - if self.extends is not None: - data["extends"] = self.extends - if self.attributes is not None: - data["attributes"] = self.attributes - return data - - -@dataclass -class RoleRead: - """ - Role data as returned from read operations (alias for Role). - """ - - id: str - key: str - name: Optional[str] = None - description: Optional[str] = None - permissions: List[str] = field(default_factory=list) - extends: List[str] = field(default_factory=list) - attributes: Dict[str, Any] = field(default_factory=dict) - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "RoleRead": - """Create a RoleRead from a dictionary.""" - return cls( - id=data.get("id", ""), - key=data.get("key", ""), - name=data.get("name"), - description=data.get("description"), - permissions=data.get("permissions", []), - extends=data.get("extends", []), - attributes=data.get("attributes", {}), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), - ) +""" +Role models for the Permissio.io SDK. +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, List +from datetime import datetime + +from permissio.models.common import parse_datetime, format_datetime + + +@dataclass +class Role: + """ + A role in the Permissio.io system. + + Attributes: + id: Unique role identifier. + key: Role key. + name: Role display name. + description: Role description. + permissions: List of permission keys. + extends: List of role keys this role extends. + attributes: Custom role attributes. + created_at: When the role was created. + updated_at: When the role was last updated. + """ + + id: str + key: str + name: Optional[str] = None + description: Optional[str] = None + permissions: List[str] = field(default_factory=list) + extends: List[str] = field(default_factory=list) + attributes: Dict[str, Any] = field(default_factory=dict) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Role": + """Create a Role from a dictionary.""" + return cls( + id=data.get("id", ""), + key=data.get("key", ""), + name=data.get("name"), + description=data.get("description"), + permissions=data.get("permissions", []), + extends=data.get("extends", []), + attributes=data.get("attributes", {}), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary.""" + return { + "id": self.id, + "key": self.key, + "name": self.name, + "description": self.description, + "permissions": self.permissions, + "extends": self.extends, + "attributes": self.attributes, + "created_at": format_datetime(self.created_at), + "updated_at": format_datetime(self.updated_at), + } + + +@dataclass +class RoleCreate: + """ + Data for creating a new role. + + Attributes: + key: Role key. + name: Role display name. + description: Role description. + permissions: List of permission keys. + extends: List of role keys this role extends. + attributes: Custom role attributes. + """ + + key: str + name: Optional[str] = None + description: Optional[str] = None + permissions: List[str] = field(default_factory=list) + extends: List[str] = field(default_factory=list) + attributes: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = {"key": self.key} + if self.name is not None: + data["name"] = self.name + if self.description is not None: + data["description"] = self.description + if self.permissions: + data["permissions"] = self.permissions + if self.extends: + data["extends"] = self.extends + if self.attributes: + data["attributes"] = self.attributes + return data + + +@dataclass +class RoleUpdate: + """ + Data for updating an existing role. + + Attributes: + name: Role display name. + description: Role description. + permissions: List of permission keys. + extends: List of role keys this role extends. + attributes: Custom role attributes. + """ + + name: Optional[str] = None + description: Optional[str] = None + permissions: Optional[List[str]] = None + extends: Optional[List[str]] = None + attributes: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = {} + if self.name is not None: + data["name"] = self.name + if self.description is not None: + data["description"] = self.description + if self.permissions is not None: + data["permissions"] = self.permissions + if self.extends is not None: + data["extends"] = self.extends + if self.attributes is not None: + data["attributes"] = self.attributes + return data + + +@dataclass +class RoleRead: + """ + Role data as returned from read operations (alias for Role). + """ + + id: str + key: str + name: Optional[str] = None + description: Optional[str] = None + permissions: List[str] = field(default_factory=list) + extends: List[str] = field(default_factory=list) + attributes: Dict[str, Any] = field(default_factory=dict) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "RoleRead": + """Create a RoleRead from a dictionary.""" + return cls( + id=data.get("id", ""), + key=data.get("key", ""), + name=data.get("name"), + description=data.get("description"), + permissions=data.get("permissions", []), + extends=data.get("extends", []), + attributes=data.get("attributes", {}), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), + ) diff --git a/permisio/models/role_assignment.py b/permissio/models/role_assignment.py similarity index 78% rename from permisio/models/role_assignment.py rename to permissio/models/role_assignment.py index d812ce1..aae0164 100644 --- a/permisio/models/role_assignment.py +++ b/permissio/models/role_assignment.py @@ -1,146 +1,146 @@ -""" -Role assignment models for the Permis.io SDK. -""" - -from dataclasses import dataclass, field -from typing import Optional, Dict, Any -from datetime import datetime - -from permisio.models.common import parse_datetime, format_datetime - - -@dataclass -class RoleAssignment: - """ - A role assignment in the Permis.io system. - - Attributes: - id: Unique role assignment identifier. - user_id: User ID. - user_key: User key. - role_id: Role ID. - role_key: Role key. - tenant_id: Tenant ID (optional). - tenant_key: Tenant key (optional). - resource_instance: Resource instance key (optional). - created_at: When the assignment was created. - """ - - id: str - user_id: str - user_key: str - role_id: str - role_key: str - tenant_id: Optional[str] = None - tenant_key: Optional[str] = None - resource_instance: Optional[str] = None - created_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "RoleAssignment": - """Create a RoleAssignment from a dictionary.""" - return cls( - id=data.get("id", ""), - user_id=data.get("user_id", data.get("userId", "")), - user_key=data.get("user_key", data.get("userKey", "")), - role_id=data.get("role_id", data.get("roleId", "")), - role_key=data.get("role_key", data.get("roleKey", "")), - tenant_id=data.get("tenant_id", data.get("tenantId")), - tenant_key=data.get("tenant_key", data.get("tenantKey")), - resource_instance=data.get("resource_instance", data.get("resourceInstance")), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary.""" - return { - "id": self.id, - "user_id": self.user_id, - "user_key": self.user_key, - "role_id": self.role_id, - "role_key": self.role_key, - "tenant_id": self.tenant_id, - "tenant_key": self.tenant_key, - "resource_instance": self.resource_instance, - "created_at": format_datetime(self.created_at), - } - - -@dataclass -class RoleAssignmentCreate: - """ - Data for creating a new role assignment. - - Attributes: - user: User key or ID. - role: Role key or ID. - tenant: Tenant key or ID (optional). - resource_instance: Resource instance key (optional). - """ - - user: str - role: str - tenant: Optional[str] = None - resource_instance: Optional[str] = None - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = { - "user": self.user, - "role": self.role, - } - if self.tenant is not None: - data["tenant"] = self.tenant - if self.resource_instance is not None: - data["resource_instance"] = self.resource_instance - return data - - -@dataclass -class RoleAssignmentRead: - """ - Role assignment data as returned from read operations. - """ - - id: str - user_id: str - user_key: str - role_id: str - role_key: str - tenant_id: Optional[str] = None - tenant_key: Optional[str] = None - resource_instance: Optional[str] = None - created_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "RoleAssignmentRead": - """Create a RoleAssignmentRead from a dictionary.""" - return cls( - id=data.get("id", ""), - user_id=data.get("user_id", data.get("userId", "")), - user_key=data.get("user_key", data.get("userKey", "")), - role_id=data.get("role_id", data.get("roleId", "")), - role_key=data.get("role_key", data.get("roleKey", "")), - tenant_id=data.get("tenant_id", data.get("tenantId")), - tenant_key=data.get("tenant_key", data.get("tenantKey")), - resource_instance=data.get("resource_instance", data.get("resourceInstance")), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - ) - - -@dataclass -class BulkRoleAssignment: - """ - Data for bulk role assignment operations. - - Attributes: - assignments: List of role assignments to create. - """ - - assignments: list = field(default_factory=list) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - return { - "assignments": [a.to_dict() if hasattr(a, "to_dict") else a for a in self.assignments] - } +""" +Role assignment models for the Permissio.io SDK. +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any +from datetime import datetime + +from permissio.models.common import parse_datetime, format_datetime + + +@dataclass +class RoleAssignment: + """ + A role assignment in the Permissio.io system. + + Attributes: + id: Unique role assignment identifier. + user: User key. + role: Role key. + tenant: Tenant key (optional). + user_id: User ID (optional). + role_id: Role ID (optional). + tenant_id: Tenant ID (optional). + resource_instance: Resource instance key (optional). + created_at: When the assignment was created. + """ + + id: str + user: str + role: str + tenant: Optional[str] = None + user_id: Optional[str] = None + role_id: Optional[str] = None + tenant_id: Optional[str] = None + resource_instance: Optional[str] = None + created_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "RoleAssignment": + """Create a RoleAssignment from a dictionary.""" + return cls( + id=data.get("id", ""), + user=data.get("user", data.get("user_key", data.get("userKey", ""))), + role=data.get("role", data.get("role_key", data.get("roleKey", ""))), + tenant=data.get("tenant", data.get("tenant_key", data.get("tenantKey"))), + user_id=data.get("user_id", data.get("userId")), + role_id=data.get("role_id", data.get("roleId")), + tenant_id=data.get("tenant_id", data.get("tenantId")), + resource_instance=data.get("resource_instance", data.get("resourceInstance")), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary.""" + return { + "id": self.id, + "user": self.user, + "role": self.role, + "tenant": self.tenant, + "user_id": self.user_id, + "role_id": self.role_id, + "tenant_id": self.tenant_id, + "resource_instance": self.resource_instance, + "created_at": format_datetime(self.created_at), + } + + +@dataclass +class RoleAssignmentCreate: + """ + Data for creating a new role assignment. + + Attributes: + user: User key or ID. + role: Role key or ID. + tenant: Tenant key or ID (optional). + resource_instance: Resource instance key (optional). + """ + + user: str + role: str + tenant: Optional[str] = None + resource_instance: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = { + "user": self.user, + "role": self.role, + } + if self.tenant is not None: + data["tenant"] = self.tenant + if self.resource_instance is not None: + data["resource_instance"] = self.resource_instance + return data + + +@dataclass +class RoleAssignmentRead: + """ + Role assignment data as returned from read operations. + """ + + id: str + user_id: str + user_key: str + role_id: str + role_key: str + tenant_id: Optional[str] = None + tenant_key: Optional[str] = None + resource_instance: Optional[str] = None + created_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "RoleAssignmentRead": + """Create a RoleAssignmentRead from a dictionary.""" + return cls( + id=data.get("id", ""), + user_id=data.get("user_id", data.get("userId", "")), + user_key=data.get("user_key", data.get("userKey", "")), + role_id=data.get("role_id", data.get("roleId", "")), + role_key=data.get("role_key", data.get("roleKey", "")), + tenant_id=data.get("tenant_id", data.get("tenantId")), + tenant_key=data.get("tenant_key", data.get("tenantKey")), + resource_instance=data.get("resource_instance", data.get("resourceInstance")), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + ) + + +@dataclass +class BulkRoleAssignment: + """ + Data for bulk role assignment operations. + + Attributes: + assignments: List of role assignments to create. + """ + + assignments: list = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + return { + "assignments": [a.to_dict() if hasattr(a, "to_dict") else a for a in self.assignments] + } diff --git a/permisio/models/tenant.py b/permissio/models/tenant.py similarity index 93% rename from permisio/models/tenant.py rename to permissio/models/tenant.py index e155d79..0710050 100644 --- a/permisio/models/tenant.py +++ b/permissio/models/tenant.py @@ -1,142 +1,142 @@ -""" -Tenant models for the Permis.io SDK. -""" - -from dataclasses import dataclass, field -from typing import Optional, Dict, Any -from datetime import datetime - -from permisio.models.common import parse_datetime, format_datetime - - -@dataclass -class Tenant: - """ - A tenant in the Permis.io system. - - Attributes: - id: Unique tenant identifier. - key: Tenant key. - name: Tenant display name. - description: Tenant description. - attributes: Custom tenant attributes. - created_at: When the tenant was created. - updated_at: When the tenant was last updated. - """ - - id: str - key: str - name: Optional[str] = None - description: Optional[str] = None - attributes: Dict[str, Any] = field(default_factory=dict) - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Tenant": - """Create a Tenant from a dictionary.""" - return cls( - id=data.get("id", ""), - key=data.get("key", ""), - name=data.get("name"), - description=data.get("description"), - attributes=data.get("attributes", {}), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary.""" - return { - "id": self.id, - "key": self.key, - "name": self.name, - "description": self.description, - "attributes": self.attributes, - "created_at": format_datetime(self.created_at), - "updated_at": format_datetime(self.updated_at), - } - - -@dataclass -class TenantCreate: - """ - Data for creating a new tenant. - - Attributes: - key: Tenant key. - name: Tenant display name. - description: Tenant description. - attributes: Custom tenant attributes. - """ - - key: str - name: Optional[str] = None - description: Optional[str] = None - attributes: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = {"key": self.key} - if self.name is not None: - data["name"] = self.name - if self.description is not None: - data["description"] = self.description - if self.attributes: - data["attributes"] = self.attributes - return data - - -@dataclass -class TenantUpdate: - """ - Data for updating an existing tenant. - - Attributes: - name: Tenant display name. - description: Tenant description. - attributes: Custom tenant attributes. - """ - - name: Optional[str] = None - description: Optional[str] = None - attributes: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = {} - if self.name is not None: - data["name"] = self.name - if self.description is not None: - data["description"] = self.description - if self.attributes is not None: - data["attributes"] = self.attributes - return data - - -@dataclass -class TenantRead: - """ - Tenant data as returned from read operations (alias for Tenant). - """ - - id: str - key: str - name: Optional[str] = None - description: Optional[str] = None - attributes: Dict[str, Any] = field(default_factory=dict) - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "TenantRead": - """Create a TenantRead from a dictionary.""" - return cls( - id=data.get("id", ""), - key=data.get("key", ""), - name=data.get("name"), - description=data.get("description"), - attributes=data.get("attributes", {}), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), - ) +""" +Tenant models for the Permissio.io SDK. +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any +from datetime import datetime + +from permissio.models.common import parse_datetime, format_datetime + + +@dataclass +class Tenant: + """ + A tenant in the Permissio.io system. + + Attributes: + id: Unique tenant identifier. + key: Tenant key. + name: Tenant display name. + description: Tenant description. + attributes: Custom tenant attributes. + created_at: When the tenant was created. + updated_at: When the tenant was last updated. + """ + + id: str + key: str + name: Optional[str] = None + description: Optional[str] = None + attributes: Dict[str, Any] = field(default_factory=dict) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Tenant": + """Create a Tenant from a dictionary.""" + return cls( + id=data.get("id", ""), + key=data.get("key", ""), + name=data.get("name"), + description=data.get("description"), + attributes=data.get("attributes", {}), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary.""" + return { + "id": self.id, + "key": self.key, + "name": self.name, + "description": self.description, + "attributes": self.attributes, + "created_at": format_datetime(self.created_at), + "updated_at": format_datetime(self.updated_at), + } + + +@dataclass +class TenantCreate: + """ + Data for creating a new tenant. + + Attributes: + key: Tenant key. + name: Tenant display name. + description: Tenant description. + attributes: Custom tenant attributes. + """ + + key: str + name: Optional[str] = None + description: Optional[str] = None + attributes: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = {"key": self.key} + if self.name is not None: + data["name"] = self.name + if self.description is not None: + data["description"] = self.description + if self.attributes: + data["attributes"] = self.attributes + return data + + +@dataclass +class TenantUpdate: + """ + Data for updating an existing tenant. + + Attributes: + name: Tenant display name. + description: Tenant description. + attributes: Custom tenant attributes. + """ + + name: Optional[str] = None + description: Optional[str] = None + attributes: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = {} + if self.name is not None: + data["name"] = self.name + if self.description is not None: + data["description"] = self.description + if self.attributes is not None: + data["attributes"] = self.attributes + return data + + +@dataclass +class TenantRead: + """ + Tenant data as returned from read operations (alias for Tenant). + """ + + id: str + key: str + name: Optional[str] = None + description: Optional[str] = None + attributes: Dict[str, Any] = field(default_factory=dict) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TenantRead": + """Create a TenantRead from a dictionary.""" + return cls( + id=data.get("id", ""), + key=data.get("key", ""), + name=data.get("name"), + description=data.get("description"), + attributes=data.get("attributes", {}), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), + ) diff --git a/permisio/models/user.py b/permissio/models/user.py similarity index 94% rename from permisio/models/user.py rename to permissio/models/user.py index 59d0bbd..0e69153 100644 --- a/permisio/models/user.py +++ b/permissio/models/user.py @@ -1,227 +1,227 @@ -""" -User models for the Permis.io SDK. -""" - -from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List -from datetime import datetime - -from permisio.models.common import parse_datetime, format_datetime - - -@dataclass -class User: - """ - A user in the Permis.io system. - - Attributes: - id: Unique user identifier. - key: User key (often email or external ID). - email: User email address. - first_name: User's first name. - last_name: User's last name. - attributes: Custom user attributes. - created_at: When the user was created. - updated_at: When the user was last updated. - """ - - id: str - key: str - email: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - attributes: Dict[str, Any] = field(default_factory=dict) - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "User": - """Create a User from a dictionary.""" - return cls( - id=data.get("id", ""), - key=data.get("key", ""), - email=data.get("email"), - first_name=data.get("first_name", data.get("firstName")), - last_name=data.get("last_name", data.get("lastName")), - attributes=data.get("attributes", {}), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary.""" - return { - "id": self.id, - "key": self.key, - "email": self.email, - "first_name": self.first_name, - "last_name": self.last_name, - "attributes": self.attributes, - "created_at": format_datetime(self.created_at), - "updated_at": format_datetime(self.updated_at), - } - - -@dataclass -class UserCreate: - """ - Data for creating a new user. - - Attributes: - key: User key (often email or external ID). - email: User email address. - first_name: User's first name. - last_name: User's last name. - attributes: Custom user attributes. - """ - - key: str - email: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - attributes: Dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = {"key": self.key} - if self.email is not None: - data["email"] = self.email - if self.first_name is not None: - data["first_name"] = self.first_name - if self.last_name is not None: - data["last_name"] = self.last_name - if self.attributes: - data["attributes"] = self.attributes - return data - - -@dataclass -class UserUpdate: - """ - Data for updating an existing user. - - Attributes: - email: User email address. - first_name: User's first name. - last_name: User's last name. - attributes: Custom user attributes. - """ - - email: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - attributes: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = {} - if self.email is not None: - data["email"] = self.email - if self.first_name is not None: - data["first_name"] = self.first_name - if self.last_name is not None: - data["last_name"] = self.last_name - if self.attributes is not None: - data["attributes"] = self.attributes - return data - - -@dataclass -class UserRead: - """ - User data as returned from read operations (alias for User). - """ - - id: str - key: str - email: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - attributes: Dict[str, Any] = field(default_factory=dict) - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "UserRead": - """Create a UserRead from a dictionary.""" - return cls( - id=data.get("id", ""), - key=data.get("key", ""), - email=data.get("email"), - first_name=data.get("first_name", data.get("firstName")), - last_name=data.get("last_name", data.get("lastName")), - attributes=data.get("attributes", {}), - created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), - updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), - ) - - -@dataclass -class UserRole: - """ - A role assigned to a user. - - Attributes: - role: Role key. - tenant: Tenant key. - resource_instance: Resource instance key (optional). - """ - - role: str - tenant: Optional[str] = None - resource_instance: Optional[str] = None - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "UserRole": - """Create a UserRole from a dictionary.""" - return cls( - role=data.get("role", ""), - tenant=data.get("tenant"), - resource_instance=data.get("resource_instance", data.get("resourceInstance")), - ) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary.""" - data: Dict[str, Any] = {"role": self.role} - if self.tenant is not None: - data["tenant"] = self.tenant - if self.resource_instance is not None: - data["resource_instance"] = self.resource_instance - return data - - -@dataclass -class UserSync: - """ - Data for syncing a user with roles. - - Attributes: - key: User key. - email: User email address. - first_name: User's first name. - last_name: User's last name. - attributes: Custom user attributes. - roles: List of roles to assign. - """ - - key: str - email: Optional[str] = None - first_name: Optional[str] = None - last_name: Optional[str] = None - attributes: Dict[str, Any] = field(default_factory=dict) - roles: List[UserRole] = field(default_factory=list) - - def to_dict(self) -> Dict[str, Any]: - """Convert to a dictionary for API request.""" - data: Dict[str, Any] = {"key": self.key} - if self.email is not None: - data["email"] = self.email - if self.first_name is not None: - data["first_name"] = self.first_name - if self.last_name is not None: - data["last_name"] = self.last_name - if self.attributes: - data["attributes"] = self.attributes - if self.roles: - data["roles"] = [r.to_dict() for r in self.roles] - return data +""" +User models for the Permissio.io SDK. +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, List +from datetime import datetime + +from permissio.models.common import parse_datetime, format_datetime + + +@dataclass +class User: + """ + A user in the Permissio.io system. + + Attributes: + id: Unique user identifier. + key: User key (often email or external ID). + email: User email address. + first_name: User's first name. + last_name: User's last name. + attributes: Custom user attributes. + created_at: When the user was created. + updated_at: When the user was last updated. + """ + + id: str + key: str + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + attributes: Dict[str, Any] = field(default_factory=dict) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "User": + """Create a User from a dictionary.""" + return cls( + id=data.get("id", ""), + key=data.get("key", ""), + email=data.get("email"), + first_name=data.get("first_name", data.get("firstName")), + last_name=data.get("last_name", data.get("lastName")), + attributes=data.get("attributes", {}), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary.""" + return { + "id": self.id, + "key": self.key, + "email": self.email, + "first_name": self.first_name, + "last_name": self.last_name, + "attributes": self.attributes, + "created_at": format_datetime(self.created_at), + "updated_at": format_datetime(self.updated_at), + } + + +@dataclass +class UserCreate: + """ + Data for creating a new user. + + Attributes: + key: User key (often email or external ID). + email: User email address. + first_name: User's first name. + last_name: User's last name. + attributes: Custom user attributes. + """ + + key: str + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + attributes: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = {"key": self.key} + if self.email is not None: + data["email"] = self.email + if self.first_name is not None: + data["first_name"] = self.first_name + if self.last_name is not None: + data["last_name"] = self.last_name + if self.attributes: + data["attributes"] = self.attributes + return data + + +@dataclass +class UserUpdate: + """ + Data for updating an existing user. + + Attributes: + email: User email address. + first_name: User's first name. + last_name: User's last name. + attributes: Custom user attributes. + """ + + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + attributes: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = {} + if self.email is not None: + data["email"] = self.email + if self.first_name is not None: + data["first_name"] = self.first_name + if self.last_name is not None: + data["last_name"] = self.last_name + if self.attributes is not None: + data["attributes"] = self.attributes + return data + + +@dataclass +class UserRead: + """ + User data as returned from read operations (alias for User). + """ + + id: str + key: str + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + attributes: Dict[str, Any] = field(default_factory=dict) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "UserRead": + """Create a UserRead from a dictionary.""" + return cls( + id=data.get("id", ""), + key=data.get("key", ""), + email=data.get("email"), + first_name=data.get("first_name", data.get("firstName")), + last_name=data.get("last_name", data.get("lastName")), + attributes=data.get("attributes", {}), + created_at=parse_datetime(data.get("created_at", data.get("createdAt"))), + updated_at=parse_datetime(data.get("updated_at", data.get("updatedAt"))), + ) + + +@dataclass +class UserRole: + """ + A role assigned to a user. + + Attributes: + role: Role key. + tenant: Tenant key. + resource_instance: Resource instance key (optional). + """ + + role: str + tenant: Optional[str] = None + resource_instance: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "UserRole": + """Create a UserRole from a dictionary.""" + return cls( + role=data.get("role", ""), + tenant=data.get("tenant"), + resource_instance=data.get("resource_instance", data.get("resourceInstance")), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary.""" + data: Dict[str, Any] = {"role": self.role} + if self.tenant is not None: + data["tenant"] = self.tenant + if self.resource_instance is not None: + data["resource_instance"] = self.resource_instance + return data + + +@dataclass +class UserSync: + """ + Data for syncing a user with roles. + + Attributes: + key: User key. + email: User email address. + first_name: User's first name. + last_name: User's last name. + attributes: Custom user attributes. + roles: List of roles to assign. + """ + + key: str + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + attributes: Dict[str, Any] = field(default_factory=dict) + roles: List[UserRole] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to a dictionary for API request.""" + data: Dict[str, Any] = {"key": self.key} + if self.email is not None: + data["email"] = self.email + if self.first_name is not None: + data["first_name"] = self.first_name + if self.last_name is not None: + data["last_name"] = self.last_name + if self.attributes: + data["attributes"] = self.attributes + if self.roles: + data["roles"] = [r.to_dict() for r in self.roles] + return data diff --git a/permisio/py.typed b/permissio/py.typed similarity index 100% rename from permisio/py.typed rename to permissio/py.typed diff --git a/permisio/sync.py b/permissio/sync.py similarity index 51% rename from permisio/sync.py rename to permissio/sync.py index 4ad7c63..a2de47b 100644 --- a/permisio/sync.py +++ b/permissio/sync.py @@ -1,104 +1,104 @@ -""" -Synchronous wrapper for the Permis.io SDK. - -This module provides a synchronous-first interface similar to Permit.io's SDK. -Use this when you prefer synchronous API calls. - -Example: - from permisio.sync import Permis - - permis = Permis( - token="permis_key_your_api_key", - project_id="my-project", - environment_id="production", - ) - - # Check permission (synchronous) - if permis.check("user@example.com", "read", "document"): - print("Access granted") -""" - -# Re-export the main Permis class for convenience -# The Permis class already provides both sync and async methods -from permisio.client import Permis, PermisApi -from permisio.config import PermisConfig, ConfigBuilder -from permisio.errors import ( - PermisError, - PermisApiError, - PermisValidationError, - PermisNetworkError, - PermisTimeoutError, - PermisRateLimitError, - PermisAuthenticationError, - PermisPermissionError, - PermisNotFoundError, - PermisConflictError, -) -from permisio.enforcement import ( - UserBuilder, - ResourceBuilder, - CheckUser, - CheckResource, - CheckContext, -) -from permisio.models import ( - User, - UserCreate, - UserUpdate, - Tenant, - TenantCreate, - TenantUpdate, - Role, - RoleCreate, - RoleUpdate, - Resource, - ResourceCreate, - ResourceUpdate, - RoleAssignment, - RoleAssignmentCreate, - CheckRequest, - CheckResponse, -) - -__all__ = [ - # Main client - "Permis", - "PermisApi", - # Config - "PermisConfig", - "ConfigBuilder", - # Errors - "PermisError", - "PermisApiError", - "PermisValidationError", - "PermisNetworkError", - "PermisTimeoutError", - "PermisRateLimitError", - "PermisAuthenticationError", - "PermisPermissionError", - "PermisNotFoundError", - "PermisConflictError", - # Enforcement - "UserBuilder", - "ResourceBuilder", - "CheckUser", - "CheckResource", - "CheckContext", - # Models - "User", - "UserCreate", - "UserUpdate", - "Tenant", - "TenantCreate", - "TenantUpdate", - "Role", - "RoleCreate", - "RoleUpdate", - "Resource", - "ResourceCreate", - "ResourceUpdate", - "RoleAssignment", - "RoleAssignmentCreate", - "CheckRequest", - "CheckResponse", -] +""" +Synchronous wrapper for the Permissio.io SDK. + +This module provides a synchronous-first interface similar to Permit.io's SDK. +Use this when you prefer synchronous API calls. + +Example: + from permissio.sync import Permissio + + permissio = Permissio( + token="permis_key_your_api_key", + project_id="my-project", + environment_id="production", + ) + + # Check permission (synchronous) + if permissio.check("user@example.com", "read", "document"): + print("Access granted") +""" + +# Re-export the main Permissio class for convenience +# The Permissio class already provides both sync and async methods +from permissio.client import Permissio, PermissioApi +from permissio.config import PermissioConfig, ConfigBuilder +from permissio.errors import ( + PermissioError, + PermissioApiError, + PermissioValidationError, + PermissioNetworkError, + PermissioTimeoutError, + PermissioRateLimitError, + PermissioAuthenticationError, + PermissioPermissionError, + PermissioNotFoundError, + PermissioConflictError, +) +from permissio.enforcement import ( + UserBuilder, + ResourceBuilder, + CheckUser, + CheckResource, + CheckContext, +) +from permissio.models import ( + User, + UserCreate, + UserUpdate, + Tenant, + TenantCreate, + TenantUpdate, + Role, + RoleCreate, + RoleUpdate, + Resource, + ResourceCreate, + ResourceUpdate, + RoleAssignment, + RoleAssignmentCreate, + CheckRequest, + CheckResponse, +) + +__all__ = [ + # Main client + "Permissio", + "PermissioApi", + # Config + "PermissioConfig", + "ConfigBuilder", + # Errors + "PermissioError", + "PermissioApiError", + "PermissioValidationError", + "PermissioNetworkError", + "PermissioTimeoutError", + "PermissioRateLimitError", + "PermissioAuthenticationError", + "PermissioPermissionError", + "PermissioNotFoundError", + "PermissioConflictError", + # Enforcement + "UserBuilder", + "ResourceBuilder", + "CheckUser", + "CheckResource", + "CheckContext", + # Models + "User", + "UserCreate", + "UserUpdate", + "Tenant", + "TenantCreate", + "TenantUpdate", + "Role", + "RoleCreate", + "RoleUpdate", + "Resource", + "ResourceCreate", + "ResourceUpdate", + "RoleAssignment", + "RoleAssignmentCreate", + "CheckRequest", + "CheckResponse", +] diff --git a/pyproject.toml b/pyproject.toml index 9324c43..8aa5fd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,18 +5,17 @@ build-backend = "setuptools.build_meta" [project] name = "permissio" version = "0.1.0" -description = "Python SDK for the Permis.io authorization platform" +description = "Python SDK for the Permissio.io authorization platform" readme = "README.md" license = {text = "MIT"} authors = [ - {name = "Permis.io Team", email = "support@permis.io"} + {name = "Permissio.io Team", email = "support@permissio.io"} ] maintainers = [ - {name = "Permis.io Team", email = "support@permis.io"} + {name = "Permissio.io Team", email = "support@permissio.io"} ] keywords = [ - "permis", - "permisio", + "permissio", "authorization", "access-control", "rbac", @@ -61,23 +60,23 @@ docs = [ ] [project.urls] -Homepage = "https://permis.io" -Documentation = "https://docs.permis.io/sdk/python" +Homepage = "https://permissio.io" +Documentation = "https://docs.permissio.io/sdk/python" Repository = "https://github.com/permissio/permissio-python" Issues = "https://github.com/permissio/permissio-python/issues" Changelog = "https://github.com/permissio/permissio-python/blob/main/CHANGELOG.md" [tool.setuptools.packages.find] where = ["."] -include = ["permisio*"] +include = ["permissio*"] [tool.setuptools.package-data] -permisio = ["py.typed"] +permissio = ["py.typed"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] -addopts = "-v --cov=permisio --cov-report=term-missing" +addopts = "-v --cov=permissio --cov-report=term-missing" [tool.mypy] python_version = "3.9" diff --git a/tests/__init__.py b/tests/__init__.py index e616c35..368a515 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -# Tests for the Permis.io Python SDK +# Tests for the Permissio.io Python SDK diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 92e5db5..0f679f1 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -1,299 +1,299 @@ -""" -Unit tests for the Permis.io Python SDK. - -Run with: pytest tests/ -v -""" - -import pytest -from permisio import Permis, PermisConfig, ConfigBuilder -from permisio.errors import ( - PermisError, - PermisApiError, - PermisValidationError, - PermisAuthenticationError, -) -from permisio.enforcement import UserBuilder, ResourceBuilder, ContextBuilder - - -class TestConfig: - """Tests for configuration.""" - - def test_permis_config_defaults(self): - config = PermisConfig(token="test_token") - assert config.token == "test_token" - assert config.api_url == "https://api.permis.io" - assert config.project_id is None # Default is None, set during ConfigBuilder - assert config.environment_id is None - assert config.timeout == 30.0 - assert config.debug is False - - def test_permis_config_custom_values(self): - config = PermisConfig( - token="test_token", - api_url="http://localhost:8080", - project_id="my-project", - environment_id="production", - timeout=60.0, - debug=True, - ) - assert config.api_url == "http://localhost:8080" - assert config.project_id == "my-project" - assert config.environment_id == "production" - assert config.timeout == 60.0 - assert config.debug is True - - def test_config_builder(self): - config = ( - ConfigBuilder("test_token") - .with_api_url("http://localhost:8080") - .with_project_id("my-project") - .with_environment_id("staging") - .with_timeout(45.0) - .with_debug(True) - .with_retry_attempts(5) - .with_throw_on_error(False) - .build() - ) - - assert config.token == "test_token" - assert config.api_url == "http://localhost:8080" - assert config.project_id == "my-project" - assert config.environment_id == "staging" - assert config.timeout == 45.0 - assert config.debug is True - assert config.retry_attempts == 5 - assert config.throw_on_error is False - - def test_config_builder_with_custom_headers(self): - config = ( - ConfigBuilder("test_token") - .with_custom_header("X-Custom", "value") - .with_custom_header("X-Another", "another") - .build() - ) - - assert config.custom_headers == {"X-Custom": "value", "X-Another": "another"} - - -class TestClient: - """Tests for client initialization.""" - - def test_client_init_with_token(self): - client = Permis(token="test_token") - assert client.config.token == "test_token" - client.close() - - def test_client_init_with_config(self): - config = PermisConfig( - token="test_token", - project_id="my-project", - ) - client = Permis(config=config) - assert client.config.project_id == "my-project" - client.close() - - def test_client_init_with_kwargs(self): - client = Permis( - token="test_token", - project_id="custom-project", - environment_id="production", - ) - assert client.config.project_id == "custom-project" - assert client.config.environment_id == "production" - client.close() - - def test_client_requires_token(self): - with pytest.raises(ValueError): # Raises ValueError, not PermisValidationError - Permis() - - def test_client_api_property(self): - client = Permis(token="test_token") - assert client.api is not None - assert client.api.users is not None - assert client.api.tenants is not None - assert client.api.roles is not None - assert client.api.resources is not None - assert client.api.role_assignments is not None - client.close() - - -class TestEnforcementBuilders: - """Tests for enforcement builders.""" - - def test_user_builder_simple(self): - user = UserBuilder("user@example.com").build() - assert user.key == "user@example.com" - assert user.attributes == {} - - def test_user_builder_with_attributes(self): - user = ( - UserBuilder("user@example.com") - .with_attribute("department", "engineering") - .with_attribute("level", 5) - .with_attributes({"location": "US", "team": "platform"}) - .build() - ) - - assert user.key == "user@example.com" - assert user.attributes["department"] == "engineering" - assert user.attributes["level"] == 5 - assert user.attributes["location"] == "US" - assert user.attributes["team"] == "platform" - - def test_resource_builder_simple(self): - resource = ResourceBuilder("document").build() - assert resource.type == "document" - assert resource.key is None - assert resource.tenant is None - - def test_resource_builder_with_all_options(self): - resource = ( - ResourceBuilder("document") - .with_key("doc-123") - .with_tenant("acme-corp") - .with_attribute("classification", "confidential") - .with_attribute("owner", "user@example.com") - .build() - ) - - assert resource.type == "document" - assert resource.key == "doc-123" - assert resource.tenant == "acme-corp" - assert resource.attributes["classification"] == "confidential" - assert resource.attributes["owner"] == "user@example.com" - - def test_context_builder(self): - context = ( - ContextBuilder() - .with_value("ip_address", "192.168.1.1") - .with_value("time_of_day", "business_hours") - .with_values({"request_id": "abc123"}) - .build() - ) - - # CheckContext uses 'data' attribute, not 'values' - assert context.data["ip_address"] == "192.168.1.1" - assert context.data["time_of_day"] == "business_hours" - assert context.data["request_id"] == "abc123" - - -class TestErrors: - """Tests for error handling.""" - - def test_permis_error_hierarchy(self): - assert issubclass(PermisApiError, PermisError) - assert issubclass(PermisValidationError, PermisError) - assert issubclass(PermisAuthenticationError, PermisApiError) - - def test_permis_api_error(self): - # PermisApiError uses 'code' not 'error_code' - error = PermisApiError( - message="Not found", - status_code=404, - code="RESOURCE_NOT_FOUND", - ) - - assert error.message == "Not found" - assert error.status_code == 404 - assert error.code == "RESOURCE_NOT_FOUND" - assert "Not found" in str(error) - - def test_permis_validation_error(self): - error = PermisValidationError( - message="Invalid input", - field="email", - ) - - assert error.message == "Invalid input" - assert error.field == "email" - - -class TestModels: - """Tests for data models.""" - - def test_user_create(self): - from permisio.models import UserCreate - - user = UserCreate( - key="user@example.com", - email="user@example.com", - first_name="John", - last_name="Doe", - attributes={"department": "sales"}, - ) - - assert user.key == "user@example.com" - assert user.email == "user@example.com" - assert user.first_name == "John" - assert user.attributes["department"] == "sales" - - def test_tenant_create(self): - from permisio.models import TenantCreate - - tenant = TenantCreate( - key="acme-corp", - name="Acme Corporation", - description="A test tenant", - ) - - assert tenant.key == "acme-corp" - assert tenant.name == "Acme Corporation" - - def test_role_create(self): - from permisio.models import RoleCreate - - role = RoleCreate( - key="editor", - name="Editor", - permissions=["read", "write"], - ) - - assert role.key == "editor" - assert "read" in role.permissions - - -class TestNormalizeFunctions: - """Tests for input normalization.""" - - def test_normalize_user_string(self): - from permisio.enforcement.models import normalize_user - - # normalize_user returns a dict, not CheckUser - result = normalize_user("user@example.com") - assert result["key"] == "user@example.com" - - def test_normalize_user_dict(self): - from permisio.enforcement.models import normalize_user - - result = normalize_user({ - "key": "user@example.com", - "attributes": {"level": 5}, - }) - assert result["key"] == "user@example.com" - assert result["attributes"]["level"] == 5 - - def test_normalize_user_object(self): - from permisio.enforcement.models import normalize_user, CheckUser - - user = CheckUser(key="user@example.com", attributes={}) - result = normalize_user(user) - # Returns a dict representation, not the same object - assert result["key"] == "user@example.com" - - def test_normalize_resource_string(self): - from permisio.enforcement.models import normalize_resource - - result = normalize_resource("document") - assert result["type"] == "document" - - def test_normalize_resource_dict(self): - from permisio.enforcement.models import normalize_resource - - result = normalize_resource({ - "type": "document", - "key": "doc-123", - "tenant": "acme", - }) - assert result["type"] == "document" - assert result["key"] == "doc-123" - assert result["tenant"] == "acme" +""" +Unit tests for the Permissio.io Python SDK. + +Run with: pytest tests/ -v +""" + +import pytest +from permissio import Permissio, PermissioConfig, ConfigBuilder +from permissio.errors import ( + PermissioError, + PermissioApiError, + PermissioValidationError, + PermissioAuthenticationError, +) +from permissio.enforcement import UserBuilder, ResourceBuilder, ContextBuilder + + +class TestConfig: + """Tests for configuration.""" + + def test_permis_config_defaults(self): + config = PermissioConfig(token="test_token") + assert config.token == "test_token" + assert config.api_url == "https://api.permissio.io" + assert config.project_id is None # Default is None, set during ConfigBuilder + assert config.environment_id is None + assert config.timeout == 30.0 + assert config.debug is False + + def test_permis_config_custom_values(self): + config = PermissioConfig( + token="test_token", + api_url="http://localhost:8080", + project_id="my-project", + environment_id="production", + timeout=60.0, + debug=True, + ) + assert config.api_url == "http://localhost:8080" + assert config.project_id == "my-project" + assert config.environment_id == "production" + assert config.timeout == 60.0 + assert config.debug is True + + def test_config_builder(self): + config = ( + ConfigBuilder("test_token") + .with_api_url("http://localhost:8080") + .with_project_id("my-project") + .with_environment_id("staging") + .with_timeout(45.0) + .with_debug(True) + .with_retry_attempts(5) + .with_throw_on_error(False) + .build() + ) + + assert config.token == "test_token" + assert config.api_url == "http://localhost:8080" + assert config.project_id == "my-project" + assert config.environment_id == "staging" + assert config.timeout == 45.0 + assert config.debug is True + assert config.retry_attempts == 5 + assert config.throw_on_error is False + + def test_config_builder_with_custom_headers(self): + config = ( + ConfigBuilder("test_token") + .with_custom_header("X-Custom", "value") + .with_custom_header("X-Another", "another") + .build() + ) + + assert config.custom_headers == {"X-Custom": "value", "X-Another": "another"} + + +class TestClient: + """Tests for client initialization.""" + + def test_client_init_with_token(self): + client = Permissio(token="test_token") + assert client.config.token == "test_token" + client.close() + + def test_client_init_with_config(self): + config = PermissioConfig( + token="test_token", + project_id="my-project", + ) + client = Permissio(config=config) + assert client.config.project_id == "my-project" + client.close() + + def test_client_init_with_kwargs(self): + client = Permissio( + token="test_token", + project_id="custom-project", + environment_id="production", + ) + assert client.config.project_id == "custom-project" + assert client.config.environment_id == "production" + client.close() + + def test_client_requires_token(self): + with pytest.raises(ValueError): # Raises ValueError, not PermissioValidationError + Permissio() + + def test_client_api_property(self): + client = Permissio(token="test_token") + assert client.api is not None + assert client.api.users is not None + assert client.api.tenants is not None + assert client.api.roles is not None + assert client.api.resources is not None + assert client.api.role_assignments is not None + client.close() + + +class TestEnforcementBuilders: + """Tests for enforcement builders.""" + + def test_user_builder_simple(self): + user = UserBuilder("user@example.com").build() + assert user.key == "user@example.com" + assert user.attributes == {} + + def test_user_builder_with_attributes(self): + user = ( + UserBuilder("user@example.com") + .with_attribute("department", "engineering") + .with_attribute("level", 5) + .with_attributes({"location": "US", "team": "platform"}) + .build() + ) + + assert user.key == "user@example.com" + assert user.attributes["department"] == "engineering" + assert user.attributes["level"] == 5 + assert user.attributes["location"] == "US" + assert user.attributes["team"] == "platform" + + def test_resource_builder_simple(self): + resource = ResourceBuilder("document").build() + assert resource.type == "document" + assert resource.key is None + assert resource.tenant is None + + def test_resource_builder_with_all_options(self): + resource = ( + ResourceBuilder("document") + .with_key("doc-123") + .with_tenant("acme-corp") + .with_attribute("classification", "confidential") + .with_attribute("owner", "user@example.com") + .build() + ) + + assert resource.type == "document" + assert resource.key == "doc-123" + assert resource.tenant == "acme-corp" + assert resource.attributes["classification"] == "confidential" + assert resource.attributes["owner"] == "user@example.com" + + def test_context_builder(self): + context = ( + ContextBuilder() + .with_value("ip_address", "192.168.1.1") + .with_value("time_of_day", "business_hours") + .with_values({"request_id": "abc123"}) + .build() + ) + + # CheckContext uses 'data' attribute, not 'values' + assert context.data["ip_address"] == "192.168.1.1" + assert context.data["time_of_day"] == "business_hours" + assert context.data["request_id"] == "abc123" + + +class TestErrors: + """Tests for error handling.""" + + def test_permis_error_hierarchy(self): + assert issubclass(PermissioApiError, PermissioError) + assert issubclass(PermissioValidationError, PermissioError) + assert issubclass(PermissioAuthenticationError, PermissioApiError) + + def test_permis_api_error(self): + # PermissioApiError uses 'code' not 'error_code' + error = PermissioApiError( + message="Not found", + status_code=404, + code="RESOURCE_NOT_FOUND", + ) + + assert error.message == "Not found" + assert error.status_code == 404 + assert error.code == "RESOURCE_NOT_FOUND" + assert "Not found" in str(error) + + def test_permis_validation_error(self): + error = PermissioValidationError( + message="Invalid input", + field="email", + ) + + assert error.message == "Invalid input" + assert error.field == "email" + + +class TestModels: + """Tests for data models.""" + + def test_user_create(self): + from permissio.models import UserCreate + + user = UserCreate( + key="user@example.com", + email="user@example.com", + first_name="John", + last_name="Doe", + attributes={"department": "sales"}, + ) + + assert user.key == "user@example.com" + assert user.email == "user@example.com" + assert user.first_name == "John" + assert user.attributes["department"] == "sales" + + def test_tenant_create(self): + from permissio.models import TenantCreate + + tenant = TenantCreate( + key="acme-corp", + name="Acme Corporation", + description="A test tenant", + ) + + assert tenant.key == "acme-corp" + assert tenant.name == "Acme Corporation" + + def test_role_create(self): + from permissio.models import RoleCreate + + role = RoleCreate( + key="editor", + name="Editor", + permissions=["read", "write"], + ) + + assert role.key == "editor" + assert "read" in role.permissions + + +class TestNormalizeFunctions: + """Tests for input normalization.""" + + def test_normalize_user_string(self): + from permissio.enforcement.models import normalize_user + + # normalize_user returns a dict, not CheckUser + result = normalize_user("user@example.com") + assert result["key"] == "user@example.com" + + def test_normalize_user_dict(self): + from permissio.enforcement.models import normalize_user + + result = normalize_user({ + "key": "user@example.com", + "attributes": {"level": 5}, + }) + assert result["key"] == "user@example.com" + assert result["attributes"]["level"] == 5 + + def test_normalize_user_object(self): + from permissio.enforcement.models import normalize_user, CheckUser + + user = CheckUser(key="user@example.com", attributes={}) + result = normalize_user(user) + # Returns a dict representation, not the same object + assert result["key"] == "user@example.com" + + def test_normalize_resource_string(self): + from permissio.enforcement.models import normalize_resource + + result = normalize_resource("document") + assert result["type"] == "document" + + def test_normalize_resource_dict(self): + from permissio.enforcement.models import normalize_resource + + result = normalize_resource({ + "type": "document", + "key": "doc-123", + "tenant": "acme", + }) + assert result["type"] == "document" + assert result["key"] == "doc-123" + assert result["tenant"] == "acme"