permitly 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ *.log
2
+ .superpowers/
3
+ .DS_Store
4
+ .env
5
+ .env.backup
6
+ .env.production
7
+ .phpactor.json
8
+ .phpunit.result.cache
9
+ /.cursor/
10
+ /.idea
11
+ /.nova
12
+ /.phpunit.cache
13
+ /.vscode
14
+ /.zed
15
+ /auth.json
16
+ /node_modules
17
+ # /public/build
18
+ /public/hot
19
+ /public/storage
20
+ /storage/*.key
21
+ /storage/pail
22
+ /vendor
23
+ _ide_helper.php
24
+ Homestead.json
25
+ Homestead.yaml
26
+ Thumbs.db
27
+ .gstack/
28
+ /.worktrees
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: permitly
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Permitly — consent and delegation for AI agents
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: httpx>=0.27.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0; extra == 'dev'
10
+ Requires-Dist: respx>=0.21.0; extra == 'dev'
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "permitly"
7
+ version = "0.1.0"
8
+ description = "Python SDK for Permitly — consent and delegation for AI agents"
9
+ license = "MIT"
10
+ requires-python = ">=3.9"
11
+ dependencies = ["httpx>=0.27.0"]
12
+
13
+ [project.optional-dependencies]
14
+ dev = [
15
+ "pytest>=8.0",
16
+ "respx>=0.21.0",
17
+ ]
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/permitly"]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
@@ -0,0 +1,18 @@
1
+ from .client import PermitlyClient
2
+ from .exceptions import AuthError, PermitlyError, UsageLimitError, ValidationError
3
+ from .models import AuditEvent, ConsentRequest, PageResult
4
+ from .scope_templates import ScopeTemplates
5
+ from .webhook import verify_signature
6
+
7
+ __all__ = [
8
+ "PermitlyClient",
9
+ "PermitlyError",
10
+ "AuthError",
11
+ "ValidationError",
12
+ "UsageLimitError",
13
+ "ScopeTemplates",
14
+ "verify_signature",
15
+ "AuditEvent",
16
+ "ConsentRequest",
17
+ "PageResult",
18
+ ]
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import types
4
+ from typing import Optional, Type
5
+
6
+ import httpx
7
+
8
+ from .exceptions import AuthError, PermitlyError, UsageLimitError, ValidationError
9
+
10
+
11
+ class PermitlyClient:
12
+ """Synchronous HTTP client for the Permitly API."""
13
+
14
+ def __init__(
15
+ self,
16
+ api_key: str,
17
+ base_url: str = "https://permitly.dev",
18
+ timeout: int = 30,
19
+ ) -> None:
20
+ from .resources.consent import ConsentResource
21
+ from .resources.audit import AuditResource
22
+
23
+ self._http = httpx.Client(
24
+ base_url=base_url,
25
+ headers={
26
+ "Authorization": f"Bearer {api_key}",
27
+ "Content-Type": "application/json",
28
+ "Accept": "application/json",
29
+ },
30
+ timeout=timeout,
31
+ )
32
+ self.consent = ConsentResource(self)
33
+ self.audit = AuditResource(self)
34
+
35
+ def _request(self, method: str, path: str, **kwargs) -> dict:
36
+ """Execute an HTTP request and return the parsed JSON body.
37
+
38
+ Raises a typed exception for any non-2xx response.
39
+ """
40
+ response = self._http.request(method, path, **kwargs)
41
+ self._raise_for_status(response)
42
+ return response.json()
43
+
44
+ def _raise_for_status(self, response: httpx.Response) -> None:
45
+ """Translate HTTP error responses into typed SDK exceptions."""
46
+ if response.is_success:
47
+ return
48
+
49
+ try:
50
+ body = response.json()
51
+ error = body.get("error", {})
52
+ code = error.get("code", "unknown_error")
53
+ message = error.get("message", response.text)
54
+ errors = error.get("errors")
55
+ except Exception:
56
+ code = "unknown_error"
57
+ message = response.text
58
+ errors = None
59
+
60
+ if response.status_code == 401:
61
+ raise AuthError(message, code=code, status_code=401)
62
+ elif response.status_code == 422:
63
+ raise ValidationError(message, code=code, status_code=422, errors=errors)
64
+ elif response.status_code == 429:
65
+ raise UsageLimitError(message, code=code, status_code=429)
66
+ else:
67
+ raise PermitlyError(message, code=code, status_code=response.status_code)
68
+
69
+ def close(self) -> None:
70
+ """Close the underlying HTTP connection pool."""
71
+ self._http.close()
72
+
73
+ def __enter__(self) -> PermitlyClient:
74
+ return self
75
+
76
+ def __exit__(
77
+ self,
78
+ exc_type: Optional[Type[BaseException]],
79
+ exc_val: Optional[BaseException],
80
+ exc_tb: Optional[types.TracebackType],
81
+ ) -> None:
82
+ self.close()
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class PermitlyError(Exception):
7
+ def __init__(self, message: str, *, code: str = "unknown_error", status_code: int = 0):
8
+ super().__init__(message)
9
+ self.code = code
10
+ self.status_code = status_code
11
+
12
+
13
+ class AuthError(PermitlyError):
14
+ """Raised on 401 — invalid or missing API key."""
15
+
16
+
17
+ class ValidationError(PermitlyError):
18
+ """Raised on 422 — request payload failed server-side validation."""
19
+
20
+ def __init__(
21
+ self,
22
+ message: str,
23
+ *,
24
+ code: str = "validation_error",
25
+ status_code: int = 422,
26
+ errors: Optional[dict] = None,
27
+ ):
28
+ super().__init__(message, code=code, status_code=status_code)
29
+ self.errors = errors or {}
30
+
31
+
32
+ class UsageLimitError(PermitlyError):
33
+ """Raised on 429 — monthly consent request limit reached."""
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class ConsentRequest:
9
+ id: str
10
+ status: str
11
+ consent_url: Optional[str] = None
12
+ end_user_ref: Optional[str] = None
13
+ scopes: Optional[list] = None
14
+ created_at: Optional[str] = None
15
+ decided_at: Optional[str] = None
16
+ expires_at: Optional[str] = None
17
+
18
+
19
+ @dataclass
20
+ class AuditEvent:
21
+ id: str
22
+ event_type: str
23
+ actor: Optional[str] = None
24
+ consent_id: Optional[str] = None
25
+ created_at: Optional[str] = None
26
+
27
+
28
+ @dataclass
29
+ class PageResult:
30
+ data: list
31
+ cursor: Optional[str]
32
+ has_more: bool
33
+
34
+
35
+ def _parse_consent(data: dict) -> ConsentRequest:
36
+ return ConsentRequest(
37
+ id=data["id"],
38
+ status=data["status"],
39
+ consent_url=data.get("consent_url"),
40
+ end_user_ref=data.get("end_user_ref"),
41
+ scopes=data.get("scopes"),
42
+ created_at=data.get("created_at"),
43
+ decided_at=data.get("decided_at"),
44
+ expires_at=data.get("expires_at"),
45
+ )
46
+
47
+
48
+ def _parse_audit_event(data: dict) -> AuditEvent:
49
+ return AuditEvent(
50
+ id=data["id"],
51
+ event_type=data["event_type"],
52
+ actor=data.get("actor"),
53
+ consent_id=data.get("consent_id"),
54
+ created_at=data.get("created_at"),
55
+ )
File without changes
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Optional
4
+
5
+ from ..models import AuditEvent, PageResult, _parse_audit_event
6
+
7
+ if TYPE_CHECKING:
8
+ from ..client import PermitlyClient
9
+
10
+
11
+ class AuditResource:
12
+ """Provides access to audit log operations."""
13
+
14
+ def __init__(self, client: "PermitlyClient") -> None:
15
+ self._client = client
16
+
17
+ def list(
18
+ self,
19
+ *,
20
+ consent_id: Optional[str] = None,
21
+ event_type: Optional[str] = None,
22
+ limit: int = 20,
23
+ cursor: Optional[str] = None,
24
+ ) -> PageResult:
25
+ """List audit events with optional filtering and pagination.
26
+
27
+ Args:
28
+ consent_id: Filter to events for a specific consent request.
29
+ event_type: Filter by event type (e.g., 'approved', 'declined', 'revoked').
30
+ limit: Maximum number of results to return (default 20).
31
+ cursor: Pagination cursor from a previous response.
32
+
33
+ Returns:
34
+ A PageResult containing a list of AuditEvents plus pagination info.
35
+ """
36
+ params: dict = {"limit": limit}
37
+ if consent_id is not None:
38
+ params["consent_id"] = consent_id
39
+ if event_type is not None:
40
+ params["event_type"] = event_type
41
+ if cursor is not None:
42
+ params["cursor"] = cursor
43
+
44
+ body = self._client._request("GET", "/v1/audit", params=params)
45
+ return PageResult(
46
+ data=[_parse_audit_event(item) for item in body["data"]],
47
+ cursor=body["meta"]["cursor"],
48
+ has_more=body["meta"]["has_more"],
49
+ )
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Optional
4
+
5
+ from ..models import ConsentRequest, PageResult, _parse_consent
6
+
7
+ if TYPE_CHECKING:
8
+ from ..client import PermitlyClient
9
+
10
+
11
+ class ConsentResource:
12
+ """Provides access to consent-request lifecycle operations."""
13
+
14
+ def __init__(self, client: PermitlyClient) -> None:
15
+ self._client = client
16
+
17
+ def request(
18
+ self,
19
+ end_user_ref: str,
20
+ scopes: list[dict],
21
+ redirect_url: str,
22
+ *,
23
+ end_user_email: Optional[str] = None,
24
+ expires_in: Optional[int] = None,
25
+ metadata: Optional[dict[str, Any]] = None,
26
+ ) -> ConsentRequest:
27
+ """Create a new consent request and return the hosted consent URL.
28
+
29
+ Args:
30
+ end_user_ref: Stable identifier for the end user in the builder's system.
31
+ scopes: List of scope dicts to request approval for.
32
+ redirect_url: URL the consent page redirects to after a decision.
33
+ end_user_email: Optional email shown on the consent page.
34
+ expires_in: Optional TTL in seconds before the request auto-expires.
35
+ metadata: Optional freeform dict stored alongside the request.
36
+
37
+ Returns:
38
+ A ConsentRequest with id, status, consent_url, and expires_at populated.
39
+ """
40
+ payload: dict = {
41
+ "end_user_ref": end_user_ref,
42
+ "scopes": scopes,
43
+ "redirect_url": redirect_url,
44
+ }
45
+ if end_user_email is not None:
46
+ payload["end_user_email"] = end_user_email
47
+ if expires_in is not None:
48
+ payload["expires_in"] = expires_in
49
+ if metadata is not None:
50
+ payload["metadata"] = metadata
51
+
52
+ body = self._client._request("POST", "/v1/consent/request", json=payload)
53
+ return _parse_consent(body["data"])
54
+
55
+ def get(self, id: str) -> ConsentRequest:
56
+ """Retrieve a consent request by ID.
57
+
58
+ Args:
59
+ id: The consent request UUID.
60
+
61
+ Returns:
62
+ The full ConsentRequest including scopes and decision timestamps.
63
+ """
64
+ body = self._client._request("GET", f"/v1/consent/{id}")
65
+ return _parse_consent(body["data"])
66
+
67
+ def verify(self, token: str) -> dict[str, Any]:
68
+ """Verify a consent JWT and return its decoded claims.
69
+
70
+ Args:
71
+ token: The signed JWT returned to the agent after consent was approved.
72
+
73
+ Returns:
74
+ Dict of JWT claims: sub, agt, cid, scopes, iat, exp, jti.
75
+ """
76
+ body = self._client._request("POST", "/v1/consent/verify", json={"token": token})
77
+ return body["data"]
78
+
79
+ def revoke(self, id: str) -> ConsentRequest:
80
+ """Revoke an existing consent request.
81
+
82
+ Args:
83
+ id: The consent request UUID to revoke.
84
+
85
+ Returns:
86
+ The updated ConsentRequest with status set to 'revoked'.
87
+ """
88
+ body = self._client._request("DELETE", f"/v1/consent/{id}")
89
+ return _parse_consent(body["data"])
90
+
91
+ def list(
92
+ self,
93
+ *,
94
+ status: Optional[str] = None,
95
+ end_user_ref: Optional[str] = None,
96
+ limit: int = 20,
97
+ cursor: Optional[str] = None,
98
+ ) -> PageResult:
99
+ """List consent requests with optional filtering and pagination.
100
+
101
+ Args:
102
+ status: Filter by status ('pending', 'approved', 'declined', 'revoked', 'expired').
103
+ end_user_ref: Filter to requests for a specific end user.
104
+ limit: Maximum number of results to return (default 20).
105
+ cursor: Pagination cursor from a previous response.
106
+
107
+ Returns:
108
+ A PageResult containing a list of ConsentRequests plus pagination info.
109
+ """
110
+ params: dict = {"limit": limit}
111
+ if status is not None:
112
+ params["status"] = status
113
+ if end_user_ref is not None:
114
+ params["end_user_ref"] = end_user_ref
115
+ if cursor is not None:
116
+ params["cursor"] = cursor
117
+
118
+ body = self._client._request("GET", "/v1/consent", params=params)
119
+ return PageResult(
120
+ data=[_parse_consent(item) for item in body["data"]],
121
+ cursor=body["meta"]["cursor"],
122
+ has_more=body["meta"]["has_more"],
123
+ )
124
+
125
+ def manage_url(self, end_user_ref: str) -> str:
126
+ """Get a signed URL for the end-user's self-service consent management page.
127
+
128
+ Args:
129
+ end_user_ref: The end user's stable identifier.
130
+
131
+ Returns:
132
+ A signed, time-limited URL the builder can redirect the end user to.
133
+ """
134
+ body = self._client._request(
135
+ "GET", "/v1/consent/manage-url", params={"end_user_ref": end_user_ref}
136
+ )
137
+ return body["data"]["url"]
@@ -0,0 +1,73 @@
1
+ class ScopeTemplates:
2
+ """Pre-built scope templates for common permissions."""
3
+
4
+ SEND_EMAIL = {
5
+ "key": "send_email",
6
+ "label": "Send Emails",
7
+ "description": "Send emails on your behalf",
8
+ "reversible": True,
9
+ "risk_level": "medium",
10
+ }
11
+ READ_EMAIL = {
12
+ "key": "read_email",
13
+ "label": "Read Emails",
14
+ "description": "Read your email inbox and messages",
15
+ "reversible": True,
16
+ "risk_level": "medium",
17
+ }
18
+ READ_CALENDAR = {
19
+ "key": "read_calendar",
20
+ "label": "Read Calendar",
21
+ "description": "View your calendar events and availability",
22
+ "reversible": True,
23
+ "risk_level": "low",
24
+ }
25
+ WRITE_CALENDAR = {
26
+ "key": "write_calendar",
27
+ "label": "Modify Calendar",
28
+ "description": "Create and modify calendar events",
29
+ "reversible": True,
30
+ "risk_level": "medium",
31
+ }
32
+ BOOK_APPOINTMENT = {
33
+ "key": "book_appointment",
34
+ "label": "Book Appointments",
35
+ "description": "Schedule appointments and meetings on your behalf",
36
+ "reversible": True,
37
+ "risk_level": "medium",
38
+ }
39
+ SEND_MESSAGE = {
40
+ "key": "send_message",
41
+ "label": "Send Messages",
42
+ "description": "Send messages and chat communications on your behalf",
43
+ "reversible": True,
44
+ "risk_level": "medium",
45
+ }
46
+ READ_FILES = {
47
+ "key": "read_files",
48
+ "label": "Read Files",
49
+ "description": "Read and access your files and documents",
50
+ "reversible": True,
51
+ "risk_level": "low",
52
+ }
53
+ WRITE_FILES = {
54
+ "key": "write_files",
55
+ "label": "Modify Files",
56
+ "description": "Create, edit, and delete your files and documents",
57
+ "reversible": False,
58
+ "risk_level": "high",
59
+ }
60
+ MAKE_PURCHASE = {
61
+ "key": "make_purchase",
62
+ "label": "Make Purchases",
63
+ "description": "Make purchases and payments using your saved payment methods",
64
+ "reversible": False,
65
+ "risk_level": "high",
66
+ }
67
+ SUBMIT_FORM = {
68
+ "key": "submit_form",
69
+ "label": "Submit Forms",
70
+ "description": "Submit forms, applications, and official documents on your behalf",
71
+ "reversible": False,
72
+ "risk_level": "high",
73
+ }
@@ -0,0 +1,27 @@
1
+ import hashlib
2
+ import hmac
3
+
4
+
5
+ def verify_signature(payload: str, signature: str, secret: str) -> bool:
6
+ """Verify a Permitly webhook signature.
7
+
8
+ The signature header value is: sha256=<hmac_sha256_hex_of_payload>
9
+ Returns True if valid, False otherwise. Uses constant-time comparison.
10
+
11
+ Args:
12
+ payload: The raw request body as a string.
13
+ signature: The X-Permitly-Signature header value.
14
+ secret: The webhook secret for this agent.
15
+
16
+ Returns:
17
+ True if the signature is valid, False otherwise.
18
+ """
19
+ if not signature.startswith("sha256="):
20
+ return False
21
+ expected_digest = hmac.new(
22
+ secret.encode("utf-8"),
23
+ payload.encode("utf-8"),
24
+ hashlib.sha256,
25
+ ).hexdigest()
26
+ received_digest = signature[len("sha256=") :]
27
+ return hmac.compare_digest(expected_digest, received_digest)
File without changes
@@ -0,0 +1,11 @@
1
+ import pytest
2
+ import respx
3
+ from permitly import PermitlyClient
4
+
5
+ BASE_URL = "https://permitly.dev"
6
+ API_KEY = "sk_live_testkey1234567890123456789012"
7
+
8
+
9
+ @pytest.fixture
10
+ def client():
11
+ return PermitlyClient(API_KEY, base_url=BASE_URL)
@@ -0,0 +1,75 @@
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from permitly import PermitlyClient
6
+ from permitly.models import AuditEvent, PageResult
7
+
8
+ BASE_URL = "https://permitly.dev"
9
+
10
+ EVENT = {
11
+ "id": "evt-uuid-001",
12
+ "event_type": "approved",
13
+ "actor": "user",
14
+ "consent_id": "cr-uuid-001",
15
+ "created_at": "2026-05-25T10:01:00+00:00",
16
+ }
17
+
18
+
19
+ @pytest.fixture
20
+ def client():
21
+ return PermitlyClient("sk_live_testkey", base_url=BASE_URL)
22
+
23
+
24
+ @respx.mock(base_url=BASE_URL)
25
+ def test_audit_list_returns_page_result(respx_mock, client):
26
+ respx_mock.get("/v1/audit").mock(
27
+ return_value=httpx.Response(200, json={
28
+ "data": [EVENT],
29
+ "meta": {"cursor": None, "has_more": False},
30
+ })
31
+ )
32
+ result = client.audit.list()
33
+ assert isinstance(result, PageResult)
34
+ assert len(result.data) == 1
35
+ assert isinstance(result.data[0], AuditEvent)
36
+ assert result.data[0].event_type == "approved"
37
+ assert result.data[0].consent_id == "cr-uuid-001"
38
+ assert not result.has_more
39
+
40
+
41
+ @respx.mock(base_url=BASE_URL)
42
+ def test_audit_list_sends_filters(respx_mock, client):
43
+ route = respx_mock.get("/v1/audit").mock(
44
+ return_value=httpx.Response(200, json={"data": [], "meta": {"cursor": None, "has_more": False}})
45
+ )
46
+ client.audit.list(consent_id="cr-uuid-001", event_type="approved", limit=5)
47
+ params = dict(route.calls[0].request.url.params)
48
+ assert params["consent_id"] == "cr-uuid-001"
49
+ assert params["event_type"] == "approved"
50
+ assert params["limit"] == "5"
51
+
52
+
53
+ @respx.mock(base_url=BASE_URL)
54
+ def test_audit_list_passes_cursor(respx_mock, client):
55
+ route = respx_mock.get("/v1/audit").mock(
56
+ return_value=httpx.Response(200, json={
57
+ "data": [],
58
+ "meta": {"cursor": "bmV4dA==", "has_more": True},
59
+ })
60
+ )
61
+ result = client.audit.list(cursor="dGVzdA==")
62
+ params = dict(route.calls[0].request.url.params)
63
+ assert params["cursor"] == "dGVzdA=="
64
+ assert result.cursor == "bmV4dA=="
65
+ assert result.has_more
66
+
67
+
68
+ @respx.mock(base_url=BASE_URL)
69
+ def test_audit_list_returns_empty_page(respx_mock, client):
70
+ respx_mock.get("/v1/audit").mock(
71
+ return_value=httpx.Response(200, json={"data": [], "meta": {"cursor": None, "has_more": False}})
72
+ )
73
+ result = client.audit.list()
74
+ assert result.data == []
75
+ assert result.cursor is None
@@ -0,0 +1,81 @@
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from permitly import PermitlyClient
6
+ from permitly.exceptions import AuthError, PermitlyError, UsageLimitError, ValidationError
7
+
8
+ BASE_URL = "https://permitly.dev"
9
+
10
+
11
+ @pytest.fixture
12
+ def client():
13
+ return PermitlyClient("sk_live_testkey", base_url=BASE_URL)
14
+
15
+
16
+ def test_client_sets_auth_header(client):
17
+ assert client._http.headers["authorization"] == "Bearer sk_live_testkey"
18
+
19
+
20
+ def test_client_default_base_url():
21
+ c = PermitlyClient("sk_live_testkey")
22
+ assert str(c._http.base_url) == "https://permitly.dev"
23
+
24
+
25
+ def test_client_exposes_consent_and_audit_resources(client):
26
+ from permitly.resources.consent import ConsentResource
27
+ from permitly.resources.audit import AuditResource
28
+ assert isinstance(client.consent, ConsentResource)
29
+ assert isinstance(client.audit, AuditResource)
30
+
31
+
32
+ @respx.mock(base_url=BASE_URL)
33
+ def test_raises_auth_error_on_401(respx_mock, client):
34
+ respx_mock.get("/v1/consent/fake-id").mock(
35
+ return_value=httpx.Response(401, json={"error": {"code": "invalid_api_key", "message": "Unauthorized."}})
36
+ )
37
+ with pytest.raises(AuthError) as exc_info:
38
+ client._request("GET", "/v1/consent/fake-id")
39
+ assert exc_info.value.status_code == 401
40
+ assert exc_info.value.code == "invalid_api_key"
41
+
42
+
43
+ @respx.mock(base_url=BASE_URL)
44
+ def test_raises_validation_error_on_422(respx_mock, client):
45
+ respx_mock.post("/v1/consent/request").mock(
46
+ return_value=httpx.Response(422, json={
47
+ "error": {
48
+ "code": "validation_error",
49
+ "message": "The given data was invalid.",
50
+ "errors": {"end_user_ref": ["The end user ref field is required."]}
51
+ }
52
+ })
53
+ )
54
+ with pytest.raises(ValidationError) as exc_info:
55
+ client._request("POST", "/v1/consent/request", json={})
56
+ assert exc_info.value.status_code == 422
57
+ assert "end_user_ref" in exc_info.value.errors
58
+
59
+
60
+ @respx.mock(base_url=BASE_URL)
61
+ def test_raises_usage_limit_error_on_429(respx_mock, client):
62
+ respx_mock.post("/v1/consent/request").mock(
63
+ return_value=httpx.Response(429, json={"error": {"code": "usage_limit_exceeded", "message": "Limit reached."}})
64
+ )
65
+ with pytest.raises(UsageLimitError) as exc_info:
66
+ client._request("POST", "/v1/consent/request", json={})
67
+ assert exc_info.value.status_code == 429
68
+
69
+
70
+ @respx.mock(base_url=BASE_URL)
71
+ def test_raises_generic_permitly_error_on_500(respx_mock, client):
72
+ respx_mock.get("/v1/consent/x").mock(return_value=httpx.Response(500, text="Server Error"))
73
+ with pytest.raises(PermitlyError) as exc_info:
74
+ client._request("GET", "/v1/consent/x")
75
+ assert exc_info.value.status_code == 500
76
+
77
+
78
+ def test_client_context_manager():
79
+ with PermitlyClient("sk_live_testkey", base_url=BASE_URL) as c:
80
+ assert c._http is not None
81
+ assert c._http.is_closed
@@ -0,0 +1,223 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+ import respx
6
+
7
+ from permitly import PermitlyClient
8
+ from permitly.exceptions import AuthError, ValidationError
9
+ from permitly.models import ConsentRequest, PageResult
10
+
11
+ BASE_URL = "https://permitly.dev"
12
+ API_KEY = "sk_live_testkey"
13
+
14
+ SCOPE = {
15
+ "key": "send_email",
16
+ "label": "Send Emails",
17
+ "description": "Send emails on your behalf",
18
+ "reversible": True,
19
+ "risk_level": "medium",
20
+ }
21
+
22
+ CONSENT_CREATED = {
23
+ "id": "cr-uuid-001",
24
+ "consent_url": "https://permitly.dev/c/cr-uuid-001",
25
+ "status": "pending",
26
+ "expires_at": "2026-05-26T10:00:00+00:00",
27
+ }
28
+
29
+ CONSENT_FULL = {
30
+ "id": "cr-uuid-001",
31
+ "end_user_ref": "user-42",
32
+ "status": "approved",
33
+ "scopes": [SCOPE],
34
+ "created_at": "2026-05-25T10:00:00+00:00",
35
+ "decided_at": "2026-05-25T10:01:00+00:00",
36
+ "expires_at": "2026-05-26T10:00:00+00:00",
37
+ }
38
+
39
+
40
+ @pytest.fixture
41
+ def client():
42
+ return PermitlyClient(API_KEY, base_url=BASE_URL)
43
+
44
+
45
+ # --- request() ---
46
+
47
+ @respx.mock(base_url=BASE_URL)
48
+ def test_consent_request_returns_consent_request(respx_mock, client):
49
+ respx_mock.post("/v1/consent/request").mock(
50
+ return_value=httpx.Response(201, json={"data": CONSENT_CREATED})
51
+ )
52
+ result = client.consent.request(
53
+ end_user_ref="user-42",
54
+ scopes=[SCOPE],
55
+ redirect_url="https://app.example.com/callback",
56
+ )
57
+ assert isinstance(result, ConsentRequest)
58
+ assert result.id == "cr-uuid-001"
59
+ assert result.status == "pending"
60
+ assert result.consent_url == "https://permitly.dev/c/cr-uuid-001"
61
+
62
+
63
+ @respx.mock(base_url=BASE_URL)
64
+ def test_consent_request_sends_correct_payload(respx_mock, client):
65
+ route = respx_mock.post("/v1/consent/request").mock(
66
+ return_value=httpx.Response(201, json={"data": CONSENT_CREATED})
67
+ )
68
+ client.consent.request(
69
+ end_user_ref="user-42",
70
+ scopes=[SCOPE],
71
+ redirect_url="https://app.example.com/callback",
72
+ expires_in=3600,
73
+ )
74
+ body = json.loads(route.calls[0].request.content)
75
+ assert body["end_user_ref"] == "user-42"
76
+ assert body["expires_in"] == 3600
77
+ assert body["scopes"] == [SCOPE]
78
+
79
+
80
+ @respx.mock(base_url=BASE_URL)
81
+ def test_consent_request_raises_validation_error(respx_mock, client):
82
+ respx_mock.post("/v1/consent/request").mock(
83
+ return_value=httpx.Response(422, json={
84
+ "error": {
85
+ "code": "validation_error",
86
+ "message": "The given data was invalid.",
87
+ "errors": {"scopes": ["The scopes field is required."]}
88
+ }
89
+ })
90
+ )
91
+ with pytest.raises(ValidationError) as exc_info:
92
+ client.consent.request(end_user_ref="u", scopes=[], redirect_url="https://x.com/cb")
93
+ assert "scopes" in exc_info.value.errors
94
+
95
+
96
+ @respx.mock(base_url=BASE_URL)
97
+ def test_consent_request_raises_auth_error(respx_mock, client):
98
+ respx_mock.post("/v1/consent/request").mock(
99
+ return_value=httpx.Response(401, json={"error": {"code": "invalid_api_key", "message": "Unauthorized."}})
100
+ )
101
+ with pytest.raises(AuthError):
102
+ client.consent.request(end_user_ref="u", scopes=[SCOPE], redirect_url="https://x.com/cb")
103
+
104
+
105
+ # --- get() ---
106
+
107
+ @respx.mock(base_url=BASE_URL)
108
+ def test_consent_get_returns_full_consent_request(respx_mock, client):
109
+ respx_mock.get("/v1/consent/cr-uuid-001").mock(
110
+ return_value=httpx.Response(200, json={"data": CONSENT_FULL})
111
+ )
112
+ result = client.consent.get("cr-uuid-001")
113
+ assert isinstance(result, ConsentRequest)
114
+ assert result.end_user_ref == "user-42"
115
+ assert result.status == "approved"
116
+ assert len(result.scopes) == 1
117
+
118
+
119
+ # --- verify() ---
120
+
121
+ @respx.mock(base_url=BASE_URL)
122
+ def test_consent_verify_returns_claims(respx_mock, client):
123
+ claims = {
124
+ "sub": "user-42",
125
+ "agt": "agent-uuid",
126
+ "cid": "cr-uuid-001",
127
+ "scopes": ["send_email"],
128
+ "iat": 1716631200,
129
+ "exp": 1716717600,
130
+ "jti": "jti-uuid",
131
+ }
132
+ respx_mock.post("/v1/consent/verify").mock(
133
+ return_value=httpx.Response(200, json={"data": claims})
134
+ )
135
+ result = client.consent.verify("eyJhbGciOiJFUzI1NiJ9.test.sig")
136
+ assert result["sub"] == "user-42"
137
+ assert result["cid"] == "cr-uuid-001"
138
+
139
+
140
+ @respx.mock(base_url=BASE_URL)
141
+ def test_consent_verify_sends_token_in_body(respx_mock, client):
142
+ route = respx_mock.post("/v1/consent/verify").mock(
143
+ return_value=httpx.Response(200, json={"data": {"sub": "u"}})
144
+ )
145
+ token = "eyJhbGciOiJFUzI1NiJ9.test.sig"
146
+ client.consent.verify(token)
147
+ body = json.loads(route.calls[0].request.content)
148
+ assert body["token"] == token
149
+
150
+
151
+ # --- revoke() ---
152
+
153
+ @respx.mock(base_url=BASE_URL)
154
+ def test_consent_revoke_returns_consent_request(respx_mock, client):
155
+ respx_mock.delete("/v1/consent/cr-uuid-001").mock(
156
+ return_value=httpx.Response(200, json={"data": {"id": "cr-uuid-001", "status": "revoked"}})
157
+ )
158
+ result = client.consent.revoke("cr-uuid-001")
159
+ assert isinstance(result, ConsentRequest)
160
+ assert result.status == "revoked"
161
+
162
+
163
+ # --- list() ---
164
+
165
+ @respx.mock(base_url=BASE_URL)
166
+ def test_consent_list_returns_page_result(respx_mock, client):
167
+ respx_mock.get("/v1/consent").mock(
168
+ return_value=httpx.Response(200, json={
169
+ "data": [{"id": "cr-1", "status": "pending", "end_user_ref": "u1", "created_at": "2026-05-25T10:00:00+00:00"}],
170
+ "meta": {"cursor": None, "has_more": False},
171
+ })
172
+ )
173
+ result = client.consent.list()
174
+ assert isinstance(result, PageResult)
175
+ assert len(result.data) == 1
176
+ assert isinstance(result.data[0], ConsentRequest)
177
+ assert not result.has_more
178
+ assert result.cursor is None
179
+
180
+
181
+ @respx.mock(base_url=BASE_URL)
182
+ def test_consent_list_sends_filters(respx_mock, client):
183
+ route = respx_mock.get("/v1/consent").mock(
184
+ return_value=httpx.Response(200, json={"data": [], "meta": {"cursor": None, "has_more": False}})
185
+ )
186
+ client.consent.list(status="approved", end_user_ref="user-42", limit=10)
187
+ params = dict(route.calls[0].request.url.params)
188
+ assert params["status"] == "approved"
189
+ assert params["end_user_ref"] == "user-42"
190
+ assert params["limit"] == "10"
191
+
192
+
193
+ @respx.mock(base_url=BASE_URL)
194
+ def test_consent_list_passes_cursor(respx_mock, client):
195
+ route = respx_mock.get("/v1/consent").mock(
196
+ return_value=httpx.Response(200, json={"data": [], "meta": {"cursor": "bmV4dA==", "has_more": True}})
197
+ )
198
+ result = client.consent.list(cursor="dGVzdA==")
199
+ params = dict(route.calls[0].request.url.params)
200
+ assert params["cursor"] == "dGVzdA=="
201
+ assert result.cursor == "bmV4dA=="
202
+ assert result.has_more
203
+
204
+
205
+ # --- manage_url() ---
206
+
207
+ @respx.mock(base_url=BASE_URL)
208
+ def test_consent_manage_url_returns_url(respx_mock, client):
209
+ respx_mock.get("/v1/consent/manage-url").mock(
210
+ return_value=httpx.Response(200, json={"data": {"url": "https://permitly.dev/manage/signed-token"}})
211
+ )
212
+ url = client.consent.manage_url("user-42")
213
+ assert url == "https://permitly.dev/manage/signed-token"
214
+
215
+
216
+ @respx.mock(base_url=BASE_URL)
217
+ def test_consent_manage_url_sends_end_user_ref(respx_mock, client):
218
+ route = respx_mock.get("/v1/consent/manage-url").mock(
219
+ return_value=httpx.Response(200, json={"data": {"url": "https://permitly.dev/manage/x"}})
220
+ )
221
+ client.consent.manage_url("user-42")
222
+ params = dict(route.calls[0].request.url.params)
223
+ assert params["end_user_ref"] == "user-42"
@@ -0,0 +1,47 @@
1
+ from permitly.models import ConsentRequest, AuditEvent, PageResult
2
+
3
+
4
+ def test_consent_request_required_fields():
5
+ cr = ConsentRequest(id="abc-123", status="pending")
6
+ assert cr.id == "abc-123"
7
+ assert cr.status == "pending"
8
+ assert cr.consent_url is None
9
+ assert cr.end_user_ref is None
10
+ assert cr.scopes is None
11
+ assert cr.created_at is None
12
+ assert cr.decided_at is None
13
+ assert cr.expires_at is None
14
+
15
+
16
+ def test_consent_request_full_fields():
17
+ cr = ConsentRequest(
18
+ id="abc-123",
19
+ status="approved",
20
+ consent_url="https://permitly.dev/c/abc-123",
21
+ end_user_ref="user-42",
22
+ scopes=[{"key": "send_email", "label": "Send Emails"}],
23
+ created_at="2026-05-25T10:00:00+00:00",
24
+ decided_at="2026-05-25T10:01:00+00:00",
25
+ expires_at="2026-05-26T10:00:00+00:00",
26
+ )
27
+ assert cr.end_user_ref == "user-42"
28
+ assert len(cr.scopes) == 1
29
+
30
+
31
+ def test_audit_event_fields():
32
+ event = AuditEvent(
33
+ id="evt-1",
34
+ event_type="approved",
35
+ actor="user",
36
+ consent_id="cr-1",
37
+ created_at="2026-05-25T10:00:00+00:00",
38
+ )
39
+ assert event.event_type == "approved"
40
+ assert event.consent_id == "cr-1"
41
+
42
+
43
+ def test_page_result_fields():
44
+ result = PageResult(data=[], cursor=None, has_more=False)
45
+ assert result.data == []
46
+ assert result.cursor is None
47
+ assert not result.has_more
@@ -0,0 +1,63 @@
1
+ from permitly.scope_templates import ScopeTemplates
2
+
3
+ REQUIRED_KEYS = {"key", "label", "description", "reversible", "risk_level"}
4
+ VALID_RISK_LEVELS = {"low", "medium", "high"}
5
+
6
+ ALL_TEMPLATES = [
7
+ ScopeTemplates.SEND_EMAIL,
8
+ ScopeTemplates.READ_EMAIL,
9
+ ScopeTemplates.READ_CALENDAR,
10
+ ScopeTemplates.WRITE_CALENDAR,
11
+ ScopeTemplates.BOOK_APPOINTMENT,
12
+ ScopeTemplates.SEND_MESSAGE,
13
+ ScopeTemplates.READ_FILES,
14
+ ScopeTemplates.WRITE_FILES,
15
+ ScopeTemplates.MAKE_PURCHASE,
16
+ ScopeTemplates.SUBMIT_FORM,
17
+ ]
18
+
19
+
20
+ def test_all_ten_templates_exist():
21
+ assert len(ALL_TEMPLATES) == 10
22
+
23
+
24
+ def test_each_template_has_required_keys():
25
+ for template in ALL_TEMPLATES:
26
+ assert REQUIRED_KEYS == set(template.keys()), f"Missing keys in {template}"
27
+
28
+
29
+ def test_each_template_has_valid_risk_level():
30
+ for template in ALL_TEMPLATES:
31
+ assert template["risk_level"] in VALID_RISK_LEVELS
32
+
33
+
34
+ def test_reversible_is_boolean():
35
+ for template in ALL_TEMPLATES:
36
+ assert isinstance(template["reversible"], bool)
37
+
38
+
39
+ def test_keys_are_snake_case():
40
+ for template in ALL_TEMPLATES:
41
+ key = template["key"]
42
+ assert key == key.lower()
43
+ assert " " not in key
44
+
45
+
46
+ def test_high_risk_templates_are_not_reversible():
47
+ for template in ALL_TEMPLATES:
48
+ if template["risk_level"] == "high":
49
+ assert template["reversible"] is False, f"{template['key']} is high-risk but reversible=True"
50
+
51
+
52
+ def test_send_email_shape():
53
+ t = ScopeTemplates.SEND_EMAIL
54
+ assert t["key"] == "send_email"
55
+ assert t["risk_level"] == "medium"
56
+ assert t["reversible"] is True
57
+
58
+
59
+ def test_make_purchase_shape():
60
+ t = ScopeTemplates.MAKE_PURCHASE
61
+ assert t["key"] == "make_purchase"
62
+ assert t["risk_level"] == "high"
63
+ assert t["reversible"] is False
@@ -0,0 +1,38 @@
1
+ import hashlib
2
+ import hmac
3
+
4
+ from permitly.webhook import verify_signature
5
+
6
+
7
+ SECRET = "whsec_test_secret_key_32byteslong"
8
+ PAYLOAD = '{"event":"consent.approved","data":{"id":"cr-123"}}'
9
+
10
+
11
+ def _make_sig(payload: str, secret: str) -> str:
12
+ digest = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
13
+ return f"sha256={digest}"
14
+
15
+
16
+ def test_valid_signature_returns_true():
17
+ sig = _make_sig(PAYLOAD, SECRET)
18
+ assert verify_signature(PAYLOAD, sig, SECRET) is True
19
+
20
+
21
+ def test_invalid_signature_returns_false():
22
+ assert verify_signature(PAYLOAD, "sha256=deadbeef", SECRET) is False
23
+
24
+
25
+ def test_wrong_secret_returns_false():
26
+ sig = _make_sig(PAYLOAD, "wrong_secret")
27
+ assert verify_signature(PAYLOAD, sig, SECRET) is False
28
+
29
+
30
+ def test_tampered_payload_returns_false():
31
+ sig = _make_sig(PAYLOAD, SECRET)
32
+ tampered = PAYLOAD.replace("approved", "revoked")
33
+ assert verify_signature(tampered, sig, SECRET) is False
34
+
35
+
36
+ def test_missing_sha256_prefix_returns_false():
37
+ raw_digest = hmac.new(SECRET.encode(), PAYLOAD.encode(), hashlib.sha256).hexdigest()
38
+ assert verify_signature(PAYLOAD, raw_digest, SECRET) is False