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.
- permitly-0.1.0/.gitignore +28 -0
- permitly-0.1.0/PKG-INFO +10 -0
- permitly-0.1.0/pyproject.toml +23 -0
- permitly-0.1.0/src/permitly/__init__.py +18 -0
- permitly-0.1.0/src/permitly/client.py +82 -0
- permitly-0.1.0/src/permitly/exceptions.py +33 -0
- permitly-0.1.0/src/permitly/models.py +55 -0
- permitly-0.1.0/src/permitly/resources/__init__.py +0 -0
- permitly-0.1.0/src/permitly/resources/audit.py +49 -0
- permitly-0.1.0/src/permitly/resources/consent.py +137 -0
- permitly-0.1.0/src/permitly/scope_templates.py +73 -0
- permitly-0.1.0/src/permitly/webhook.py +27 -0
- permitly-0.1.0/tests/__init__.py +0 -0
- permitly-0.1.0/tests/conftest.py +11 -0
- permitly-0.1.0/tests/test_audit.py +75 -0
- permitly-0.1.0/tests/test_client.py +81 -0
- permitly-0.1.0/tests/test_consent.py +223 -0
- permitly-0.1.0/tests/test_models.py +47 -0
- permitly-0.1.0/tests/test_scope_templates.py +63 -0
- permitly-0.1.0/tests/test_webhook.py +38 -0
|
@@ -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
|
permitly-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|